SSH隧道

1 背景

现有三台机器,网络关系如下:

机器 地址 SSH 端口 说明
本地 PC - - 开发机(Windows)
服务器 10.0.206.124 32354 远程 Linux 服务器
台架 10.13.193.69 31936 测试台架,运行 SSH 服务

连通性:

  • 本地 PC → 服务器:可以,ssh -p 32354 root@10.0.206.124
  • 本地 PC → 台架:可以,ssh -p 31936 <台架用户名>@10.13.193.69
  • 服务器 → 台架:不可以,服务器路由不到台架所在的网段

现在的需求是:在服务器上操作台架。台架不能被服务器直接访问,怎么办?

2 解决方案

核心思路:在本地 PC 上建立一条 SSH 远程转发隧道,把服务器的 54321 端口映射到台架的 31936 端口。之后在服务器上访问自己的 127.0.0.1:54321,流量就会经本地 PC 中转,最终到达台架。

1
2
服务器 10.0.206.124            本地 PC                   台架 10.13.193.69
访问 127.0.0.1:54321 ──SSH隧道──► 跳板 ──直接 TCP──► 10.13.193.69:31936

2.1 在本地 PC 上建立隧道

1
ssh -N -o ServerAliveInterval=15 -o ServerAliveCountMax=8 -R 54321:10.13.193.69:31936 -p 32354 -i "C:\Users\username\.ssh\id_rsa_6v_a3" root@10.0.206.124

命令保持运行,隧道就保持有效。各参数作用如下:

参数 作用
-N 不执行远程命令,只做端口转发
-o ServerAliveInterval=15 每 15 秒向服务器发送一次保活探测
-o ServerAliveCountMax=8 连续 8 次探测无响应就断开(约 120 秒)
-R 54321:10.13.193.69:31936 让服务器监听 54321,流量经本地 PC 转发到台架的 31936
-p 32354 登录服务器的 SSH 端口
-i "C:\Users\username\.ssh\id_rsa_6v_a3" 登录服务器使用的私钥文件

注意 -p 是小写,指定的是 SSH 端口;scp 中对应的参数是大写 -P。私钥路径不要漏掉 .ssh 前面的反斜杠,常见错误是写成 C:\Users\username.ssh\...,这样指向的是另一个目录。

2.2 在服务器上访问台架

隧道建立后,在服务器上这样连接台架:

1
ssh -p 54321 <台架用户名>@127.0.0.1

传输文件:

1
scp -P 54321 local_file <台架用户名>@127.0.0.1:/tmp/

必须连 127.0.0.1,不能连台架的 IP。服务器访问台架 IP 依然是不通的,能连的只有服务器本机的 54321 端口。

也可以写进服务器上的 ~/.ssh/config,把隧道端口当成一台“别名主机”:

1
2
3
4
Host bench
HostName 127.0.0.1
Port 54321
User <台架用户名>

之后在服务器上 ssh bench 即可。

2.3 方案为什么能成立

关键在于 -R 的语法:

1
-R [bind_address:]port:host:hostport

其中 host:hostport(这里是 10.13.193.69:31936)是由本地 PC 去解析和连接的,不是在服务器上连接。所以服务器能不能访问台架并不重要,只要本地 PC 能访问台架,隧道就能通。本地 PC 在这里扮演的是跳板机的角色。

3 原理:远程转发 -R

3.1 数据流向

以本次命令 -R 54321:10.13.193.69:31936 为例,一条连接完整经过以下几步:

1
2
3
4
5
6
7
8
9
10
11
12
[服务器] 应用 ──► 127.0.0.1:54321
│
│ ① 服务器 sshd 接受连接
▼
SSH 隧道(已建立的 SSH 连接)
│
│ ② 流量沿隧道回到本地 PC 的 ssh 客户端
▼
[本地 PC] ssh 客户端 ──► ③ 主动连接 10.13.193.69:31936
│
▼
[台架] sshd :31936

从各方的视角看:

  • 服务器只是在访问自己的 127.0.0.1:54321,它并不需要知道台架的存在;
  • 台架看到的连接来自本地 PC;
  • 本地 PC 是中间的跳板,负责把两边接起来。

