SSH 端口转发与配置固化:-L、-R、-D 与 ~/.ssh/config
换一种隧道:不靠客户端软件,用 ssh 自己开一条。三个参数记混是常态,一个口诀就能分清。
学完这节你能做到
- 分清 -L / -R / -D 三种转发的监听端与出口端,并写对目标地址
- 把长命令收进 ~/.ssh/config,用 ProxyJump 与 ControlMaster 固化下来
- 把一条隧道做成能长期活着的 systemd 服务
建议先学
跳过这几节会看不懂本节的部分推导
一句话记法
上一节讲的两种代理,ssh 自己就能开一条 —— 不用装客户端、不用配订阅,
只要你能登上那台机器。区别是它同时解决另一个问题:集群在跳板机后面,
后面每一节要连的机器都在那道门里。
三个参数,-L、-R、-D,记混是常态。有一个特别好用的口诀:
左边那一侧开新端口。
ssh -L 本地端口:目标:端口—— 新端口开在本地(Local)ssh -R 远端端口:目标:端口—— 新端口开在远端(Remote)ssh -D 本地端口—— 新端口开在本地,而且是个 SOCKS5 代理
再配一个顺序记法:ssh -L local:remote、ssh -R remote:local ——
参数首字母和第一个地址的归属永远一致。
下面这张图把所有形态放在一起,看完再往下读会顺很多:

本节的示意图与实验拓扑均引自 Ivan Velichko 的 A Practical Guide to SSH Tunnels(iximiuz Labs), 图片版权归原作者所有。那篇教程配了可以直接上手的在线 playground, 强烈建议照着跑一遍 —— 本节侧重把它整理成中文的排障心智模型,动手环节以原文为准。
约定一套贯穿全节的拓扑(同样来自原文):
| 主机 | 地址 | 说明 |
|---|---|---|
local | 家里 192.168.0.0/24 + 公网 203.0.113.0/24 | 你的工作机 |
remote | 203.0.113.30,同时在 172.16.0.0/24 | 公网跳板 / 网关,ssh remote 可达 |
private | 172.16.0.40 | 只在 VPC 内部可达 |
internal | 192.168.0.10 | 只在家里网段可达 |
本地转发 -L:把远端服务搬到本地
最常用的一个。远端有个服务只监听 localhost,你在本地开一个端口去访问它:
ssh -f -N -L 8080:localhost:80 203.0.113.30
curl localhost:8080

这里的 localhost:80 是在 remote 上解析的 —— 它指的是 remote 自己的 80 端口。
这一点是理解后面所有变体的关键:冒号后面那个地址,永远由 sshd 那一侧解析。
-f -N 是长期挂隧道的标配:-N 不执行远程命令只做转发,-f 转到后台。
把目标地址从 localhost 换成一个 remote 能访问到的地址,就变成了经跳板访问内网:
ssh -f -N -L 8081:172.16.0.40:80 203.0.113.30
这就是"用跳板机连数据库"的标准做法:本地连 localhost:8081,实际打到 VPC 里的 RDS。
-J 加 -L:访问第三台机器自己的 loopback
上面那招有个盲区:如果目标服务只监听在 private 自己的 127.0.0.1 上呢?
remote 也连不上它。这时候要用 -J(ProxyJump)把 SSH 会话终结在 private 上:
ssh -f -N -J 203.0.113.30 -L 8082:localhost:9000 172.16.0.40

差别就在这里:-J 的跳板只负责中转字节,SSH 会话本身建立在最终主机上,
所以 -L 里的 localhost 解析成 private 自己。
远程转发 -R:把本地服务暴露到远端
方向反过来:你本地跑着一个服务,想让远端(或经由远端的其他人)访问:
ssh -f -N -R 0.0.0.0:8080:localhost:80 203.0.113.30

这是 -R 最常见的挫败点:命令执行成功、没有任何报错,但别的机器就是连不上那个端口。
原因是 sshd 默认只允许远程转发绑到它自己的 127.0.0.1。要绑 0.0.0.0 必须在
服务端 /etc/ssh/sshd_config 里打开:
GatewayPorts yes
改完 systemctl reload sshd。在服务端用 ss -lnt | grep 8080 确认监听地址到底是
127.0.0.1:8080 还是 0.0.0.0:8080 —— 一眼就能分辨是不是踩了这个坑。
同样,把源地址从 localhost 换成家里另一台设备(ssh -f -N -R 0.0.0.0:8081:192.168.0.10:80 remote),
就能把内网设备暴露出去。这是最朴素的"内网穿透":不需要公网 IP,
只需要一台能 ssh 上去的公网机器。
动态转发 -D:本机变成 SOCKS5 代理
前面几种都要事先知道目标地址。-D 不需要 —— 它在本地开一个 SOCKS5 代理,
目标地址由每个请求自己带:
ssh -f -N -D 1080 203.0.113.30
curl --socks5-hostname localhost:1080 172.16.0.40:80
curl --socks5-hostname localhost:1080 172.16.0.50:80 # 同一条隧道,不同目标

