↓ 跳过正文

Cloudflare WARP Zero Trust

一个人的私有网络:Cloudflare WARP、Tunnel 和我用来干活的几台机器

我在 MacBook Air 上写代码,但重活(本地模型、微调、一个 k3s 集群、agent 任务)都在办公室一台没有公网域名的工作站上跑,测试用的手机又多半连着 5G。Cloudflare WARP 加上两条 Cloudflare Tunnel,把这三台设备连成了一个私有网络:固定地址放在一个别人都不用的网段里,dev server 有挂在 Access 登录后面的公网 HTTPS,仪表盘则从不碰公网。这篇讲它是怎么搭起来的:从经 Caddy 给 `.test` 域名上本地 HTTPS、用 SwiftBar 菜单显示哪些服务在线,到地址为什么是 198.18.0.x 和 fd00:198:18::x,以及路上踩过的四个坑。

我的工作环境是三台很少待在同一个地方的设备。一台 MacBook Air,我去哪它去哪。一台放在办公室、叫 devbox 的工作站,有块像样的 GPU,上面跑着 Ollama、用来微调的 Unsloth Studio、一个自带容器镜像仓库的小 k3s 集群,还有我丢在那儿慢慢啃长任务的某个 agent。再就是我的手机:我做的应用真正被使用的地方就是它,而它一天里大部分时间连的是移动数据,不是 wifi。

很长一段时间里,这三台设备之间基本不通。笔记本在办公室局域网里能连上 devbox,出了办公室就不行。手机和笔记本连着同一个 wifi 时,能访问笔记本上的 dev server,前提是我记得绑定 0.0.0.0,还得手动敲进 DHCP 那天早上分给 Mac 的地址。工作站上所有有意思的东西,Ollama API、Traefik 仪表盘、链路追踪、镜像仓库,都绑在 localhost 或集群网络上,这是故意的,因为它们都不该出现在公网上。

这篇文章讲我怎么用 Cloudflare WARP 和两条 Cloudflare Tunnel 把它们连起来,这对我做 AI 功能和移动应用的方式带来了什么变化,还有那几个坏了却一声不吭的地方。

我真正想要的
#

写成需求的话,很短:

  1. 笔记本应该是个瘦客户端。凡是需要 GPU、大量内存或者要跑几个小时的东西,都应该在 devbox 上跑,而笔记本无论在办公室、在家还是在咖啡馆,都用同一种方式连过去。
  2. 手机应该能在任何地方、用移动数据访问我的 dev server 和 shell,而且不需要一个我得记着开关的 VPN。
  3. 我用来观察系统运行状态的东西,仪表盘、链路追踪、docker info、模型列表,应该只有我能访问,而且永远不该有公网 URL。
  4. 不开入站端口。办公室路由器不开,家里路由器不开,哪儿都不开。

第四条排除了老办法,也就是端口转发加动态 DNS。它也排除了很多取巧的方案:devbox 和笔记本都在我控制不了的 NAT 后面,而笔记本每隔几小时就换一个 NAT。

之前:一个装满 SSH 隧道的仓库
#

最早的版本是一个我叫作 Automatic Tunnel 的小仓库,到现在它仍然是底层。它在笔记本上只解决一个问题:给通过 SSH 访问的东西起干净的名字,别的不管。dnsmasq 把 *.test 解析到 127.0.0.1,Caddy 按主机名路由到本地端口,用 tls internal 让浏览器满意,再由一组 autossh launch agent 保持 SSH 转发开着,断了就重连。主机名本身说明了东西在哪儿:

后缀含义承载方式
*.local.test这台 Mac无,就是个 dev server
*.ssh.test某台服务器一条 autossh 转发
*.fly.testFly.io走 WireGuard 的 fly proxy

所以 https://devbox-ollama.ssh.test/api/tags 列出的是工作站上的模型,https://api.local.test 是我手上在做的那个应用的 API,跑在我自己的机器上。

