Self-hosted HTTP tunnels with SSH and nginx


A buddy needs to proofread your work-in-progress weblog publish, however its preview
solely runs on localhost:8080. Several tools might help. Some run as a
business service, like ngrok or Cloudflare Quick Tunnels. Some are
self-hostable however require a selected shopper, like frp or localtunnel.
Some solely require a plain SSH shopper however depend on a selected SSH server, like
sish. Let’s implement a self-hosted answer with solely OpenSSH and
nginx!

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

First, we ahead connections from a port on a distant server to your native
service:

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

When you specify 0 because the distant 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 {
  hear 0.0.0.0:443 ssl ;
  hear [::0]:443 ssl ;
  server_name ~^p(?ddddd).ssh.luffy.cx$;
  location / {
    proxy_pass http://127.0.0.1:$port;
  }
}

We additionally want so as to add DNS information for *.ssh.luffy.cx and get a wildcard
certificates by 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 take advantage of it for ACME DNS-01
challenges
, each for wildcard certificates and for domains served by
a number of net servers. In my case, NixOS gets the certificates
automatically
.

The port is the one “secret”1 protecting the content material confidential. Other
forwarding options add a random string to the area identify to stop an
intruder from enumerating the attainable values.

Thanks to ngx_http_secure_link_module, we will safe
this setup a bit. This module computes a hash2 over a set of values,
together with a secret, and compares it with the hash from the request. The hash is
base64-encoded, so we can’t put it within the area identify, which is
case-insensitive. Instead, we put it within the URL as a username, together with its
expiration timestamp:3

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

The shopper sends the username to the server with HTTP basic
authentication
. This works with most HTTP shoppers, together with curl.
Nginx exposes the username within 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 2 elements from $remote_user and be a part of them with a
comma.4 We additionally give the module the string to hash. It incorporates 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 standing of the verify within the $secure_link variable:

  • empty if the hashes don’t match,
  • "0" in the event that they match however the hyperlink has expired, or
  • "1" in any other case.

If the hash is wrong or lacking, we return a 401 error with a
WWW-Authenticate header to ask for credentials. If the hyperlink has expired, we
return a 410 error. We take away the Authorization header earlier than forwarding the
request and add a number of directives to proxy WebSocket connections.
Here is the whole configuration:5

map $remote_user $httpssh_link {
  "~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
  hear 0.0.0.0:443 ssl ;
  hear [::0]:443 ssl ;
  server_name ~^p(?ddddd).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"' all the time;
      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 "improve";
    proxy_buffering off;
    proxy_read_timeout 30m;
  }
}

I feel you are actually asking your self the plain query: “How ought to 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 actually saying: “Vincent, this isn’t very handy! I’ll stick
with ngrok for those who don’t thoughts.” Okay, I hear you. Let’s write a helper script.

The major issue is discovering the ephemeral port that OpenSSH allocates, because it
doesn’t seem in any atmosphere variable.6 To work round this impediment,
we search for the ancestor sshd-session processes:7

pids=$(
  pid=$$
  whereas [ "$pid" -gt 1 ]; do
    line=$(ps -o comm=,pid=,ppid= -p "$pid")
    echo "$line"
    pid=${line##* }
  executed | 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 related to 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 }' 
  | type -un)
if [ -z "$ports" ]; then
  echo "no forwarded port, use ssh -R 0:localhost:PORT" >&2
  exit 1
fi

Finally, we show the URLs and maintain 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/"
executed
sleep infinity

I set up 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 answer, I solely depend on OpenSSH and nginx, two items of software program
already operating on this server. One brief command provides me a self-hosted tunnel
and a URL to share. To strive it, seize the complete helper script, which
features a few minor enhancements. If you run NixOS, as any particular person of style
would, take a look at my http-over-ssh.nix as an alternative. ❄️



Source link