Self-hosted HTTP tunnels with SSH and Nginx
A write-up shows self-hosted HTTP tunnels using OpenSSH reverse forwards and nginx signed URLs.
Vincent Bernat describes a self-hosted way to share a localhost preview using only OpenSSH and nginx, as an alternative to ngrok, Cloudflare Quick Tunnels, frp, localtunnel, or sish. An SSH remote forward allocates an ephemeral port, and nginx proxies a matching HTTPS hostname to that port with a wildcard certificate. Access is limited with nginx's secure-link module, which checks a base64 hash and expiry carried in the URL username, plus a helper that discovers the allocated port.
- Uses OpenSSH remote forwarding with port 0 so the server picks a free port.
- nginx maps pNNNNN subdomains to the forwarded local port over HTTPS.
- Wildcard DNS and a Let's Encrypt certificate cover the tunnel hostnames.
- ngx_http_secure_link_module checks a timed hash sent as HTTP basic auth.
- A helper script finds the sshd listening port and prints the signed URL.
Full article1,094 words · extracted from vincent.bernat.ch · click to collapse
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-R0:localhost:8080http-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-R0:localhost:8080web02.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{
listen0.0.0.0:443ssl;
listen[::0]:443ssl;
server_name~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
location/{
proxy_passhttp://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.CNAMEweb02.luffy.cx.
ssh.luffy.cx.CAA0issuewild"letsencrypt.org"
_acme-challenge.ssh.luffy.cxCNAMEssh.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$portZuPerS3cr3!";
}
}
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{
listen0.0.0.0:443ssl;
listen[::0]:443ssl;
server_name~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
location/{
secure_link$httpssh_link;
secure_link_md5"$secure_link_expires$portZuPerS3cr3!";
if($secure_link=""){
add_headerWWW-Authenticate'Basicrealm="tunnel"'always;
return401;
}
if($secure_link="0"){
return410;
}
proxy_passhttp://127.0.0.1:$port;
proxy_set_headerHost$host;
proxy_set_headerX-Forwarded-For$proxy_add_x_forwarded_for;
proxy_set_headerAuthorization"";
proxy_http_version1.1;
proxy_set_headerUpgrade$http_upgrade;
proxy_set_headerConnection"upgrade";
proxy_bufferingoff;
proxy_read_timeout30m;
}
}
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"\
> |opensslmd5-binary\
> |opensslbase64\
> |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"-gt1];do
line=$(ps-ocomm=,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
exit1
fi
Then, we get the listening ports associated with these sshd-session
processes:8
ports=$(sudo-nss--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
exit1
fi
Finally, we display the URLs and keep the session open:
lifetime=86400
secret='ZuPerS3cr3!'
expires=$(($(date+%s)+lifetime))
forportin$ports;do
token=$(printf'%s %s %s'"$expires""$port""$secret"\
|opensslmd5-binary\
|opensslbase64\
|tr+/-_|tr-d=)
echo"https://$token--$expires@p$port.ssh.luffy.cx/"
done
sleepinfinity
I install this script as http-over-ssh on the server and add this entry to my
~/.ssh/config:
Hosthttp-over-ssh
Hostnameweb02.luffy.cx
RemoteCommandhttp-over-ssh
ControlPathnone
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. ❄️