Securing Local Web Services
―
I have a server at home running a few services that have web-based interfaces. They’re only available within my LAN, but I still want them to use HTTPS/TLS, because software increasingly wants to use HTTPS all the time, and it’s good defense in depth. For a while, I used self-signed certificates, but with the increasing prevalence of devices like phones and smart TVs that don’t provide convenient ways to install custom certificates, I wanted a different approach, and I recently implemented a solution that closes that gap.
Prerequisites🔗
The prerequisite needed to implement this is a real domain somewhere with DNS that you can control via an API. Fortunately I have this, as wadny.com uses Cloudflare DNS, which has an API.
Additionally, you’ll want stable LAN IP address(es) for your server. I’ll show a typical example for IPv4 and a unique local address for IPv6. If you don’t have stable addresses for some reason (maybe you’re in a college dorm and everything is dynamically assigned), there are dynamic DNS clients that can identify your address(es) automatically and set them in DNS, which again requires some sort of API support. If you go this route, make sure the client is appropriately configured to find your LAN address(es) if you don’t want things available on the public internet.
Step One: Add DNS Records🔗
The first step involves DNS, but not necessarily via an API yet. Create a sub-domain and wildcard record underneath it for your local services, with A records for your IPv4 LAN address and, if you have IPv6, AAAA records for that address. For example:
| Record name | Record type | Value |
|---|---|---|
local.example.com |
A |
10.0.0.1 |
*.local.example.com |
A |
10.0.0.1 |
local.example.com |
AAAA |
fd12:3456:7890:1234::1 |
*.local.example.com |
AAAA |
fd12:3456:7890:1234::1 |
These domains will be visible on the public internet, but the LAN IP addresses they point to will be conveniently meaningless to anyone else, so services will remain inaccessible to anyone outside your LAN. If you do want things accessible on the public internet, you need to use your public IP address and you need to be reachable from the internet. This may already be the case with IPv6, but in some cases with IPv4 you might have a bigger problem to solve.
Step Two: Install Web Server🔗
The second step is to install a web server, such as nginx. To start with, it can listen on regular HTTP port 80 with some static placeholder content. In my case, I just installed the package provided by Debian using sudo apt install nginx and the default configuration came with a placeholder page. At this point, you should be able to reach your server via http://local.example.com or http://anything-you-want.local.example.com.
Step Three: Get TLS Certificate🔗
The third step is to get a TLS certificate from a real certificate authority, such as Let’s Encrypt. There are tools that can automate this process, such as Certbot. These clients have different ways of proving to the authority that you own and control the domain you’re requesting a certificate for, and different ways of setting up the certificate with your web server. Debian comes with a package for Certbot, which I would prefer to use if possible, although I ended up installing it manually due to some confusion trying to get it working.
Since, in this case, the web server is not available on the public internet, you need to use a DNS-based challenge to prove to the authority that you control the domain, which is why your DNS needs an API. If you use Certbot, it has a number of DNS plugins for interacting with different DNS services; choose the appropriate one, and make sure to install it.
Certbot is not designed to just set up a configuration file and let it run as a service, which is a bit confusing. You need to use a complicated certbot run command with a lot of arguments to set things up, but once the certificate has been obtained the first time, Certbot saves records of it and the options needed to refresh it so you don’t need to run the full command again. Instead, a cron or timer job can run certbot renew and it will automatically renew any certificates that are about to expire.
To install the certificate, do not use the --nginx argument! This is a plugin for validating the domain, not installing the certificate, and since the server is not publically accessible, it will result in validation failing! Instead, you want -i nginx to specify installation to nginx.
As an example, you might end up with: sudo certbot run --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare-credentials.ini -d local.example.com -d "*.local.example.com" -i nginx -v
Step Four: Use TLS Certificate🔗
Certbot installed the certificate by modifying /etc/nginx/sites-available/default but since we want to run multiple sites with TLS, this is not a great approach. I pulled its changes out of there and made /etc/nginx/conf.d/certbot.conf instead, with the SSL certificate settings and the include /etc/letsencrypt/options-ssl-nginx.conf; statement. Note that that included file duplicated some settings that are in /etc/nginx/nginx.conf by default, which nginx considers a configuration error; I changed /etc/nginx/nginx.conf to remove the duplicate SSL settings, rather than modifying the Let’s Encrypt file, in case Certbot regenerates it during renew.
We want to end up with multiple server configurations: one for each service being proxied, a default for anything else, and a non-TLS one just in case someone or something uses http: instead of https:. The “right” way to do this is to create multiple files in /etc/nginx/sites-available/ (one for each server block) and symlink them into /etc/nginx/sites-enabled/. I removed the link for the default site and left it alone as an example, and created a new file for each server block.
The default server should listen on port 443 using SSL/TLS, and use the fallback server name so that everything directs to it unless there’s a more precise match:
server {
listen [fd12:3456:7890:1234::1]:443 ssl default_server;
listen 10.0.0.1:443 ssl default_server;
server_name _;
# Other settings here, e.g. root and locations
}
The non-TLS server will redirect everything to https: for convenience:
server {
listen [fd12:3456:7890:1234::1] default_server;
listen 10.0.0.1 default_server;
server_name _;
# Rewrite all requests to HTTPS
rewrite ^ https://$host$request_uri? permanent;
}
At this point you should now be able to reach https://local.example.com or https://anything-you-want.local.example.com from any sort of device on your LAN without having to install or deal with certificates. Also, if you use http: instead, it should automatically redirect you to the equivalent https: address. Awesome!
Step Five: Proxy Other Services🔗
Now you can add more server blocks to nginx that will reverse proxy to other services. Each service may have its own guidance on what you need to configure, which could include special headers, multiple locations, different protocols, and all sorts of complexity that I can’t predict. A basic example would look like this:
server {
listen [fd12:3456:7890:1234::1]:443 ssl;
listen 10.0.0.1:443 ssl;
server_name service.local.example.com;
http2 on;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
}
}
Note that we no longer have default_server in the listen statements, and we also have a full domain in server_name so that this specific domain will proxy to a new service.
If your service was previously set up to listen on your LAN, you can change it to only listen on the lo loopback interface or loopback IP addresses (e.g. 127.0.0.1 or ::1) so that users have to go through the proxy. This is generally a good idea, as nginx is a safer HTTP server than what may be built in to any particular service.
If your service supports HTTPS, you can keep using it between nginx and the service for full end-to-end encryption. You may need to re-generate a self-signed certificate to use localhost as the domain. You’ll need to configure nginx to accept the self-signed certificate from the service, adding some settings in the location block after proxy_pass:
proxy_ssl_trusted_certificate /$your_service_configuration/server.crt;
proxy_ssl_verify on;
proxy_ssl_session_reuse on;
Finally, you should be able to access https://service.local.example.com from any device on your LAN and have it be fully trusted automatically, without needing to install custom certificates.