Hacker News Best

Self-hosted HTTP tunnels with SSH and Nginx

8.5内容质量

TL;DR · AI 摘要

使用OpenSSH和Nginx搭建自托管HTTP隧道,实现本地服务的远程访问,无需依赖第三方商业工具。

核心要点

  • 通过SSH -R 0:localhost:8080实现动态端口转发,结合Nginx反向代理暴露本地服务
  • Nginx配置使用正则捕获动态端口,并通过ngx_http_secure_link_module实现带过期时间的哈希验证
  • 使用CAA记录指定Let's Encrypt通配符证书签发,配合NixOS自动证书管理

结构提纲

按章节快速跳转。

  1. 介绍本地服务远程访问的常见解决方案及自托管方案的优势

  2. ·SSH端口转发

    演示如何使用OpenSSH实现动态端口转发的基本配置

  3. ·Nginx反向代理

    详细说明Nginx配置动态端口捕获和安全验证模块的使用

  4. 解释CNAME记录配置和通配符证书的获取流程

  5. 通过哈希验证和时间戳实现访问控制的详细实现

  6. 与ngrok等商业工具的性能和成本对比分析

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 自托管HTTP隧道
    • SSH配置
      • 动态端口分配
      • 隧道建立命令
    • Nginx设置
      • 正则端口捕获
      • 安全链接模块
    • 安全措施
      • 哈希验证
      • CAA记录
      • 证书管理

金句 / Highlights

值得收藏与分享的关键句。

  • 使用ssh -R 0:localhost:8080命令自动分配端口,生成临时URL暴露本地服务

    — 代码示例段落

    ⬇︎ 下载 PNG𝕏 分享到 X
  • Nginx配置通过正则表达式捕获动态端口:server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$

    — Nginx配置代码块

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 安全模块实现带过期时间的哈希验证:hash=base64(Expires,Port,Secret),有效期控制在1小时

    — 安全控制章节

    ⬇︎ 下载 PNG𝕏 分享到 X
#SSH#Nginx#HTTP隧道#自托管#网络安全
打开原文

Self-hosted HTTP tunnels with SSH and nginx

Vincent Bernat October 3, 2026

6-minute read

Also available in

  • français

Filed under

  • web

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 !

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

Basic setup #

First, we forward connections from a port on a remote server to your local service:

code
$
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 :

code
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 :

code
*.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 .

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 hash 2 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

code
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:

code
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

code
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!

code
$
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.

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

code
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

code
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:

code
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 :

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