用的是 .test 的子域名,而不是 .ssh、.fly 这样新造的顶级域,也不是 .local。RFC 6761 把 .test 专门保留给这种用途,而 .local 归 mDNS 所有;占用它的话,Mac 上的 Bonjour 发现就会失效。dnsmasq 本来就通配了整个 *.test,所以多一级标签不花任何成本:一个指向 127.0.0.1 的 /etc/resolver/test 文件,此后再也不用改 DNS。

给不存在的域名上 HTTPS
#

这些名字全都走 HTTPS,证书浏览器也信任,对一个只存在于我笔记本上的主机名来说,这听起来不太可能。Caddy 靠它自带的内部证书颁发机构做到这一点。在某个站点上写 tls internal,Caddy 就会用本地 CA 给这个站点签证书,而不是去找 Let’s Encrypt;安装脚本会跑一次 caddy trust,把 CA 的根证书放进 macOS 的系统钥匙串(Chrome 和 Safari 读这里),以及 Firefox 自己的证书库。从此 https://anything.test 打开时都不会有证书警告,普通的 http:// 会重定向过去,而且没有任何东西离开这台机器。

这不只是为了好看。现在 Web 平台上有很多东西在纯 HTTP 下直接罢工:secure cookie、service worker、剪贴板 API、任何带 SameSite=None 的东西。在 http://localhost:8081 上测 Expo 的 web 构建,会把那些到了真正 HTTPS 域名上才冒出来的 bug 藏起来。在 https://app.local.test 上测,它们就会在我的笔记本上现形。

一个站点块,每个服务除了端口都一样:

devbox-ollama.ssh.test, devbox-ollama.test {
	tls internal
	import cors
	reverse_proxy 127.0.0.1:11435 {
		import proxyhdr
		transport http {
			keepalive off
			dial_timeout 5s
		}
	}
}

麻烦的部分都在那两个 snippet 里。cors 把调用方的 Origin 原样回传,而不是发 *,因为一旦涉及凭据,* 就不被允许;它还自己用 204 应答 OPTIONS 预检请求,上游永远不用操心。proxyhdr 把 Host 头改写成实际连接的地址。有些挑剔的后端,包括路由器管理页面和 Chrome 的远程调试端点,会校验 Host,否则就会把 devbox-ollama.ssh.test 当成一个从没听说过的名字拒掉。Ollama 自己的 origin 检查接受任意端口的 loopback,所以把 Host 改成 127.0.0.1:11435 就能过。

keepalive off 是我原本肯定会搞错的那个。上游是 SSH 转发,网络一变,autossh 就会把它拆掉重建。开着 keep-alive 的话,Caddy 会一直攥着一条通往已经死掉的隧道的池化连接,在重载之前一直返回莫名其妙的 404。关掉之后每个请求都重新建连,在 loopback 上这点开销几乎测不出来。

每个服务也仍然响应它原来的扁平名字(devbox-ollama.test),所以启用后缀方案之前的书签还能用。

菜单栏
#

launchd 撑着十几条转发,我老在问的问题是"它还活着吗?",而答案散在三个地方:launchctl list、lsof 和一次 curl。于是我写了个 SwiftBar 插件把它们归到一处。菜单栏上显示一个 🚇;下拉菜单里每个主机名前面有个圆点:

  • 🟢 服务通过隧道响应了请求
  • 🟡 隧道是通的,但后面的服务没反应
  • 🔴 本地端口上没有任何东西在监听,也就是隧道本身断了

有用的是黄色这一档。只分绿和红的检查,区分不了"SSH 转发断了"和"远端的 Ollama 崩了",而这两者的修法完全不同。每个服务下面有个子菜单,里面是 Open、Kick tunnel、Restart Caddy 和 Tail log。点服务名会在浏览器里打开它,而这些操作放在它下面单独一行灰色的 port → remote · tunnel 上,因为在 SwiftBar 里,带子菜单的菜单项会吞掉自己的点击用来展开子项,合在一行的话,看着像链接,却永远打不开。

插件没有自己的服务列表。它每次运行时解析当前的 Caddyfile 和 ~/.ssh/config,所以往这两个文件里加一个服务,也就等于把它加进了菜单。

