Self-hosted HTTP tunnels with SSH and Nginx

Hacker News by 6 min read 26x views
Self-hosted HTTP tunnels with SSH and Nginx

Share Post

A companion 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, akin ngrok or Cloudflare Quick Tunnels. Some are self-hostable but necessitate a particular client, akin frp or localtunnel. Some lone necessitate a plain SSH client but depend on a particular SSH server, like sish. Let’s execute a self-hosted resolution alongside lone OpenSSH and nginx!

$ ssh -R 0:localhost:8080 http-over-ssh Allocated harbor 41535 for distant onward to localhost:8080 https://[email protected]/ 

First, we onward connections from a harbor on a distant server to your local service:

$ ssh -N -R 0:localhost:8080 web02.luffy.cx Allocated harbor 41535 for distant onward to localhost:8080 

When you define 0 as the distant port, the server allocates a liberated 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 additionally 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 area hosted on Route 53. I use it for ACME DNS-01 challenges, the two for wildcard certificates and for domains served by several web servers. In my case, NixOS gets the certificates automatically.

The harbor is the lone “secret”1 keeping the satisfied confidential. Other forwarding solutions add a random cord to the domain name to forestall an intruder from enumerating the imaginable values.

Thanks to ngx_http_secure_link_module, we can secure this setup a bit. This component computes a hash2 complete a set of values, including a secret, and compares it alongside 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, alongside alongside its expiration timestamp:3

https://[email protected]/en/blog ╰─────────┬──────────╯ ╰───┬────╯ ╰─┬─╯ ╰──┬───╯ hash expires harbor path 

The client sends the username to the server alongside HTTP basic authentication. This plant alongside most HTTP clients, including curl. Nginx exposes the username in the $remote_user variable. The component expects the hash and the expiration timestamp divided by a comma. We use a map directive to extract the two parts from $remote_user and associate them alongside a comma.4 We additionally provision the component the cord 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 component returns the position of the inspect in the $secure_link variable:

  • empty if the hashes do not match,
  • "0" if they equivalent but the nexus has expired, or
  • "1" otherwise.

If the hash is incorrect or missing, we come back a 401 error alongside a WWW-Authenticate header to ask for credentials. If the nexus has expired, we return a 410 error. We eliminate 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 apparent 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 assume you are now saying: “Vincent, this is not extremely convenient! I’ll stick with ngrok if you don’t mind.” Okay, I comprehend you. Let’s compose a helper script.

The chief difficulty is finding the ephemeral harbor that OpenSSH allocates, as it does not appear in any surroundings variable.6 To activity about this obstacle, we appearance 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 connected alongside these sshd-session processes:8

ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \  | grep -F "$pids" \  | awk '{ imprint $4 }' | awk -F: '{ imprint $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 meeting 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 instal this manuscript as http-over-ssh on the server and add this admission to my ~/.ssh/config:

Host http-over-ssh  Hostname web02.luffy.cx  RemoteCommand http-over-ssh  ControlPath none 

With this solution, I lone depend on OpenSSH and nginx, two pieces of software already operating on this server. One abbreviated command gives me a self-hosted tunnel and a URL to share. To try it, catch the complete helper script, which includes a few insignificant improvements. If you run NixOS, as any individual of taste would, have a appearance at my http-over-ssh.nix instead. ❄️

Other Article Hacker News
↑
Close Right Ads
Close Left Ads