构建安全的内网穿透:用 Cloudflare Tunnel + Shadowsocks 远程 SSH 回家
发布于 2026-07-21
远程连回家里的电脑是个很常见的需求,但直接把 SSH 端口暴露到公网风险不小——扫描、爆破一直都在发生。这次没有走“公网 IP + 端口转发”的老路,而是把 Cloudflare Tunnel (cloudflared) 和 Shadowsocks + v2ray-plugin 拼在一起:前者负责穿透 NAT、隐藏真实公网 IP,后者负责把流量伪装成普通的 WebSocket over TLS,全程走 443 端口出去。
下面记录完整的搭建过程、中间踩的几个坑,以及最终能跑起来的配置。
一、整体架构
目标是“在家用局域网直连,出门在外自动切换成走隧道回家”,所以设计了两条路径。
局域网模式:手机上的 Termius 直接通过局域网 IP 或 mDNS 名字连 Mac 的 22 端口,不经过任何代理层,延迟在 5ms 以内,速度只受 Wi-Fi 限制。
外网穿透模式,流量要经过好几层封装:
Termius (手机)
│ 连接自定义域名,被 Fake-IP 拦截
▼
Shadowrocket (手机 App)
│ Shadowsocks + v2ray-plugin,WebSocket over TLS,走 443 端口
▼
Cloudflare 边缘节点
│ Cloudflare Tunnel(QUIC 隧道)
▼
cloudflared(Mac 守护进程)
│ HTTP 转发到 127.0.0.1:8008
▼
v2ray-plugin(Mac 端)
│ 解封装,转发到本地随机端口
▼
ss-server(Mac 端)
│ Shadowsocks 解密,转发到 127.0.0.1:22
▼
Mac 本地 SSH
多层协议嵌套的代价是排错很痛苦——任何一层配错都只会表现为“连接断开”或一个语焉不详的 EOF,看不出到底是哪一层的问题。下面这几个坑基本上就是把这条链路从头到尾过了一遍才定位到的。
二、踩坑排查记录
1. Fake-IP 造成的困惑
Termius 日志里显示连接目标是 198.18.0.50:22,一开始以为是配置错了。实际上这是 Shadowrocket 开启域名拦截(TUN 模式)时的正常行为:它拦截了自定义域名的 DNS 请求,返回一个 198.18.0.0/15 网段里的假地址,借此把这条 TCP 流量接管进代理引擎——这个 IP 从来没打算真正被路由,纯粹是个占位符。
2. HTTP 302 与 http parser error
Shadowrocket 报 http parser error,日志里能看到一段 <html><title>302 Found</title>... 的网页内容。
原因是 Cloudflare Zero Trust(Access)给这个域名配置了需要登录鉴权的 Application,Cloudflare 边缘节点在放行前先返回一个 302,要求走网页登录流程。而 v2ray-plugin 说的是二进制代理协议,完全不认识这个 HTML 页面,直接握手失败。
解决办法:去 Cloudflare Zero Trust 的 Applications 里,把这个域名对应的策略删掉,或者把 Policy Action 改成 Bypass,跳过登录页。
3. v2ray-plugin 的 Host/SNI 填错
WebSocket 连接直接被 Cloudflare 边缘节点拒绝。查下来是 Shadowrocket 里 v2ray-plugin 的 Host 字段默认填了 cloudfront.com(这是 App 自带的一个示例值,不是我自己填的),跟实际请求的域名对不上。Cloudflare 边缘校验 HTTP Host 头时发现不匹配,直接挂断。
把 Host 和 SNI 都显式改成自己的域名(比如 ssh.ohnoalan.com)就解决了。
4. cloudflared 的 service 类型配错
流量到了 cloudflared 这一层就卡住。起初在 config.yml 里把后端服务写成了 service: ssh://127.0.0.1:8008,但 v2ray-plugin 监听的其实是 HTTP/WebSocket,cloudflared 得用 HTTP 代理模式跟它对话,而不是当成一个 SSH 后端。改成 service: http://127.0.0.1:8008 之后就通了。
5. Mux 两端不一致导致 invalid metalen
ss-server 端报 common/mux: unexpected EOF > common/mux: failed to read metadata > common/mux: invalid metalen 42414。
原因是 Mac 端 v2ray-plugin 默认开启了 Muxing(多路复用),手机端却关着。服务端把手机传过来的原始 Shadowsocks 字节流误当成 Mux 帧头去解析,校验元数据长度失败就直接断开了。
解决办法是让两端保持一致:在 Mac 端 ss-config.json 的 plugin_opts 里加上 mux=0,显式关掉多路复用(长连接场景下关掉也更稳定)。
三、最终配置
1. Mac 端:Shadowsocks + v2ray-plugin
用 Homebrew 装好依赖:
brew install shadowsocks-libev v2ray-plugin
~/ss-config.json:
{
"server": "127.0.0.1",
"server_port": 8008,
"password": "YourStrongPasswordHere",
"timeout": 300,
"method": "aes-256-gcm",
"plugin": "v2ray-plugin",
"plugin_opts": "server;path=/ssh-proxy;mux=0"
}
mux=0 明确关闭多路复用,避免和手机端不一致导致的解析错误。
2. Mac 端:Cloudflare Tunnel
/etc/cloudflared/config.yml:
tunnel: <Your-Tunnel-UUID>
credentials-file: /etc/cloudflared/<Your-Tunnel-UUID>.json
ingress:
- hostname: ssh.ohnoalan.com
service: http://127.0.0.1:8008
- service: http_status:404
3. Mac 端:伪域名解析
给 /etc/hosts 加一行,让本机上处理这个域名的请求都指回自己:
127.0.0.1 leealan-mb0.remote
4. 启动服务
sudo cloudflared --config /etc/cloudflared/config.yml tunnel run
ss-server -c ~/ss-config.json
实际部署时建议把这两个进程接到 launchd(.plist)里做成开机自启 + 崩溃自动重启,手动前台跑只适合调试阶段。
5. Cloudflare 后台
- DNS:
ssh.ohnoalan.com的 CNAME 指向<Tunnel-UUID>.cfargotunnel.com,代理状态开启(橙色云朵)。 - Network:域名的 Network 设置里,WebSockets 开关打开。
- Zero Trust Access:确认没有针对这个域名的拦截策略(不然又是 302 那个坑)。
6. 手机端:Shadowrocket
新建节点:
- Type:
Shadowsocks - Address:
ssh.ohnoalan.com,Port:443 - Password / Algorithm:和
ss-config.json里的一致(aes-256-gcm) - Plugin:
v2ray-plugin,Mode:websocket - Host / SNI:
ssh.ohnoalan.com - TLS:开
- Path:
/ssh-proxy(必须和 Mac 端完全一致) - Muxing:关
再加一条分流规则:域名 leealan-mb0.remote 走这个节点。
7. Termius
建两个主机配置方便切换:
| 配置项 | 局域网直连 | 外网穿透 |
|---|---|---|
| Address | 192.168.1.x / Mac.local |
leealan-mb0.remote |
| Port | 22 | 22 |
| 认证方式 | 密码 / 密钥 | 密码 / 密钥 |
四、验证
局域网下点 Mac Local,直连 22 端口。切到 5G 网络,打开 Shadowrocket,点 Mac Remote:首次连接会弹一次 Host Key 校验(SHA256:xxx...),确认之后就能正常进 Mac 的终端。
小结
这套链路层数不少:Fake-IP、TLS、WebSocket、Cloudflare Tunnel、Shadowsocks、Mux——任何一层协议不匹配都只会表现成“连接断了”这种毫无信息量的错误。真正有用的方法就是顺着流量的方向,一层一层地对照日志排查,而不是猜。这也是这次踩坑过程里最值得记下来的一点。