第一版每十秒刷新一次,而且 每个服务 都要单独调用一次 lsof 和 launchctl,一次运行大约七十个进程。macOS 立刻把 SwiftBar 列进了"耗电量大的应用"。现在它一开始只取一次 lsof 快照和一次 launchctl 快照,所有查询都从这两段字符串里找答案,并发探测远端,每五分钟跑一次:每次大约 0.4 秒 CPU,折合一个核的 0.1%。打开菜单时它故意不刷新,因为 Fly 的转发经 WireGuard 每个要大约 1.5 秒才响应,每点一下都会卡在那儿。计数下面有一行 checked 14:05,说明这份状态有多旧,另有一个 Refresh 项可以强制刷新。

它有一个漏洞,恰恰是最要紧的那个。devbox 没有公网域名。tunnel-devbox 连的是一个 192.168.x 地址,所以只有笔记本在办公室局域网里时才管用。一离开办公室,菜单栏就变红,而且一直红着;我为了给笔记本减负而搬到工作站上的所有东西,恰好在我不坐在它旁边的时候全都够不着。

两条隧道,方向要对
#

解法分三部分,前两部分是 Cloudflare Tunnel。

Cloudflare Tunnel 就是 cloudflared 保持一条通往 Cloudflare 边缘的出站连接。Cloudflare 把请求沿着这条连接送回来,所以不管机器躲在多少层 NAT 后面,路由器上不用监听任何端口就能访问到它。笔记本连上酒店 wifi 它照样跟着走,CGNAT 也拦不住它。

我跑了两条,每台机器一条,原因值得说一句。显而易见的捷径是只在笔记本上跑一条隧道,再加一条 ingress 规则,指向办公室局域网里 devbox 的 SSH 端口。这样 devbox 上什么都不用装。可它也要经过笔记本,而笔记本正是那台会离开办公室的机器。它会恰好在它本该解决的那种情况下失效。devbox 不挪窝,所以由 devbox 自己保持通往边缘的连接。

# devbox/config.yml (run on devbox as a systemd service)
protocol: auto        # prefer QUIC, fall back to HTTP/2 over TCP 443
ha-connections: 4     # mains-powered, its job is to stay reachable

warp-routing:
  connectTimeout: 5s

ingress:
  - hostname: ssh-devbox.example.com
    service: tcp://127.0.0.1:22
  - service: http_status:404

用 tcp:// 而不是 ssh://,是个很容易弄错的地方。ssh:// 选的是 Cloudflare 在浏览器里渲染的终端,它会把数据流包装成网页用的格式;普通的 ssh 客户端从这里过,能收到 banner,然后死在密钥交换那一步。tcp:// 才是 cloudflared access ssh 期待的那种纯字节管道,顺带还能承载 scp、rsync、走 SSH 的 git 和端口转发。

最后这一点,正好补上了 Automatic Tunnel 的漏洞。现在 SSH 配置会按每次连接选路:

Match originalhost devbox,tunnel-devbox !exec "nc -z -G 1 <office-lan-ip> 22 >/dev/null 2>&1"
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...

在办公室局域网里,探测成功,SSH 直连,一跳到位。在其他任何地方探测失败,SSH 就走 Cloudflare,用 Access service token 认证,中间不用在浏览器里登录。这里是 Match originalhost,不是 Match host:host 在 HostName 替换之后才匹配,那时名字已经变成 IP 了,这个块永远不会触发。结果就是 devbox-ollama.ssh.test 在办公室、在家、在火车上都能用,菜单栏上的圆点一直是绿的。

笔记本上那条隧道方向相反。它给 Mac 上的 dev server 分配真正的公网 HTTPS 主机名:我手上在做的每个应用,都有 Django API、它的 WebSocket 应用和 Expo web 构建这三个。它们每一个都挡在 Cloudflare Access 后面,这一点后面还会讲,因为正是它让这件事变得安全,而不是鲁莽。

