Low-effort WebSocket encryption using nginx

(Posted 2024-10-16 14:21 -0400)

This is a post on adding encryption to a WebSocket server without needing to change its code, using nginx. Some command line knowledge is assumed.

Background

Recently, I was looking into making a MUD, and decided I wanted to do it with a WebSocket service. Since it’s usual for modern web browsers to encrypt connections, I also wanted to make sure it supported TLS.

I decided to write the server in Haskell, but the websockets package I saw recommended didn’t support TLS, and the wuss package it suggested to add TLS support didn’t support servers! So, I did a web search to see if anyone else had run into this problem.

One StackOverflow answer suggested adding TLS after the fact using nginx. This sounded much more convenient than having to deal with TLS logic myself, so that’s what I set out to do, and what I’ll explain how I did in this post.

Prerequisites

You’ll need to have nginx installed.

You’ll also need a WebSocket server. For the sake of demonstration, we’ll use websocat, even though it already supports encryption.

On Arch Linux, you can install both of these from the “extra” repository with pacman -S nginx websocat.

On Debian-based systems, you can get nginx with apt install nginx. It looks like the latest websocat releases as of time of writing don’t include a .deb, but the 1.8.0 release does.

For other OSes, check the links above for instructions.

Serving a static website

Let’s start by serving up a single HTML file.

As a bonus requirement, I decided I wanted to be able to run nginx unprivileged (not as root), so let’s make a new directory somewhere convenient and cd into it.

In that directory, make a file called nginx.conf. We’ll add to it over the course of the post, but for now just add the following to it:

daemon off;

This will cause nginx to stay attached to the terminal you run it from, for ease of iteration.

Now let’s make the file we’re going to serve. In that same directory, make another directory called static (this name has no special significance) and a file static/index.html with the following content:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>hello nginx!</title>
    </head>
    <body>
        <p>hello nginx!</p>
        <script src="main.js"></script>
    </body>
</html>

(Don’t worry about the nonexistent script for now, we’ll get to it.)

After a quick glance through man nginx, it looks like we can use the command nginx -p ./ -c nginx.conf to run the server from this directory.

If we try to run it now, nginx should complain that there’s no “events” section. Let’s add this empty one to nginx.conf:

events {}

Running again, we get open() "/run/nginx.pid" failed. Add:

pid nginx.pid;

Now it should run without errors, but… it doesn’t do anything yet! That’s because we haven’t actually told nginx to be an HTTP server.

The Beginner’s Guide has a perfectly good section on this, whose advice we’ll adapt to our requirements:

http {
    server {
        location / { root static; }
    }
}

Running it now gives open() "/var/log/nginx/access.log" failed, so we’ll add another directive just inside the http block:

    access_log access.log;

You could also just as well put /dev/null in place of access.log if you don’t want to log incoming connections to a file.

Now run it one last time. It should stay running now. Has our hard work finally paid off? Use a browser to visit http://localhost:8000/ and find out! (We didn’t specify it, but 8000 is the default port, per the documentation.)

It loaded a page, right? If so, great! On to the next part! Otherwise, double check that nginx.conf looks something like this:

daemon off;
events {}
pid nginx.pid;

http {
    access_log access.log;

    server {
        location / { root static; }
    }
}

Proxying a WebSocket server

Next we’ll add WebSocket support to our server. There are some special considerations, which fortunately are covered by the WebSocket proxying page in the nginx documentation.