3.2 保活参数

ServerAliveInterval 和 ServerAliveCountMax 是客户端的探活设置:

  • ServerAliveInterval=15:每 15 秒向服务器发送一次保活探测;
  • ServerAliveCountMax=8:连续 8 次探测没有响应,就认为连接已经断开并退出。

也就是最多约 15 × 8 = 120 秒无响应后,ssh 会主动结束。

为什么要设置:本地 PC 和服务器之间通常隔着 NAT 或防火墙,长时间没有数据的 TCP 连接会被中间设备悄悄回收,表现就是“隧道看起来还在,实际已经不通了”。定时发送保活报文可以让连接保持活跃,也能让 ssh 及时发现死连接。

注意区分:ServerAlive* 是客户端探活,对应的服务端参数是 ClientAlive*。

3.3 监听地址与 GatewayPorts

默认情况下,-R 只在服务器的回环地址上监听,这是 sshd 出于安全考虑的默认行为。在服务器上可以看到:

1
2
ss -lntp | grep 54321
LISTEN 0 128 127.0.0.1:54321 0.0.0.0:* users:(("sshd",pid=xxx,fd=xxx))

127.0.0.1:54321 表示只有服务器本机能使用这条隧道,其他机器连不上。

如果确实需要让服务器所在网段的其他机器也能使用,需要修改服务器的 /etc/ssh/sshd_config:

1
GatewayPorts yes

重启 sshd 后,用下面的形式显式指定监听所有地址:

1
2
3
ssh -R 0.0.0.0:54321:10.13.193.69:31936 ...
# 或
ssh -R '*:54321:10.13.193.69:31936' ...

但大多数场景(包括本次)只需要服务器自己用,保持默认的回环监听即可,暴露面越小越安全。

3.4 常见的失败原因

  • remote port forwarding failed for listen port 54321:服务器上 54321 已被占用,或者 sshd 配置了 AllowTcpForwarding no;
  • 服务器上连 54321 被拒绝:本地 PC 的 ssh 进程已经退出,或隧道没有建立成功(用 ssh -v 可以看到 remote forward success for: listen 54321);
  • 连上了但马上断开:本地 PC 到台架不通,或台架防火墙拦截。可以先在本地 PC 上直接 ssh -p 31936 <台架用户名>@10.13.193.69 验证;
  • bind: Permission denied:非 root 用户绑定 1024 以下端口需要权限,换一个高位端口即可。

另外,如果希望端口转发失败时 ssh 直接退出,而不是留下一条“看起来正常”的登录会话,可以加上:

1
-o ExitOnForwardFailure=yes

4 SSH 的其他转发方式与应用场景

SSH 隧道本质是把其他 TCP 连接封装在一条已经建立的 SSH 连接里传输。对外看只是普通的 SSH 流量,里面跑的可以是 SSH、HTTP、数据库等任意 TCP 协议。除了上面用到的 -R,SSH 还提供 -L 和 -D 两种转发方式。

4.1 -L 本地转发

语法:

1
-L [bind_address:]port:host:hostport

含义:在本地监听 port,本地收到的连接通过 SSH 服务器转发到 host:hostport。与 -R 相反,这里的 host:hostport 是由服务器侧连接的。

假设情况反过来:服务器能访问台架,本地 PC 访问不到台架,那么在本地 PC 上执行:

1
ssh -N -L 54321:10.13.193.69:31936 -p 32354 root@10.0.206.124

之后在本地 PC 上执行 ssh -p 54321 <台架用户名>@127.0.0.1,就相当于访问台架。

-L 更常见的用法是访问服务器内网的服务。例如服务器本机跑了一个 Web 服务,端口 80:

1
ssh -N -L 8080:127.0.0.1:80 -p 32354 root@10.0.206.124

本地浏览器访问 http://127.0.0.1:8080 就能看到服务器的页面。注意这里的 127.0.0.1 是相对于服务器解析的,指的是服务器自己。

一句话概括:-L 是“把远端网络拉到本地”。

4.2 -D 动态转发

语法:

1
-D [bind_address:]port