菜单栏也学会了认这两条隧道,而且没法靠探测来做。入站隧道没有本地端口可查,两个 connector 里有一个根本不在笔记本上跑,而最直接的测试,curl 一下 ssh-devbox.example.com,不管有没有 connector 挂着,都会从 Cloudflare 边缘拿到响应,所以隧道死了它也会显示绿色。launchd 也好不到哪去:cloudflared 所有边缘连接都断了,它照样显示"loaded, exit 0"。所以菜单的这一部分去问 Cloudflare API 它认为每条隧道是什么状态,因为决定一次 ssh 能不能落地的就是它;每一行还有 Tail log 和 Restart connector 两个操作,通过 SSH 在拥有这条隧道的那台机器上执行。如果四秒内连不上 API,这一整部分就干脆不显示,而不是画成红色。菜单是扫一眼就看的东西,那里的一次误报,比少一行的代价更大。

第三部分:WARP 让它成为一个网络
#

带主机名的隧道对任何说 HTTPS 的东西都很好用,对能跑 cloudflared 的机器上的 SSH 也一样。手机不行。而且隧道主机名不是一个 TCP 端点:Cloudflare 边缘在上面只终结 HTTPS 和 WebSocket,所以 ssh.example.com 的 22 端口是关着的,在 Access 上怎么折腾都打不开。

这就是 WARP 的活了。Cloudflare One Agent(企业版 WARP 客户端,不是面向普通用户的"1.1.1.1"应用,那个加入不了组织)装在笔记本和手机上,注册进我的 Zero Trust 组织。每条隧道通告私有路由,运行 WARP 的设备就能直接对这些地址开一个普通 socket,落到正确的机器上。它不再是"按名字访问这个服务",而是"这几台机器和我在同一个网络里":

地址机器
198.18.0.1 / fd00:198:18::1MacBook Air
198.18.0.2 / fd00:198:18::2devbox
198.18.0.3 / fd00:198:18::3另一台 Mac

这些地址是每台机器上的 loopback 别名,开机时创建,所以跟 DHCP 或者我身后是哪台路由器都没关系。198.18.0.2 就是 devbox,在办公室是,在家是,从连着 5G 的手机上也是。Mac 真正的局域网地址我每换一个地方就变一次;这个永远不变。

split tunnel 用的是 include 模式,里面只有这些路由(外加 WARP 自身需要的 Cloudflare 网段)。正是这一点让这个客户端能长期开着。默认是 exclude 模式,WARP 承载除一串私有网段之外的所有流量,于是手机发出的每个字节都要绕道 Cloudflare。这对电量和延迟有实打实的代价,大到我最后只会在需要时才打开它,那就失去意义了。在 include 模式下,发往我这三台机器的流量走 WARP,其他一切照常走原来的路径,完全不受影响。所以客户端就一直开着。笔记本上的 warp-cli status 整天都是 Connected,我根本不用去想它。

传输方式我选的是 MASQUE,也就是跑在 UDP 443 上的 HTTP/3,带 HTTP/2 回退,而不是跑在 UDP 2408 上的 WireGuard。有些运营商和访客网络会封 2408;几乎没人封 443。我组织的策略还给 MASQUE 打开了后量子密钥协商,这不是我特意去找的,但也没理由拒绝。

为什么是 198.18,为什么还有 IPv6 地址
#

这些地址看着有点怪,而我最初挑的那几个错得很有教育意义。

我第一次用的是 10.99.99.1,因为大家顺手就会选它。它在两种 split tunnel 模式下都不行。在 exclude 模式下,WARP 的默认列表排除了所有 RFC 1918 网段外加 100.64.0.0/10,所以客户端会在流量离开设备之前就把它丢掉。在 include 模式下,它会和我碰巧接入的任何真实 10.x 网络冲突;咖啡馆、联合办公空间和不少办公室用的正是这个网段。198.18.0.0/15 是 RFC 2544 的基准测试网段。它是保留地址,从不在公网上路由,不在 WARP 的排除列表里,普通网络也不会用它。IPv6 地址用 ULA(fd00::/8),理由相同。

