Self-hosted HTTP tunnels with SSH and Nginx
TL;DR · AI 摘要
使用OpenSSH和Nginx搭建自托管HTTP隧道,实现本地服务的远程访问,无需依赖第三方商业工具。
核心要点
- 通过SSH -R 0:localhost:8080实现动态端口转发,结合Nginx反向代理暴露本地服务
- Nginx配置使用正则捕获动态端口,并通过ngx_http_secure_link_module实现带过期时间的哈希验证
- 使用CAA记录指定Let's Encrypt通配符证书签发,配合NixOS自动证书管理
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 自托管HTTP隧道
- SSH配置
- 动态端口分配
- 隧道建立命令
- Nginx设置
- 正则端口捕获
- 安全链接模块
- 安全措施
- 哈希验证
- CAA记录
- 证书管理
金句 / Highlights
值得收藏与分享的关键句。
使用ssh -R 0:localhost:8080命令自动分配端口,生成临时URL暴露本地服务
Nginx配置通过正则表达式捕获动态端口:server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$
安全模块实现带过期时间的哈希验证:hash=base64(Expires,Port,Secret),有效期控制在1小时
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 !
$
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:
$
ssh
-N
-R
0
:localhost:8080
web02.luffy.cx
Allocated port 41535 for remote forward to localhost:8080When 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 .
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
https://[email protected]/en/blog
╰─────────┬──────────╯ ╰───┬────╯ ╰─┬─╯ ╰──┬───╯
hash expires port pathThe 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-WgWell, 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
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
fiThen, 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
fiFinally, 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
infinityI 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
noneWith 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. ❄️