注意客户端必须会说 SOCKS5(所以 curl 要用 --socks5-hostname),
代理基础 那节讲过。浏览器可以直接配 SOCKS5 代理,
命令行工具靠 ALL_PROXY 或 proxychains。
看一遍完整路径,注意出口 IP 和 DNS 解析都发生在服务器那一侧:
一条 ssh -D 就能让本机变成 SOCKS5 代理,出口在服务器那一侧。
汇总表
| 命令 | 新端口开在 | 出口在 | 目标是否固定 |
|---|---|---|---|
ssh -L 8080:目标:端口 | 本地 | SSH 服务器侧 | 固定 |
ssh -J 跳板 -L 8080:localhost:端口 最终主机 | 本地 | 最终主机自己 | 固定 |
ssh -R 8080:目标:端口 | 远端 | 客户端侧 | 固定 |
ssh -D 1080 | 本地 | SSH 服务器侧 | 不固定 |
ssh -R 1080(无目标,需 OpenSSH ≥ 7.6) | 远端 | 客户端侧 | 不固定 |
把长命令收进 ~/.ssh/config
上面这些命令都很长,每次敲一遍不现实:
# 全局默认(放在文件开头)
Host *
ServerAliveInterval 30
ServerAliveCountMax 3
ControlMaster auto
ControlPath ~/.ssh/cm-%C
ControlPersist 10m
# 跳板机
Host bastion
HostName bastion.example.com
User ops
IdentityFile ~/.ssh/id_ed25519_ops
# 内网机器:一律经跳板机
Host 10.20.*
User root
ProxyJump bastion
# 具体某台,带端口转发
Host db-prod
HostName 10.20.3.40
ProxyJump bastion
LocalForward 15432 localhost:5432
之后 ssh db-prod 就自动经跳板、自动建好本地 15432 转发,ssh 10.20.3.99 匹配通配符自动经跳板。
两条匹配规则要记住:
- 从上往下匹配,每个参数取第一次出现的值 —— 所以希望全局生效的参数(上面那组保活与复用)
要放在开头,作为兜底的
Host *才放最后。 Host是别名,HostName才是真实地址,两者可以完全不同。
配置文件一长,"到底哪条规则生效了"就说不清。ssh -G <host>
直接打印合并后的最终配置,不用自己推理匹配顺序。
ssh -G db-prod | grep -E 'hostname|user|proxyjump|localforward|controlpath'
# 连不上时看它读了哪些文件、应用了哪些块
ssh -vvv db-prod 2>&1 | grep -E 'Reading configuration|Applying options'ProxyJump 与 ProxyCommand
-J / ProxyJump 是现代写法,底层等价于老的 ProxyCommand ssh -W;多级跳板用逗号串起来
(ssh -J bastion1,bastion2 10.20.3.40)。
ProxyCommand 今天主要用在另一件事上:ssh 不读 HTTP_PROXY 环境变量,
要走 SOCKS5 代理只能这么写:
Host github.com
User git
ProxyCommand nc -x 127.0.0.1:1080 %h %p
# nc 版本不带 -x 时用 socat
# ProxyCommand socat - SOCKS4A:127.0.0.1:%h:%p,socksport=1080
这一条同时解决了 git clone git@github.com:... 走代理的问题 ——
git 的 ssh 传输就是调用 ssh,配置在这里对它一样生效。
占位符只要记三个:%h 目标主机名、%p 目标端口、%r 远程用户名。
Agent 转发(ssh -A)让你在跳板机上继续用本地的密钥。方便,但有风险:
跳板机的 root 可以劫持你的 agent socket,用你的密钥去连别的机器。
ProxyJump 的密钥交换只在本地进行,跳板机全程接触不到你的私钥 ——
它只是个字节中转。这是 -J 相对老式"先登跳板再登目标"做法的一个安全优势。
ControlMaster:第二次连接秒开
每次 ssh 都要重新做 TCP 握手 + 密钥交换 + 认证,经跳板机时开销翻倍。
连接复用把它降到接近零:第一次连接一两秒,之后每次都是毫秒级。
对频繁执行远程命令的脚本(ansible、批量运维)收益尤其大。
| 参数 | 作用 |
|---|---|
ControlMaster auto | 没有主连接就建一个,有就复用 |
ControlPath ~/.ssh/cm-%C | 复用用的 socket 文件路径,%C 是定长哈希 |
ControlPersist 10m | 主连接在最后一个会话退出后继续保留 10 分钟 |
ls -l ~/.ssh/cm-* # 当前有哪些复用连接
ssh -O check db-prod # 某台是否有活跃主连接
ssh -O exit db-prod # 主动关掉主连接
① 主连接断了,所有复用会话一起断。 网络抖动时的影响面比不复用更大。
② socket 路径太长会报错:ControlPath too long。
Unix socket 路径有长度限制(约 100 字节),主机名长时容易超 —— 用 %C 哈希,长度固定。
③ 改了配置不生效。 因为复用的是旧主连接,新配置没被读取。
改完配置记得 ssh -O exit <host> 关掉主连接。这个坑很费时间 ——
你以为配置没生效,其实是连接没重建。
让隧道长期活着:三层做法
-f -N 只解决了"挂后台",没解决"断了自动回来"。
第一层,保活:
Host tunnel-*
ServerAliveInterval 30 # 每 30 秒发一次心跳
ServerAliveCountMax 3 # 连续 3 次没回应就断开
ExitOnForwardFailure yes # 端口绑定失败就退出,别留假隧道
TCPKeepAlive no
保活用 ServerAliveInterval 而不是 TCPKeepAlive:前者走 SSH 协议层的加密通道,
中间设备无法伪造;后者是 TCP 层的明文探测,可能被中间设备欺骗性应答。
ExitOnForwardFailure yes 是最容易被忽略但最重要的一条:
默认情况下端口被占用或 GatewayPorts 没开时,ssh 照样连上、只是不转发 ——
你会对着一个"进程还在"的假隧道排查很久。
第二层,自动重连:
autossh -M 0 -f -N -L 8080:localhost:80 tunnel-host \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes
-M 0 表示不用 autossh 自己的监控端口,改为依赖 SSH 的保活机制 —— 这是现在的推荐用法。
第三层,systemd 模板单元 —— 生产环境的正解,开机自启 + 崩溃重启 + 日志统一:
# /etc/systemd/system/ssh-tunnel@.service
[Unit]
Description=SSH tunnel to %i
After=network-online.target
Wants=network-online.target
[Service]
User=tunnel
ExecStart=/usr/bin/ssh -N %i \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes -o BatchMode=yes
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
systemctl enable --now ssh-tunnel@db-prod
journalctl -u ssh-tunnel@db-prod -f
模板单元(@ 形式)的好处是一份文件管所有隧道,%i 就是 ~/.ssh/config
里的主机别名,转发配置写在 config 里。
BatchMode=yes:禁止任何交互提示。否则首次连接的主机指纹确认会让它一直挂着- 密钥必须免密码,或者用
ssh-agent的 systemd 集成 known_hosts要预先准备好:以运行用户的身份先手工连一次,或者用ssh-keyscan预填
sudo -u tunnel ssh-keyscan -H bastion.example.com >> /home/tunnel/.ssh/known_hosts漏掉任何一条,症状都是"服务起来了但隧道不通",而 systemctl status 看着正常。
服务端:把隧道能力收紧
一个能 ssh 上跳板机的账号,默认就能把跳板机当代理访问它所在的整个网络。 生产跳板机应该给隧道用途单独建账号并收紧:
# /etc/ssh/sshd_config
Match User tunnel-only
AllowTcpForwarding local # 只允许 -L,禁掉 -R
PermitOpen 10.20.3.40:5432 # 白名单:只能转发到这个目标
PermitTTY no
X11Forwarding no
AllowAgentForwarding no
ForceCommand /usr/sbin/nologin # 不给 shell
sshd -t && systemctl reload sshd # 改完先语法检查再 reload
| 参数 | 作用 |
|---|---|
AllowTcpForwarding local/remote/no | 分别控制 -L / -R 是否允许 |
PermitOpen host:port | 转发目标白名单,可以写多个 |
GatewayPorts no(默认) | -R 只能绑 loopback |
AllowAgentForwarding no | 防止跳板机 root 劫持你的 agent |
ForceCommand /usr/sbin/nologin | 只能转发、不能执行命令 |
给隧道用途单独建账号、只放通必要的目标,比事后审计有用得多。
检查点
ssh -R 0.0.0.0:8080:localhost:80 gateway 执行成功,但从别的机器连 gateway:8080 不通。最可能的原因?
要访问 private 主机上一个只监听 127.0.0.1:9000 的服务,而 private 只能经 bastion 到达。正确的命令是?
改了 ~/.ssh/config 里某台主机的转发配置,重新 ssh 却发现没生效。最可能的原因?
这节课的落点
- 口诀:左边那一侧开新端口;
-L local:remote、-R remote:local -L/-R里冒号后面的地址由 sshd 那一侧解析,这是所有变体的关键- 只监听 loopback 的远端服务要用
-J把会话终结在目标机上 -R默认只绑 loopback,绑0.0.0.0需要服务端GatewayPorts yes-D是本地 SOCKS5,出口和 DNS 都在服务器侧;客户端必须会说 SOCKS5~/.ssh/config把长命令变成别名;Host是别名、HostName才是真实地址, 从上往下取第一次出现的值ssh -G <host>是排查配置的第一条命令,直接给出合并后的最终参数ssh不读代理环境变量,走 SOCKS5 要用ProxyCommand nc -x;这同时解决 git over ssh- 多跳用
ProxyJump而不是ssh -A—— 私钥全程留在本地 ControlMaster让第二次连接毫秒级;三个坑:主连接断则全断、路径过长(用%C)、 改配置后要ssh -O exit才生效- 长期隧道三层:
ServerAliveInterval+ExitOnForwardFailure yes→autossh -M 0→ systemd 模板单元(必须BatchMode=yes+ 免密钥 + 预填known_hosts) - 服务端用
Match User收紧:AllowTcpForwarding local、PermitOpen白名单、AllowAgentForwarding no