Let’s try using the first example there almost verbatim. Add this inside the server block, i.e., at the same level as the existing location block:

        location /chat/ {
            proxy_pass http://0.0.0.0:8001;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }

Note: If you plan for this website to face the public internet, you will not be allowing traffic to port 8001, because we won’t be encrypting that one!

If you try restarting the server and accessing http://localhost:8000/chat at this point, you should get “502 Bad Gateway”. This means it’s working! Sort of. The backend we told it to redirect to isn’t up.

While nginx is still running, start websocat -s 8001. Then refresh the page and you should get “Only WebSocket connections are welcome here”. This is websocat talking! Now we just need to use it with actual WebSockets.

Now we return to that missing script from earlier. Create static/main.js with the following contents:

'use strict'

var socket = new WebSocket('ws://' + location.host + '/chat/')

// When we successfully connect, send "hello, server!".
socket.addEventListener('open', function () {
    socket.send('hello, server!')
})

// When we get a message:
// - Add it to the page body.
// - If it contains "ping", repeat it back but with all instances of "ping"
// replaced with "pong".
socket.addEventListener('message', function (event) {
    var message = event.data
    var element = document.createElement('p')
    element.textContent = message
    document.body.appendChild(element)

    if (message.indexOf('ping') !== -1) {
        socket.send(message.replace(/ping/g, 'pong'))
    }
})

// As a bonus treat, the New Nintendo 3DS web browser can run this code fine!

Now, reload http://localhost:8000/ and check the output of websocat. You should see “hello, server!” has been received. Try typing various things into websocat’s terminal and watch them appear on the web page! You can even have multiple instances of the page open and they should all receive what you enter.

Your browser console may complain that the script is served as the wrong MIME type. This is probably not a fatal issue, but if it bothers you, add this to nginx.conf just inside the http block:

    include /etc/nginx/mime.types;

We’re almost there! Your nginx.conf should now look like this:

daemon off;
events {}
pid nginx.pid;

http {
    include /etc/nginx/mime.types;
    access_log access.log;

    server {
        location / { root static; }

        location /chat/ {
            proxy_pass http://0.0.0.0:8001;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}

Adding encryption

Right now, the website, including the WebSocket service, are served unencrypted. Our final step will be to add TLS to them.

Note: Encryption is really easy to mess up. I’m by no means an expert, so please consult other resources for best practices!

For purposes of this post, we’ll be generating a self-signed certificate. This isn’t good enough for a public website, because your browser will tell you it doesn’t trust the certificate, and it would defeat the point if your visitors ignored this warning! For that, you’ll likely want to use Let’s Encrypt or the like, which I won’t cover here.

Now that that warning is out of the way, let’s run the following command to generate what we need, which I cobbled together by looking at man openssl-req:

openssl req -x509 -nodes -subj '/' -out certificate.pem -keyout key.pem

This creates certificate.pem and key.pem in the current directory. Note that key.pem is a private key without a passphrase. As a rule, if someone else gets a copy of your private key, you should replace it as soon as possible.

Now we need to edit nginx.conf once more. Once again, the nginx documentation has some guidance. Just inside the server block, add:

        listen 8000 ssl;
        ssl_certificate certificate.pem;
        ssl_certificate_key key.pem;

Now nginx will use HTTPS protocol on that port, but there’s one other thing: We have to change the 'ws://' in the JavaScript code to 'wss://' to tell it to use secure WebSocket.

Restart nginx once more, double check that websocat -s 8001 is running, and then visit https://localhost:8000/. Your browser should warn you that the certificate isn’t trusted (If it doesn’t, consider switching to one that does!), but go ahead and click through anyway.

You should find… that nothing about the page changed! Mission accomplished!

Conclusion

As suggested by the StackOverflow answer, it was possible to take an unencrypted WebSocket service and add TLS support to it without fundamentally changing any of its logic; websocat -s 8001 was the only command we used to run that part of the setup.

Some things could be improved:

And of course, the HTML and JS we used were placeholders, as was websocat.

Ultimately my plan is to make a multiplayer browser game and, with this, I’m one step closer!

Thanks to redwing for beta reading this post!

Appendix

Here are the final states of all the files.

nginx.conf:

daemon off;
events {}
pid nginx.pid;

http {
    include /etc/nginx/mime.types;
    access_log access.log;

    server {
        listen 8000 ssl;
        ssl_certificate certificate.pem;
        ssl_certificate_key key.pem;

        location / { root static; }

        location /chat/ {
            proxy_pass http://0.0.0.0:8001;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}

static/index.html:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>hello nginx!</title>
    </head>
    <body>
        <p>hello nginx!</p>
        <script src="main.js"></script>
    </body>
</html>

static/main.js:

'use strict'

var socket = new WebSocket('wss://' + location.host + '/chat/')

// When we successfully connect, send "hello, server!".
socket.addEventListener('open', function () {
    socket.send('hello, server!')
})

// When we get a message:
// - Add it to the page body.
// - If it contains "ping", repeat it back but with all instances of "ping"
// replaced with "pong".
socket.addEventListener('message', function (event) {
    var message = event.data
    var element = document.createElement('p')
    element.textContent = message
    document.body.appendChild(element)

    if (message.indexOf('ping') !== -1) {
        socket.send(message.replace(/ping/g, 'pong'))
    }
})

// As a bonus treat, the New Nintendo 3DS web browser can run this code fine!

certificate.pem and key.pem don’t have fixed contents. Instead, they’re generated by this command:

openssl req -x509 -nodes -subj '/' -out certificate.pem -keyout key.pem