用 MBP 和 Ubuntu 理解 SSH:本地转发、反向转发与动态转发
围绕 MBP 10.9.42.98 与 Ubuntu 10.9.1.52,逐条拆解 -L、-R、-D:入口在哪、谁连接目标、SOCKS 如何提交地址,以及怎样用 curl 验证而不被缓存与代理配置误导。
先看全局:入口在哪里,谁连接目标
本地转发和反向转发,首先区别在于监听入口放在哪一侧;动态转发进一步允许应用为每条新连接指定目标。先把“入口”“SSH 连接两端”“最终目标”分开看,再读命令中的地址与端口。
本文的三条隧道命令都在 MacBook Pro M5(MBP)本机执行,SSH 连接的服务器都是 Ubuntu。这里用“本地端口转发”“远程/反向端口转发”来称呼 -L 与 -R。
| 方式 | 监听入口 | 谁发起到目标的连接 | 本次实验的效果 |
|---|---|---|---|
-L 本地转发 | MBP 的 127.0.0.1:18000 | Ubuntu | MBP 读取 Ubuntu 的网页 |
-R 反向转发 | Ubuntu 的 18000,监听地址受服务端配置约束 | MBP | Ubuntu 读取 MBP 的网页 |
-D 动态转发 | MBP 的 SOCKS 入口 127.0.0.1:1080 | Ubuntu | MBP 的应用通过 Ubuntu 访问每条连接指定的目标 |
三种方式建立后的数据都可以双向传输。“反向”不表示响应只能单向流动,也不表示 Ubuntu 必须反过来主动建立到 MBP 的 SSH 登录连接。
依据:[1]。实验准备:用两张不同的网页辨认来源
本次设备信息来自学习讨论:
| 机器 | 地址 | 本次角色 |
|---|---|---|
| MacBook Pro M5 | 10.9.42.98 | 执行隧道命令的 SSH 客户端;也是其中一台 HTTP 服务主机 |
| Ubuntu | 10.9.1.52 | SSH 服务端,登录用户 lizhao1;也是一台 HTTP 服务主机 |
假定 MBP 可以正常 SSH 登录 Ubuntu,两台机器有 Python 3 和 curl,示例中的监听端口没有被其他程序占用。SSH 使用默认 22 端口;如果实际端口不同,在 ssh 命令中增加 -p 实际端口。
① Ubuntu 服务终端:先从 MBP 登录 Ubuntu
ssh lizhao1@10.9.1.52
看到 Ubuntu 的 shell 后,执行下面的命令,并保持这个服务终端运行:
ssh_demo_dir=$(mktemp -d)
printf '%s\n' '<h1>Hello from Ubuntu: 10.9.1.52</h1>' > "$ssh_demo_dir/index.html"
python3 -m http.server 8000 --bind 127.0.0.1 --directory "$ssh_demo_dir"
② MBP 服务终端:另开一个 MBP 本机终端
在这个终端直接执行,不先登录 Ubuntu:
ssh_demo_dir=$(mktemp -d)
printf '%s\n' '<h1>Hello from MacBook Pro M5: 10.9.42.98</h1>' > "$ssh_demo_dir/index.html"
python3 -m http.server 8000 --bind 127.0.0.1 --directory "$ssh_demo_dir"
两台机器都使用 8000 不会相互占用:端口属于各自机器。两个服务均绑定回环地址,只接收各自本机发起的连接;这正好用来观察 SSH 转发的作用。临时目录只提供实验页面,避免把当前工作目录作为网页目录。
③ 先验证服务本身,再建立隧道
在对应机器的另一个终端中分别运行:
curl -q --noproxy '*' --max-time 3 http://127.0.0.1:8000/
| 执行位置 | 预期响应正文 |
|---|---|
| MBP | <h1>Hello from MacBook Pro M5: 10.9.42.98</h1> |
| Ubuntu | <h1>Hello from Ubuntu: 10.9.1.52</h1> |
接下来额外保留一个隧道终端,三条隧道命令都在 MBP 本机运行。按顺序实验时,完成一节后,在隧道终端按 Ctrl+C 停掉该隧道;两台机器的 HTTP 服务可以一直保留到全部实验结束。验证终端是否在 MBP 或 Ubuntu,以各节标注为准。
本地转发 -L:让 MBP 读取 Ubuntu
目标:在 MBP 访问本机 18000 端口,读到 Ubuntu 的 8000 端口提供的网页。
① 在 MBP 本机的隧道终端执行
ssh -N -o ExitOnForwardFailure=yes \
-L 127.0.0.1:18000:127.0.0.1:8000 \
lizhao1@10.9.1.52
-N 表示不执行远程命令。连接成功后,终端保持在前台且通常没有输出;不要为了“返回提示符”立即关闭它,最终是否可用仍要靠下面的请求验证。
② 在另一个 MBP 本机终端验证
curl -q --noproxy '*' --max-time 3 http://127.0.0.1:18000/
预期正文:<h1>Hello from Ubuntu: 10.9.1.52</h1>。同时,Ubuntu 的 Python 服务终端应出现这次 GET 请求的日志。
③ 把两个 127.0.0.1 分开读
| 命令片段 | 在这个例子里的含义 |
|---|---|
-L 127.0.0.1:18000 | 在 MBP 的回环地址监听 18000,供 MBP 的程序连接 |
:127.0.0.1:8000 | 让 Ubuntu 连接它自己的回环地址上的 8000 |
lizhao1@10.9.1.52 | MBP 登录的 SSH 服务端是 Ubuntu |
- MBP 的 curl 连接 MBP 的
18000。 - MBP 的 SSH 客户端通过现有 SSH 连接向 Ubuntu 请求转发。
- Ubuntu 发起到 Ubuntu 自己
127.0.0.1:8000的 TCP 连接。 - 网页内容经过这条转发连接返回给 MBP。
完整写法是 -L [监听地址:]监听端口:目标地址:目标端口。目标地址也可以是 Ubuntu 能到达的第三台机器;-L 并不限定只能访问 SSH 服务器自身。
观察隧道是否必要:在隧道终端按 Ctrl+C 后,再从 MBP 发起同一条新的 curl 请求。在没有其他程序接管 18000 的条件下,它应失败;Ubuntu 的 HTTP 服务此时仍可继续运行。
反向转发 -R:让 Ubuntu 读取 MBP
目标:在 Ubuntu 访问它的 18000 端口,读到 MBP 的 8000 端口提供的网页。确认上一节的隧道已停止,并保留两台机器的 HTTP 服务。
① 仍然在 MBP 本机的隧道终端执行
ssh -N -o ExitOnForwardFailure=yes \
-R 127.0.0.1:18000:127.0.0.1:8000 \
lizhao1@10.9.1.52
② 登录 Ubuntu,再在 Ubuntu 中验证
另开一个 MBP 终端,运行 ssh lizhao1@10.9.1.52。进入 Ubuntu 的 shell 后执行:
curl -q --noproxy '*' --max-time 3 http://127.0.0.1:18000/
预期正文:<h1>Hello from MacBook Pro M5: 10.9.42.98</h1>。同时,MBP 的 Python 服务终端应出现这次 GET 请求的日志。
| 命令片段 | 在这个例子里的含义 |
|---|---|
-R 127.0.0.1:18000 | 请求在 Ubuntu 的回环地址监听 18000;最终监听范围由 Ubuntu 的 sshd 配置约束 |
:127.0.0.1:8000 | 让 MBP 连接 MBP 自己的回环地址上的 8000 |
lizhao1@10.9.1.52 | SSH 连接依然由 MBP 主动连接 Ubuntu |
- Ubuntu 的 curl 连接 Ubuntu 的
18000。 - Ubuntu 的 SSH 服务端把这次连接通过现有 SSH 通道交给 MBP。
- MBP 的 SSH 客户端连接 MBP 自己的
127.0.0.1:8000。 - MBP 的网页内容沿这条连接返回给 Ubuntu。
在 Ubuntu sshd 默认 GatewayPorts=no 时,远端入口只允许 Ubuntu 本机访问。本实验通过 Ubuntu 本机验证,不需要为了外部设备访问而开启 GatewayPorts。若服务端已经改为 yes,它会强制通配地址监听,详见后面的监听范围表。
目标也可以是 MBP 能访问的其他机器。完成这一节后停止隧道;如果验证终端还在 Ubuntu shell,先用 exit 返回 MBP,再继续动态转发实验。
动态转发 -D:同一个入口,连接不同目标
-L 的每个监听端口对应命令中预先指定的目标。-D 在本地创建 SOCKS 代理入口,由应用在建立每条代理连接时提供目标地址和端口。
① 在 MBP 本机建立动态转发
ssh -N -o ExitOnForwardFailure=yes \
-D 127.0.0.1:1080 \
lizhao1@10.9.1.52
这个命令指定入口 1080 和 SSH 服务端 Ubuntu,没有写死最终目标。MBP 上的 ssh 程序本身承担 SOCKS 代理角色,无须额外启动一个 SOCKS 服务程序。
② 同样的 URL,比较直接访问与代理访问
确认 Ubuntu 的 8000 服务仍在运行。先在 MBP 本机直接访问:
curl -q --noproxy '*' --max-time 3 http://127.0.0.1:8000/
预期读到 Hello from MacBook Pro M5。
然后仍在 MBP 本机,明确通过刚才的 SOCKS 入口访问同一个 URL:
curl -q --noproxy "" \
--proxy socks5h://127.0.0.1:1080 \
--max-time 10 \
http://127.0.0.1:8000/
这次预期读到 Hello from Ubuntu。代理参数中的 127.0.0.1:1080 属于 MBP;URL 中的目标由 Ubuntu 发起连接,所以它连接的是 Ubuntu 自己的 127.0.0.1:8000。
③ 保留同一条 SSH 隧道,只改变目标 URL
如果 Ubuntu 的网络和策略允许访问外网,可以在 MBP 运行:
curl -q --noproxy "" \
--proxy socks5h://127.0.0.1:1080 \
--max-time 10 \
https://example.com/
这次目标变为 example.com:443,入口仍是 MBP 的 1080,SSH 服务端仍是 Ubuntu。Ubuntu 能否连接这个目标,是此次访问能否成功的必要条件。
socks5h 会把目标域名交给代理链路处理;在这套 SSH 转发中由 Ubuntu 侧解析。socks5 则由 curl 本地解析目标域名后提交地址。前一个 localhost 实验使用 IP 字面量,本身没有验证 DNS 解析位置的差异。
浏览器需要设置 SOCKS 代理 127.0.0.1:1080,再访问实际网站。直接把 http://127.0.0.1:1080/ 当成网页地址,会发送 HTTP 请求,而这个端口首先需要 SOCKS 协议交互。
SOCKS 的作用:应用把目标告诉 SSH
“动态转发是不是多调用一次 SOCKS,让 SOCKS 指定目标端口?”这个理解抓住了新增的目标协商过程,但可以更精确:
以 curl 使用 socks5h 访问 https://example.com/ 为例:
| 步骤 | 发生的事 |
|---|---|
| 1 · 连接代理入口 | MBP 的 curl 连接 MBP 的 127.0.0.1:1080 |
| 2 · SOCKS 握手与请求 | curl 按 SOCKS5 进行协商,然后提交 CONNECT 请求,携带目标 example.com 和端口 443 |
| 3 · 交给 SSH 转发 | MBP 的 ssh 解析请求,通过现有 SSH 连接要求 Ubuntu 建立相应的目标连接 |
| 4 · Ubuntu 连接目标 | Ubuntu 按请求连接 example.com:443 |
| 5 · 传输业务数据 | 连接建立后,curl 与目标网站的数据通过该转发连接双向传输 |
这里的“一次”应理解为每条新的被代理 TCP 连接开始时的一段 SOCKS 交互,它可以包含多条协议消息。同一条连接中的后续数据沿已确定的连接传输;不会因为发送了一个新的数据包就重新选择目标。一条保持连接的 HTTP 会话也不一定为每个请求重新进行 SOCKS 握手。
-D 支持 SOCKS4/5 的 TCP 连接转发。它不会自动接管所有程序,也不能据此推断 UDP、QUIC 或 ping 会走这条隧道。应用需要明确使用代理,目标还要满足 Ubuntu 的网络可达性及 SSH 转发策略。
SSH 保护的是 MBP 与 Ubuntu 之间的链路。推广到第三方目标时,Ubuntu 到目标这一段的应用数据是否加密,取决于应用协议;例如 HTTPS 使用 TLS 保护客户端与网站之间的数据。
依据:[1]、[6]、[8]。互换机器后,本地与远程也随之互换
在“目标服务分别位于这两台机器”的前提下,下面四种理解都成立:
| 在哪台机器运行 ssh | 方式 | 入口在哪 | 可以实现的访问 |
|---|---|---|---|
| MBP,连接 Ubuntu | -L | MBP | MBP 访问 Ubuntu 的指定服务 |
| MBP,连接 Ubuntu | -R | Ubuntu | Ubuntu 访问 MBP 的指定服务 |
| Ubuntu,连接 MBP | -L | Ubuntu | Ubuntu 访问 MBP 的指定服务 |
| Ubuntu,连接 MBP | -R | MBP | MBP 访问 Ubuntu 的指定服务 |
同样实现“MBP 访问 Ubuntu”,可以选择 MBP 发起 -L,也可以选择 Ubuntu 发起 -R。两种方式要求的 SSH 登录方向不同:前者需要 MBP 能登录 Ubuntu,后者需要 Ubuntu 能登录 MBP。已经证明前者可用,并不能直接证明后者也可用。
所以“本地/反向”确实是一组相对概念,但参照物是发起当前 SSH 会话的客户端。端口转发提供的是指定的网络服务访问,不代表获得对方所有资源的访问能力。
本次三条主实验命令都没有出现 MBP 的 10.9.42.98,因为它本身就是 SSH 发起方,本机监听或本机目标又使用了回环地址;地址没有显式写出,不等于 MBP 没有参与。
用 curl 验证:排除浏览器缓存,保留证据边界
新的 curl 请求可以避开浏览器本地缓存造成的观察干扰,但不能保证排除所有缓存。普通 curl 调用不读取 Chrome 的响应缓存;CDN、反向代理、服务端应用缓存仍可能返回以前保存的内容。
在本次直连 -L 或 -R 入口的实验中,可以增加详细连接日志:
curl -q --noproxy '*' -v --max-time 3 http://127.0.0.1:18000/
| 参数 | 本例中的用途 |
|---|---|
-q | 作为 curl 的第一个选项,跳过默认 curlrc;它不是关闭 HTTP 缓存的开关 |
--noproxy '*' | 直接访问转发入口,不受 curl 代理配置影响;动态代理实验改用空字符串 |
-v | 展示连接与协议过程;它不是禁用缓存的选项 |
--max-time 3 | 给本机演示请求设置时间上限;超时本身不足以唯一确定故障原因 |
哪些证据组合起来才有说服力?
- 请求在正确机器上执行,并使用正确入口。
- 响应正文能辨认真实服务来源:Ubuntu 或 MBP。
- 对应的 Python 服务终端出现这次新 GET 的日志。
- 停止对应隧道后,新的入口请求失败,而服务本机的 8000 检查仍能成功。
每次独立启动 curl,都不会复用上一次 curl 进程中的连接。这不意味着已有 SSH 连接也被重新建立:新的被转发连接可以继续使用同一条 SSH 会话。
对普通互联网 HTTP 请求,如需请求缓存先验证再复用,可以发送:
curl -i -H 'Cache-Control: no-cache' https://example.com/
no-cache 的含义是请求先验证再复用,不能视为保证回源重新生成内容;请求缓存指令是否被遵守也需要实际观察。no-store 不会自动让已经存储的响应失效。新的网络请求、缓存验证、应用重新计算,是三个不同的事件。
监听范围、失败处理与实验收尾
本实验为什么不要求修改 GatewayPorts?
-R 的验证请求由 Ubuntu 本机发出,不需要让其他机器直接访问 Ubuntu 的转发入口。Ubuntu 的 sshd_config 中,GatewayPorts 的几种取值含义是:
| 服务端 GatewayPorts | 远端转发监听范围 |
|---|---|
no(默认) | 限制为回环地址,只供服务端本机访问 |
yes | 强制绑定通配地址;不能把命令中的回环地址当作最终监听范围的保证 |
clientspecified | 允许 SSH 客户端选择监听地址 |
如果将来要让第三台机器直接访问这个远端入口,还要同时考虑绑定地址、路由和防火墙等实际条件。本实验不需要为这个目标额外开放监听范围。
参数能保证什么?
| 参数或现象 | 准确解释 |
|---|---|
-N | 只建立连接和转发,不执行远程命令 |
-p | 指定外层 SSH 服务端的端口,不是最终 HTTP 目标端口 |
-f | 认证后进入后台;初次实验保留前台,便于看到错误并停止 |
-C | 启用压缩;效果取决于链路和数据,不能认为总是更快 |
ExitOnForwardFailure=yes | 请求的转发无法建立时退出,例如入口监听失败;不检查最终 HTTP 服务是否健康,也不因随后某次目标连接失败而必然退出 |
| SSH 终端保持无输出 | 可能是 -N 正常运行状态;需要业务请求确认转发真正可用 |
按失败发生的位置排查
| 观察结果 | 下一步检查 |
|---|---|
| 服务本机访问 8000 就失败 | 先检查 Python 服务是否运行、是否端口占用、是否在预期机器上执行 |
| SSH 报认证或连接错误 | 确认 MBP 到 Ubuntu 的 SSH 端口、用户和登录凭据 |
| 转发入口建立失败 | 看终端原始错误,检查端口占用及服务端是否允许该方向的转发 |
| 入口能连接,但目标访问失败 | 检查真正发起目标连接的那台机器是否能访问目标服务 |
| 动态转发意外读到 MBP 页面 | 确认 curl 是否真的配置 SOCKS,以及是否错误使用了 --noproxy '*' 或被 NO_PROXY 排除 |
| 请求超时 | 结合 SSH 日志、curl -v 和服务端日志判断;不单凭超时断言唯一原因 |
服务端还可能通过 AllowTcpForwarding、DisableForwarding、PermitOpen、PermitListen 等配置限制转发;能登录成功不等于所有转发都被允许。
停止实验:在当前隧道终端按 Ctrl+C;在两个 Python 服务终端分别按 Ctrl+C。需要退出 Ubuntu shell 时使用 exit。由 mktemp 创建的两个实验目录可以在确认路径后按需清理。
复盘问题与官方资料
这些问题用于自行检查理解;文章发布不代表已经在设备上实测通过,也不代表已经完成复盘。
在 MBP 执行 -L 与 -R 时,右侧的 127.0.0.1 分别指谁?
-L 由 Ubuntu 连接目标,因此右侧回环地址属于 Ubuntu;-R 由 MBP 连接目标,因此右侧回环地址属于 MBP。
为什么动态转发没有在 ssh 命令中写目标端口?
应用会在每条新代理连接开始时,通过 SOCKS 请求提交目标地址和端口。MBP 的 SSH 客户端据此请求 Ubuntu 建立目标连接。
能否用 --noproxy '*' 来验证 -D?
它会让 curl 对所有目标绕过代理,因此不能用来验证该 SOCKS 路径。使用 --noproxy 空字符串,并显式指定 socks5h 代理。
已经拿到一个网页,是否就证明了预期隧道工作?
还要核对执行机器、入口、代理配置与页面来源,并观察真正服务端的新请求日志。错误地绕过代理,也可能返回另一台机器上的正常网页。
官方资料
- OpenSSH ssh(1):-L、-R、-D、-N、-p、-f、-C 的行为与转发方向。
- OpenSSH sshd_config(5):GatewayPorts 与服务端转发限制。
- OpenSSH ssh_config(5):ExitOnForwardFailure 的保证范围。
- curl 官方手册:--noproxy、--proxy、socks5h、-q、连接复用与详细日志。
- Python http.server:本机演示 HTTP 服务的启动、绑定和目录参数。
- RFC 1928:SOCKS5:握手、CONNECT 请求、目标地址与端口字段。
- RFC 9111:HTTP Caching:缓存验证与请求缓存指令的边界。
- RFC 4254:SSH Connection Protocol:转发通道与目标连接机制。