为什么要有 IPv6,这个故事更有意思。v4 地址通了以后,我在手机上连着 wifi 测了一下,没问题,就当完事了。换到 5G 上,不通了。Agent 显示 connected ,隧道健康,Access 也没问题;wifi 上的测试已经证明了这些。问题在于现在的移动网络大多是纯 IPv6。手机根本没有原生 IPv4,是通过运营商的 464XLAT 转换器去访问那个 v4 字面地址的,而当 include 列表里只有一个 v4 前缀时,这个转换器位于隧道 之外 。流量就这么出了公网,而 198.18.0.0/15 在公网上不路由,于是被悄无声息地丢掉了。手机上没有报错,connector 的日志里什么也没有。

解法是给每台机器再配一个手机能原生通过 IPv6 访问的地址,并把它加进路由和 include 列表。现在我手机上的 SSH 客户端在移动数据下真正用的,就是 fd00:198:18:: 这组地址。

卸载:把笔记本当终端用
#

整套配置就是为这一部分存在的。

模型和微调。 Ollama 跑在 devbox 上,绑定 127.0.0.1:11434。办公室局域网里的其他机器都看不见它,更别说公网了。在笔记本上,它出现在 https://devbox-ollama.ssh.test,或者给需要端口的工具一个裸端口:

OLLAMA_HOST=127.0.0.1:11435 ollama list

用 11435 而不是 11434 是故意的:11434 是 Ollama 自己的默认端口,要是笔记本哪天也跑了个本地 Ollama,两者就会抢这个端口,而我会根据谁先启动,悄悄地从"本地"那个拿到 devbox 的回答。

实际效果是,我做 AI 功能时,它调用的模型跑在工作站的 GPU 上,而不是笔记本上,不管我人在哪儿。评估跑批、给一大堆文档做 embedding、在 Unsloth Studio 里微调:都在 devbox 上跑,笔记本的风扇一声不响。做 agent 原型时,可以让它指向工作站上的本地模型,反复跑工具调用也不产生 API 账单,等循环调顺了再换成托管模型。

长时间运行的 agent。 一个要跑一小时的 agent 会话,不该取决于我的笔记本盖子是不是一直开着。改在 devbox 上跑,笔记本就只是一扇看它的窗口:合上盖子,过会儿不管在哪儿 ssh devbox,或者从手机上连,它都还在。有隧道之前,这只在办公室里管用,而办公室恰恰是我最不需要它的地方。

构建和集群。 devbox 上的 k3s 有自己的镜像仓库,所以镜像可以在工作站上构建和推送,就在它们运行的地方旁边,而不是用笔记本通过酒店 wifi 上传。Headlamp 仪表盘、Traefik 仪表盘和镜像仓库的 UI,在笔记本上都是 *.ssh.test 主机名。

移动端的开发循环。 手机和笔记本在同一个私有网络里,笔记本的 dev server 还有挂在 Access 后面的公网主机名。所以我可以带着一个 Expo 原生构建走出大楼,连着 5G,它照样和我桌上跑着的 Django API 通信。或者和 devbox 上那个通信,如果我把它留在那儿的话。在真机、真实移动网络上的反馈循环,以前意味着部署到共享的 staging 服务器。现在意味着保存一个文件。

这里有个坑,最好在你把它当成应用 bug 去调之前就知道。Access 也保护着 WebSocket 主机名,而原生的 WebSocket 客户端不是浏览器。它不带 CF_Authorization cookie,所以 Expo 构建连到 Channels 应用的连接会在边缘被一个 403 拒掉,看起来跟 Channels 的错误完全不像。浏览器标签页没问题,因为登录后它们已经有了那个 cookie。对任何不是浏览器的东西(原生应用、脚本、调用 dev API 的 agent),答案是 Access 的 service token,以两个请求头的形式发送:

CF-Access-Client-Id:     <client_id>
CF-Access-Client-Secret: <client_secret>

这个 token 本身什么权限都不给。Access 只在策略里点了它名的应用上认它,所以它能触及的范围恰好就是那串 dev 主机名,一点不多。我把它的策略归在 non_identity 决策下,而不是 allow,这样在审计日志里它始终显示为一个持有者凭据,而不是某个登录进来的人。