在本地监听一个 SOCKS5 代理端口:

1
ssh -N -D 1080 -p 32354 root@10.0.206.124

这条命令在本地 127.0.0.1:1080 起了一个 SOCKS5 代理。任何把流量交给这个代理的程序,其连接都会经 SSH 服务器发出,而目标地址由程序在运行时通过 SOCKS 协议告诉代理,不需要在命令行里写死 host:hostport。

使用方式:

1
2
3
4
5
# curl 走代理
curl --socks5 127.0.0.1:1080 http://intranet.example.com

# Linux 下让任意程序走代理
proxychains4 curl http://intranet.example.com

浏览器中把代理设置为 SOCKS5、地址 127.0.0.1、端口 1080 即可。

与 -L 的区别:-L 一次只能把本地一个端口映射到一个固定目标;-D 只开一个端口,却能代理到服务器能访问的任意地址,目标由每个连接动态指定。可以把它理解为“目标可变的 -L”。

OpenSSH 7.6 及以上版本还支持远程动态转发 -R 1080(不带目标地址),在远端开一个 SOCKS 代理,效果与 -D 镜像。

4.3 三者的区别与联系

对比项 -L 本地转发 -R 远程转发 -D 动态转发
监听端 本地 远端(SSH 服务器) 本地
目标地址 命令行写死 命令行写死 不写死,由 SOCKS 请求决定
目标由谁连接 服务器 本地 服务器
承载协议 任意 TCP 任意 TCP SOCKS5 代理
典型场景 通过跳板机访问内网服务 把本地/内网服务暴露给远端 让程序整体走服务器的网络出口
一句话 把远端拉到本地 把本地推到远端 本地开一个能去远端的代理

三者的联系:

  • 底层都复用同一条 SSH 连接,靠 SSH 的通道(channel)多路复用同时承载多个转发;
  • -L 和 -R 是一对镜像:同一组映射,在哪一端执行决定了监听端和目标端的角色;
  • -D 可以看作 -L 的动态版本:只监听一个端口,但目标由 SOCKS 协议在运行时决定,一个端口服务所有地址;
  • 三个参数可以同时出现在一条命令里,也可以重复使用(例如同时开多条 -L 或 -R)。

4.4 如何选择

需求 选择
服务器要访问本地 PC 或本地 PC 能到达的地址 -R
本地要访问服务器或服务器内网的服务 -L
让浏览器、程序整体走服务器的网络出口 -D

回到本次场景:台架的服务地址是固定的 10.13.193.69:31936,所以用 -R 最直接。如果希望服务器能访问“本地 PC 能到达的任意地址”,则可以改用远程动态转发 -R 1080,在服务器上把它当作 SOCKS5 代理使用。

5 实用技巧

5.1 后台运行

Linux / macOS 上可以加 -f 让 ssh 转入后台:

1
ssh -f -N -R 54321:10.13.193.69:31936 ...

Windows 自带 OpenSSH 的部分版本不支持 -f,更稳妥的做法是单独开一个终端窗口,或者用任务计划程序在登录时启动。需要断线自动重连时,Linux 下可以用 autossh:

1
2
autossh -M 0 -N -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \
-R 54321:10.13.193.69:31936 -p 32354 root@10.0.206.124

-M 0 表示不使用 autossh 自己的监控端口,而依赖 SSH 的 ServerAlive* 参数判断连接状态。

5.2 调试

加 -v 可以看到转发是否成功:

1
ssh -v -N -R 54321:10.13.193.69:31936 ...

关键日志:

1
debug1: remote forward success for: listen 54321, connect 10.13.193.69:31936

出现 remote forward failure 时,结合前面的失败原因逐项排查。

5.3 安全提醒

  • 隧道一旦建立,能访问服务器 54321 的人就等价于能访问台架,注意服务器上的账户权限和访问控制;
  • 不要随意开启 GatewayPorts yes,默认只监听回环地址是最安全的;
  • 使用密钥认证,不要图省事使用弱密码;
  • 隧道只应该开给需要的端口,转发范围越小越好。

6 参考

这是一篇优秀的博客,必须推荐。