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:8080When 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”<sup>1</sup> 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 hash<sup>2</sup> 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.<sup>4</sup> 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]+)quot; "$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]+)quot; "$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.<sup>6</sup> 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. ❄️