使用 SSH 和 Nginx 搭建自托管 HTTP 隧道
Self-hosted HTTP tunnels with SSH and Nginx

原始链接: https://vincent.bernat.ch/en/blog/2026-http-over-ssh

本文介绍了一种自托管的 ngrok 替代方案,仅使用 OpenSSH 和 nginx 将本地主机上的服务暴露出去。SSH 远程端口转发命令(`ssh -N -R 0:localhost:8080 host`)会请求服务器分配一个临时端口。nginx 使用通配符 DNS 和证书,为每个分配到的端口代理 HTTPS 请求,并将其转发到对应的本地服务;辅助脚本负责检测端口、生成可分享的 URL,并维持会话。 由于临时端口的随机空间有限,该方案通过 `ngx_http_secure_link_module` 添加基于 MD5 的限时令牌。令牌作为 HTTP Basic Auth 的用户名传入,并由 nginx 验证;如果令牌缺失、无效或过期,服务器会返回 401 或 410。配置还会移除 Authorization 请求头、支持 WebSocket,并关闭代理缓冲。文中提供了 Shell 和 NixOS 两种实现,同时警告要注意敏感信息管理,并避免使用范围过于宽泛的端口转发。

The Hacker News 的讨论帖介绍了基于 SSH 和 Nginx 自托管的 HTTP 隧道。文章作者推荐了 **sish**:这是一款采用 MIT 许可证的 SSH 隧道服务器,支持自动 TLS、请求日志、HTTP(S)、WebSocket 和 TCP 隧道,并提供内部别名。其托管版本 **tuns.sh** 增加了多区域支持和 UDP 隧道。 commenters 更倾向于组合使用现有工具,而不是再引入一个守护进程;但也有人担心 URL 泄露、隧道被枚举、凭据暴露、会话长期遗留,以及 Nginx 配置可能意外代理任意本地端口。作者表示,后续步骤可以降低这些风险,不过仍有人建议采用更简单的高端口 SSH 转发。 其他建议包括: - 使用 **iroh** 建立加密的点对点连接,无需端口转发或公网 IP - 使用 **iroh-ssh** 在 CGNAT 后提供网页界面 - 使用双向 TLS 限制访问 - 采用类似 WireGuard 或 Tailscale 的私有网络 - 对于简单需求,使用传统的自托管静态服务器 最后, commenters 指出,直接托管静态内容可能更方便,但也会带来隐私、ISP 政策、攻击面和工作场所安全方面的问题。
相关文章

原文

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://[email protected]/

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:

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.

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://[email protected]/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 $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]+)$" "$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.

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" -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. ❄️

联系我们 contact @ memedata.com