Self-hosted HTTP tunnels with SSH and nginx
Vincent Bernat
A friend wants to proofread your work-in-progress blog post, but its preview
only runs on localhost:8080. Several tools can help. Some run as a
commercial service, like ngrok or Cloudflare Quick Tunnels. Some are
self-hostable but require a specific client, like frp or localtunnel.
Some only require a plain SSH client but rely on a specific SSH server, like
sish. Let’s implement a self-hosted solution with only OpenSSH and
nginx!
$ ssh -R 0:localhost:8080 http-over-ssh
Allocated port 41535 for remote forward to localhost:8080
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/
Basic setup#
First, we forward connections from a port on a remote server to your local service:
$ ssh -N -R 0:localhost:8080 web02.luffy.cx
Allocated port 41535 for remote forward to localhost:8080
When you specify 0 as the remote port, the server allocates a free port.
Then, we configure nginx to proxy requests from https://p41535.ssh.luffy.cx to
http://127.0.0.1:41535:
server {
listen 0.0.0.0:443 ssl ;
listen [::0]:443 ssl ;
server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
location / {
proxy_pass http://127.0.0.1:$port;
}
}
We also need to add DNS records for *.ssh.luffy.cx and get a wildcard
certificate through Let’s Encrypt:
*.ssh.luffy.cx. CNAME web02.luffy.cx.
ssh.luffy.cx. CAA 0 issuewild "letsencrypt.org"
_acme-challenge.ssh.luffy.cx CNAME ssh.luffy.cx.acme.luffy.cx.
acme.luffy.cx is a zone hosted on Route 53. I use it for ACME DNS-01
challenges, both for wildcard certificates and for domains served by
several web servers. In my case, NixOS gets the certificates
automatically.
Access control#
The port is the only “secret”1 keeping the content confidential. Other forwarding solutions add a random string to the domain name to prevent an intruder from enumerating the possible values.
Thanks to ngx_http_secure_link_module, we can secure
this setup a bit. This module computes a hash2 over a set of values,
including a secret, and compares it with the hash from the request. The hash is
base64-encoded, so we cannot put it in the domain name, which is
case-insensitive. Instead, we put it in the URL as a username, along with its
expiration timestamp:3
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog
╰─────────┬──────────╯ ╰───┬────╯ ╰─┬─╯ ╰──┬───╯
hash expires port path
The client sends the username to the server with HTTP basic
authentication. This works with most HTTP clients, including curl.
Nginx exposes the username in the $remote_user variable. The module expects
the hash and the expiration timestamp separated by a comma. We use a map
directive to extract the two parts from $remote_user and join them with a
comma.4 We also give the module the string to hash. It contains the
expiration timestamp, the port, and a secret:
map $remote_user $httpssh_link {
"~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
# […]
location / {
secure_link $httpssh_link;
secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
}
}
The module returns the status of the check in the $secure_link variable:
- empty if the hashes do not match,
"0"if they match but the link has expired, or"1"otherwise.
If the hash is incorrect or missing, we return a 401 error with a
WWW-Authenticate header to ask for credentials. If the link has expired, we
return a 410 error. We remove the Authorization header before forwarding the
request and add a few directives to proxy WebSocket connections.
Here is the complete configuration:5
map $remote_user $httpssh_link {
"~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
listen 0.0.0.0:443 ssl ;
listen [::0]:443 ssl ;
server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
location / {
secure_link $httpssh_link;
secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
if ($secure_link = "") {
add_header WWW-Authenticate 'Basic realm="tunnel"' always;
return 401;
}
if ($secure_link = "0") {
return 410;
}
proxy_pass http://127.0.0.1:$port;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization "";
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 30m;
}
}
I think you are now asking yourself the obvious question: “How should I generate the hash?” Easy peasy!
$ expires=$(( $(date +%s) + 86400 ))
$ port=41535
$ secret='ZuPerS3cr3!'
$ printf '%s %s %s' "$expires" "$port" "$secret" \
> | openssl md5 -binary \
> | openssl base64 \
> | tr +/ -_ | tr -d =
6J3jK1WmB15c6WmjW_X-Wg
Well, I suppose you are now saying: “Vincent, this is not very convenient! I’ll stick with ngrok if you don’t mind.” Okay, I hear you. Let’s write a helper script.
Helper script#
The main difficulty is finding the ephemeral port that OpenSSH allocates, as it
does not appear in any environment variable.6 To work around this obstacle,
we look for the ancestor sshd-session processes:7
pids=$(
pid=$$
while [ "$pid" -gt 1 ]; do
line=$(ps -o comm=,pid=,ppid= -p "$pid")
echo "$line"
pid=${line##* }
done | awk '$1 == "sshd-session" { printf "pid=%s,\n", $2 }'
)
if [ -z "$pids" ]; then
echo "not an ssh session" >&2
exit 1
fi
Then, we get the listening ports associated with these sshd-session
processes:8
ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \
| grep -F "$pids" \
| awk '{ print $4 }' | awk -F: '{ print $NF }' \
| sort -un)
if [ -z "$ports" ]; then
echo "no forwarded port, use ssh -R 0:localhost:PORT" >&2
exit 1
fi
Finally, we display the URLs and keep the session open:
lifetime=86400
secret='ZuPerS3cr3!'
expires=$(( $(date +%s) + lifetime ))
for port in $ports; do
token=$(printf '%s %s %s' "$expires" "$port" "$secret" \
| openssl md5 -binary \
| openssl base64 \
| tr +/ -_ | tr -d =)
echo "https://$token--$expires@p$port.ssh.luffy.cx/"
done
sleep infinity
I install this script as http-over-ssh on the server and add this entry to my
~/.ssh/config:
Host http-over-ssh
Hostname web02.luffy.cx
RemoteCommand http-over-ssh
ControlPath none
With this solution, I only rely on OpenSSH and nginx, two pieces of software
already running on this server. One short command gives me a self-hosted tunnel
and a URL to share. To try it, grab the complete helper script, which
includes a few minor improvements. If you run NixOS, as any person of taste
would, have a look at my http-over-ssh.nix instead. ❄️