Custom Discord server part 1:
Client startup

Hi there!

Last week I was hit with a Schnapsidee. I have /etc/hosts and Rust has some great crates for creating web APIs, why not write a backend for Discord and trick the official client into talking to it. This is somewhat novel; there is a reimplementation of the Discord backend, but it doesn't yet use the official client, and also it's made with TypeScript ¯\_(ツ)_/¯.

This is Part 1 in a series exploring the Discord API, and reimplementing it. You can find the other parts here:

TLS shenanigans

First things first, let's get the client to connect to our server. To do this we need two things: an HTTPs server, and a certificate to fool the client into thinking we're actually discord.com. The HTTPs server is easy to do, I'm using Rust's warp, but any REST framework will do. The certificate is a bit more complicated. HTTPs certificates are usually signed by a certificate authority (CA), which in turn has a certificate signed by a root CA. I have neither of these, so any certificates that I create, would be rejected by any sane TLS implementation with a "self-signed certificate" error. Fair enough.

But how does the browser know about the root CA? Hard coding the root CA's certificate of course. And where there's a hard code, there is a way to change it. On macOS, this way is called Keychain Access.app, more generally it's referred to as a certificate store. Let's first create a certificate for discord.com at the IP 127.0.0.1 using OpenSSL's command-line utility:


$ openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
     -nodes -keyout key.pem -out cert.pem -subj "/CN=discord.com" \
     -addext "subjectAltName=DNS:discord.com,DNS:*.discord.com,IP:127.0.0.1"

The intricacies of this command aren't really important, and to be frank I don't get all of it either, just know that we generated our certificate into cert.pem and a key into key.pem. It'll be valid for 10 years, but by then we can just make another one.

I don't know how to register certificates in other certificate stores, but under macOS it's easily done with this command:


$ sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain cert.pem

Exploring Discord routes

Now, the HTTPs server I set up earlier is quite naive, it accepts any path, prints it out and returns it as a string with a 200 Ok. This won't make the Discord client believe we're an actual backend, but it's a good way of telling which endpoints are important in getting the client up and running. Running this and starting the Discord client, we can observe that it first tries to GET the /api/updates/stable endpoint and gets stuck at the "Update failed - retrying in x seconds" screen. This particular endpoint isn't listed in the official Discord API documentation, but putting it into a browser of your choice reveals that it doesn't require any authentication and happily returns


{
    "name": "0.0.291",
    "pub_date": "2024-01-09T18:25:05",
    "url": "https://dl.discordapp.net/apps/osx/0.0.291/Discord.zip",
    "notes": ""
}

You can check it out for yourself here. This kind of response is expected, because the Discord API uses JSON structures almost everywhere else. The only thing notable about it is that it defaulted to returning the download link for the macOS (osx) version of Discord. This happens regardless of user- agent, browser and operating system used.

Alright, let's try creating a new route that just returns this exact JSON string. Running it again, the Discord client doesn't get much further. The client shows a "Downloading update 1 of 1" for a short time period and then goes back to failing to update. Further investigation with mitmproxy reveals that, while the endpoint and JSON string are correct, the actual query the client sent looks a bit differently: /api/updates/stable?version=0.0.291&platform=osx

Let's put this into our browser again, aaaannd... nothing happens? The Discord API returns a 204 No Content. Apparently it detected the client is up to date and told it to just go ahead and start up. This is easily implemented. There are some further quirks, e.g. there are different versions for Windows (platform=win) and Linux (platform=linux) with their own "pub_date"s, both of which don't have a download link or the notes field, and obviously the API returns a 404 Not Found for any other platform string.

Forwarding Discord files

Trying to start the client again, still leaves us at the "Update failed" screen, but our server tells us that the client requested another file, namely /api/modules/stable/versions.json. Well, this is akward, I don't have that file. Of course I could go ahead and download this file from Discord and serve it as a normal file, but it is probably subject to change (updated every version) and it also needs the platform and version parameter from /api/updates/stable, so getting all versions.json for all versions and platforms could be quite a hassle. Additionally, the client will also ask us for JavaScript and CSS files, as well as PNG assets, which I simply don't want to all download.

A simple workaround is to just forward these files from the actual discord.com. Rust has several crates for making HTTP(s) request, but I'm going with reqwest. Before we can make any requests though, we have to solve one problem. The OS won't properly resolve discord.com for us, because we've overwritten that domain in /etc/hosts. That way of redirecting the client was kind of hacky anyways, so naturally we're going to leave it like that and simply resolve the domain ourselves with hickory. Conveniently, reqwest's ClientBuilder has a way of injecting these custom resolves into an HTTP client, so we don't have to worry about HTTPs certificates at all. This would be a problem if we had to send requests an IP directly.

Finally the client gets past the updater and launches into the main app. Looking into the servers debug log we can see that most of the JS code Discord uses is loaded from the web, including the main code from the ominously named /app endpoint. Looking around a bit we can see that all my servers (guilds) and friends are still available. I can't see old messages are send new ones, but I can see who's online and where I got new messages.

The gateway

The reason for this is Discord's gateway. The gateway handles any communication that goes from Discord's servers to your client, including stuff like receiving messages and typing indicators. Crucially, the first thing it does after a client connects, is send all the information about you, your guilds and friends that is necessary to display Discord's landing page. However, since the gateway is at a separate domain, gateway.discord.gg, the client still talks to the default Discord servers, and receives all the information about you.

This is the first post in a series I'll hopefully continue. Let's see how far I get in recreating Discord's API. Until then:

Farewell!

You can find the actual implementation of this post on GitHub.