Self-hosted HTTP tunnels with SSH and nginx

Self-hosted HTTP tunnels with SSH and nginx

使用 SSH 和 Nginx 自建 HTTP 隧道

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!

朋友想校对你正在撰写的博客文章,但预览版只能在 localhost:8080 上运行。市面上有许多工具可以提供帮助:有些是商业服务,如 ngrok 或 Cloudflare Quick Tunnels;有些可以自建但需要特定的客户端,如 frp 或 localtunnel;还有些仅需普通的 SSH 客户端,但依赖特定的 SSH 服务器,如 sish。让我们仅使用 OpenSSH 和 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:

当你将远程端口指定为 0 时,服务器会自动分配一个空闲端口。接着,我们配置 Nginx,将来自 https://p41535.ssh.luffy.cx 的请求代理到 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 添加 DNS 记录,并通过 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.

acme.luffy.cx 是托管在 Route 53 上的一个区域。我将其用于 ACME DNS-01 验证,既用于通配符证书,也用于由多个 Web 服务器托管的域名。在我的案例中,NixOS 会自动获取这些证书。

Access control

访问控制

The port is the only “secret” 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 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:

端口是保持内容机密的唯一“秘密”。其他转发方案会在域名中添加随机字符串,以防止入侵者枚举可能的端口值。得益于 ngx_http_secure_link_module,我们可以稍微加强此设置的安全性。该模块会对一组值(包括一个密钥)进行哈希计算,并将其与请求中的哈希值进行比较。由于哈希值是 base64 编码的,我们不能将其放入不区分大小写的域名中。相反,我们将其作为用户名放入 URL 中,并附带过期时间戳:

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. We also give the module the string to hash. It contains the expiration timestamp, the port, and a secret:

客户端通过 HTTP 基本认证将用户名发送给服务器。这适用于大多数 HTTP 客户端,包括 curl。Nginx 会将用户名暴露在 $remote_user 变量中。该模块要求哈希值和过期时间戳用逗号分隔。我们使用 map 指令从 $remote_user 中提取这两部分,并用逗号连接它们。我们还向模块提供需要哈希的字符串,其中包含过期时间戳、端口和一个密钥:

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:

该模块在 $secure_link 变量中返回检查状态:如果哈希不匹配则为空,如果匹配但链接已过期则为 “0”,否则为 “1”。如果哈希不正确或缺失,我们返回 401 错误并附带 WWW-Authenticate 头以请求凭据。如果链接已过期,我们返回 410 错误。我们在转发请求前移除了 Authorization 头,并添加了一些指令来代理 WebSocket 连接。以下是完整配置:

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.

好吧,我想你现在肯定在说:“Vincent,这太不方便了!如果不介意的话,我还是用 ngrok 吧。” 没问题,我听到了。让我们写一个辅助脚本吧。

Helper script

辅助脚本

The main difficulty is finding the ephemeral port that OpenSSH allocates, as it does not appear in any environment variable. To work around this obstacle, we look for the ancestor sshd-session processes:

主要的难点在于找到 OpenSSH 分配的临时端口,因为它不会出现在任何环境变量中。为了绕过这个障碍,我们查找父进程 sshd-session:

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:

然后,我们获取与这些 sshd-session 进程关联的监听端口:

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:

最后,我们显示 URL 并保持会话开启:

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:

我将此脚本作为 http-over-ssh 安装在服务器上,并将以下条目添加到我的 ~/.ssh/config 中:

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