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
- Prerequisites
- Serving a static website
- Proxying a WebSocket server
- Adding encryption
- Conclusion
- Appendix
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.
-p ./
says to look for the configuration and other files in the present working directory.-c nginx.conf
says to usenginx.conf
as the configuration file.
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:
- As mentioned, self-signed certificates are generally considered untrusted by browsers. It would be best to have one signed by a certificate authority such as Let’s Encrypt.
- It’s generally less confusing to serve on the standard port, which for HTTPS is 443. I chose to use a different port just so I could quickly test as a non-root user, since Linux blocks use of ports up to 1024 by ordinary users.
- At least for me, nginx complains that it “could not build optimal types_hash”. As this didn’t actually affect any functionality, I didn’t feel it needed addressing in this post, but nginx does helpfully provide instructions on how to address it.
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