公网永远看不到的数据
#

我想要的另一半是可观测性。能告诉你系统运行状况的东西,大多数本就不该公开,这没错。在 devbox 上,这些东西是:

  • Traefik 仪表盘,以及装着 Traefik 链路追踪的 Jaeger
  • 管 k3s 集群的 Headlamp
  • 容器镜像仓库的 UI(只读)
  • Ollama 的模型列表和当前加载了哪些模型
  • docker info 和容器统计数据

这些都没有公网主机名,以后也不会有。它们绑定在 loopback 或 k3s 的 service IP 上,通过 SSH 转发访问,而这条转发本身又跑在隧道上。我手机上有个监控应用,经 WARP 通过 SSH 读取 docker info --format '{{json .}}',所以不管在哪儿,我都能看到容器是不是都在跑,或者有没有什么东西把内存吃光了。

也正是这个应用让我发现了一个值得一提的小 bug。Mac 上那个隧道用的 sshd,还停留在我调试时设的 LogLevel DEBUG2。带 -e 时 sshd 把日志写到 stderr,而对一个会话来说,这个 stderr 就是 SSH 通道的 stderr。每条命令的输出后面都多了一行 debug2: do_setup_env: set TMPDIR,而手机应用会把两个流合并,于是拿到的是合法 JSON 后面跟着一行日志,解析失败,把整段显示成错误。改成 LogLevel INFO 就好了。调试完记得把日志级别改回去。

同样是刻意为之的,是我 不 放进这个网络的东西。旧的 autossh 隧道还能连到几台别人网络里的路由器,我帮忙照看着。它们没有公网主机名,也没有 WARP 路由。路由器管理页面挂在公网上,哪怕有登录挡着也是个隐患,而且那不是我的网络,轮不到我去扩大它的暴露面。

Access 不是可选项
#

Cloudflare Tunnel 的主机名,DNS 记录一建好就在公网上了。它前面没有防火墙;不开端口的全部意义就在这里。保护必须来自 Cloudflare 边缘,而这正是 Access 做的事。在访问者证明自己是谁之前,它根本不转发请求,所以没认证的扫描器永远到不了笔记本。这很重要,因为这些是 dev server:带着 settings 的 Django DEBUG 报错页,一个敞开的 Metro bundler。这些主机名后面的东西,没有一样是为面对公网而做的。

策略只放行我自己的几个邮箱地址,别的一概不放,证明身份的方式有两种:邮件一次性 PIN 码和 GitHub SSO。会话持续八小时,因为现实中的威胁是一台在咖啡馆里开着、带着一整天有效 cookie 的笔记本,而不是有人暴力破解 PIN 码。

SSH 被认证了两次,由两个互不知情的系统分别负责。Access 决定一条 TCP 流能不能到达 sshd;sshd 照样要我的密钥。丢了其中任何一个,机器都不会因此敞开。Mac 上的隧道 sshd 还信任 Cloudflare 的 SSH CA,所以 Access 可以用从我的 SSO 会话签发的短期证书让我登录,同时 authorized_keys 也照样能用,这样一份坏掉的 Access 配置也不会把我锁在自己的笔记本外面。

我最满意的是一个很小的部分。暴露发生在三件事同时成立的时候:一条 DNS 记录、一个在跑的 connector、一个已激活的 zone。它们在不同时间变为真,顺序也不由我控制;zone 会在 nameserver 改完之后过一阵子自己变成 active,那时 Mac 很可能正睡着。所以 launch agent 不直接跑 cloudflared。它先跑一个预检:从隧道自己的配置里读出主机名,问 Access API 哪些已经被覆盖,只要有一个没被覆盖就拒绝启动:

HOSTNAMES=$(grep -E '^\s*-\s*hostname:' "$CONFIG" | sed -E 's/.*hostname:\s*//' | sort -u)
PROTECTED=$(curl -fsS -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
    "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps?per_page=100" \
    | python3 -c 'import sys,json; print("\n".join(a.get("domain","") for a in json.load(sys.stdin)["result"]))') \
    || { echo "could not reach the Access API. refusing (fail closed)."; exit 1; }

