构建安全的内网穿透:用 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.jsonplugin_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 后台

  • DNSssh.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——任何一层协议不匹配都只会表现成“连接断了”这种毫无信息量的错误。真正有用的方法就是顺着流量的方向,一层一层地对照日志排查,而不是猜。这也是这次踩坑过程里最值得记下来的一点。