for h in $HOSTNAMES; do
    grep -qxF "$h" <<<"$PROTECTED" || { echo "NOT starting: $h has no Access app"; exit 1; }
done
exec cloudflared tunnel --config "$CONFIG" run

launch agent 设了 KeepAlive,所以被拒之后它每 30 秒自己重试一次,Access 应用一建好,隧道就自己起来了。失败时关闭,代价是一次宕机。失败时敞开,代价是一次暴露。我很清楚这两件事里我更愿意解释哪一件。

踩过的四个坑
#

上面这些现在都能用了。一路走来,四个坑里有三个是同一个模式:坏掉的那个东西在任何地方都不报错,而我能看到的部分全都显示健康。

路由和 include 列表是两份不同的列表。 隧道路由让 Cloudflare 愿意 把某个地址的流量送到正确的 connector。WARP 的 include 列表才决定客户端一开始会不会 发送 它。devbox 从手机上连不通,就是因为它的路由已经存在,但 include 列表里还只有 Mac 的地址。手机在本地就把包丢了,connector 从没见到过任何流量,所有仪表盘都是绿的。每加一台新机器,都要把它的地址同时加进这两处。

纯 IPv6 移动网络上的 IPv4 字面地址。 上面讲过了:wifi 上能用,5G 上失败,没有报错。如果一条 WARP 路由在 wifi 上能用、移动数据上不行,而 Agent 显示已连接,那是路由的问题,不是隧道的问题。

cloudflared 后面的 macOS sshd。 macOS 用 launchd 的 socket activation 启动 sshd,每个连接一个短命的 sshd -i。放在 cloudflared 后面时,banner 能到达客户端,但在客户端的标识字符串传回来之前,数据流就被拆掉了,所以每次登录都死在 kex_exchange_identification。我从两端都确认过,还用一个普通的 TCP 中继复现了出来,排除了 Cloudflare 的嫌疑。解法是另起一个独立的 sshd -D,监听 127.0.0.1:2222 和 WARP 的 loopback 地址,以我的用户身份运行,唯一的用途就是充当隧道的源站。系统设置里的远程登录原封不动。devbox 是 Linux,跑的是正常的常驻 sshd,从来没有这个问题。

不复存在的配置字段。 旧的示例在隧道配置里写着 warp-routing: enabled: true。cloudflared 2026.8 会直接拒绝这个字段。只要这个块存在、且有路由指向这条隧道,WARP 路由就是开着的。至少这个报了错,跟前面三个比起来,简直称得上厚道。

它改变了什么
#

老实说,总结起来就是:我不再根据硬件在哪儿来决定在哪儿干活。笔记本是一副好键盘、一块好屏幕,续航很长,它不需要比这更多。工作站不管我在哪儿都替我扛重活。手机是在真实网络上的真实测试设备,而不是拴在办公室 wifi 上的东西。而我用来观察系统在干什么的那些部分,对我来说一点就开,对别人来说根本不存在。

这个规模下,它在 Cloudflare 免费的 Zero Trust 套餐以内。花掉的是时间,外加一份我很庆幸写了的 README,因为上面那些故障,每一个第二次遇到时都会是一个全新的谜。

如果你在考虑搭类似的东西,下面是我会拿来问你的配置的几个问题:

  1. 有哪些东西跑在你的笔记本上,只是因为笔记本是你唯一随时能连上的机器?
  2. 你的手机能用移动数据访问你的 dev server 吗,而不只是在办公室 wifi 上?用 5G 测,别用 wifi。
  3. 你的仪表盘里,有哪些是公开的,只因为那是从外面看它们最省事的办法?每一个都可以改成私有路由。
  4. 如果明天某个隧道主机名起来了却没有对应的 Access 应用,什么会拦住它?如果答案是"我会记得",那就把预检写出来。
  5. 你的私有地址所在的网段,是你去过的那些网络永远不会用的吗?