<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Cloudflare Tunnel · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/cloudflare-tunnel/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/cloudflare-tunnel/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>笔记本就是服务器：用 Cloudflare Tunnel 把副业项目放上公网</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>Cloudflare Tunnel 是什么、安全性如何、要花多少钱？不开任何端口，让自己机器上的服务拿到真正的 HTTPS 主机名，再用 ingress 白名单决定公网能访问哪些路径，附 Docker Compose 配置。</description>
      <content:encoded><![CDATA[<p>我的每个想法都是这样开始的：在笔记本上跑起来了，能用，然后想让别人也看看。有很长一段时间，下一步总是一张云账单：一台机器、一个数据库、一个 bucket、一个指过去的域名，再为一个只有三个人会看的东西按月付费。对副业项目，或者一个正经项目的第一个月来说，这个顺序是反的：还不知道会不会有生产环境，就先为生产环境掏钱了。</p>
<p>这篇文章讲的是另一种顺序。服务继续跑在开发它的那台机器上，由 Cloudflare Tunnel 给它一个公开的 HTTPS 主机名。笔记本上没有任何东西对互联网监听：不做端口转发，不碰路由器设置，也不暴露 IP 地址。是机器主动连出去到 Cloudflare，Cloudflare 再把请求顺着这条连接送回来。Tunnel 本身免费，TLS 证书、DDoS 防护和缓存则随你多半已经有的 Cloudflare zone 一起附送。</p>
<p>例子是真实的。我的<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >一个产品</a> 跑在 Fly.io 上，它的移动应用从后端获取 JavaScript 的 OTA 更新（自托管的 Expo Updates）。我想在自己的 Mac 上再跑一份更新服务器，给开发构建用，生产环境原封不动。下面的输出都来自那个下午，包括没跑通的那部分。</p>

<h2 class="relative group">Cloudflare Tunnel 到底是什么
    <div id="cloudflare-tunnel-到底是什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cloudflare-tunnel-%e5%88%b0%e5%ba%95%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>普通的服务器是等别人连进来的。要从互联网访问一台笔记本，你得在路由器上开端口、转发到笔记本，还得指望运营商给你一个公网 IPv4 地址（很多地方都不给，我这里也是）。然后，那个端口上监听的所有东西，整天都暴露在互联网上每一个扫描器面前。</p>
<p>Tunnel 把方向反过来。<code>cloudflared</code> 跑在你的应用旁边，主动向 Cloudflare 的边缘建立出站连接，一共四条，连到附近的两个数据中心。你 zone 里的一条 DNS 记录把主机名指向这个 tunnel。当请求打到这个主机名时，Cloudflare 终止 TLS，把请求顺着其中一条连接转发下来，<code>cloudflared</code> 再按你配置里写的交给对应的本地服务。</p>
<figure class="diagram">
  <div role="img" aria-label="请求经 HTTPS 到达 Cloudflare，经过它的规则，到达 tunnel 端点，再顺着 cloudflared 从你机器上建立的出站连接下来；cloudflared 按主机名和路径把它交给 API、edge，或者返回 404"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">互联网</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">你的机器</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">手机 / 浏览器</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="22">TLS、重定向规则、WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">Tunnel 端点</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">出站 QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">（不开端口）</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress 规则：主机名 + 路径白名单</text>
</svg>
</div><figcaption>机器只会往外连。Cloudflare 的 zone 规则在 tunnel 之前执行；ingress 规则决定什么能被访问到。</figcaption>
</figure>

<p>从安全角度看，关键在于路由器上没有任何入站规则，你的 IP 地址也从不出现在 DNS 里。有人扫描你家的网络连接，什么也找不到，因为上面根本没有东西在监听。</p>

<h2 class="relative group">从头到尾的搭建
    <div id="从头到尾的搭建" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bb%8e%e5%a4%b4%e5%88%b0%e5%b0%be%e7%9a%84%e6%90%ad%e5%bb%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>你需要一个托管在 Cloudflare 上的域名（免费套餐就够），以及装好 <code>cloudflared</code>（Mac 上 <code>brew install cloudflared</code>）。</p>
<p><strong>1. 登录一次。</strong> 这会打开浏览器，你选好 zone，它会保存一张证书，让这台机器可以在那个 zone 里创建 tunnel 和 DNS 记录：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. 创建 tunnel。</strong> 这个项目的所有东西我都放在单独的文件夹里，因为凭据文件就是 tunnel 的身份：谁拿到它，谁就能替你的主机名提供流量。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. 把主机名指向它。</strong> 每个主机名一条命令，创建一条指向 <code>&lt;tunnel-id&gt;.cfargotunnel.com</code> 的代理 CNAME：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. 写 ingress 规则。</strong> 这是对安全最要紧的部分，所以下面单独一节来讲。</p>
<p><strong>5. 跑起来。</strong> 我的应用本来就跑在 Docker Compose 下，所以 <code>cloudflared</code> 只是同一个网络里多出来的一个容器。它按服务名访问应用，应用根本不需要在宿主机上发布端口：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>四条连接连到首尔，笔记本就从外向内地上了互联网。<code>restart: unless-stopped</code> 让它在重启后、Docker 一启动就自动恢复。</p>

<h2 class="relative group">暴露路径，而不是服务器
    <div id="暴露路径而不是服务器" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9a%b4%e9%9c%b2%e8%b7%af%e5%be%84%e8%80%8c%e4%b8%8d%e6%98%af%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是我差点犯的错，我猜也是最常见的一个。最省事的配置只有一行：<code>hostname → http://localhost:8000</code>。这会把<strong>整个开发服务器</strong>都发布出去。</p>
<p>开发服务器不是为这个设计的。我的开着 <code>DEBUG=True</code>，任何错误都会显示完整的 traceback 和配置。它还关掉了认证（<code>AUTH_MODE=disabled</code>），任何 bearer token 都会让你以测试用户身份登录。这在 <code>localhost</code> 上完全合理，放到公网主机名上就是灾难。你的多半也有类似的东西：用默认密码的管理后台、灌测试数据的端点、debug toolbar，或者因为只有你自己在调用、所以信任调用方的 API。</p>
<p>所以 tunnel 配置是一份白名单。<code>cloudflared</code> 按顺序拿每个请求去匹配 ingress 规则，按主机名，也按路径上的正则表达式。第一条匹配的生效，最后一条兜住其余所有请求：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>发布端点就在同一台服务器上、同一个前缀下（<code>/api/expo-updates/import-update/</code>），但它不在列表里。管理后台和 API 的其余部分也不在。这些请求根本出不了 Cloudflare。</p>
<p>你不必盲信自己的正则；<code>cloudflared</code> 会告诉你一个 URL 匹配的是哪条规则：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>我会把这个检查写进脚本，每次改配置都跑一遍。只要几秒钟，而一条比你本意匹配得更多的正则，正是那种只会从别人那里得知的错误。</p>
<p>还有两条经验法则：</p>
<ul>
<li><strong>暴露出去的东西应该自己负责认证。</strong> OTA 端点提供的是公开的只读数据。设备 check-in 用每台设备自己的密钥签名。两者的安全都不依赖网络。如果你想给别人看一个没有登录的页面，就在那个主机名前面放 <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a>（50 个用户以内免费），而不是自己去做登录。</li>
<li><strong>别把 tunnel 当成开发流程里常驻后台的一部分。</strong> 有意识地启动它，只为你想要的那个主机名，用完就停。<code>docker compose stop cloudflared</code> 一条命令就把一切从互联网上撤下来。</li>
</ul>

<h2 class="relative group">吃掉我主机名的那个重定向
    <div id="吃掉我主机名的那个重定向" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%90%83%e6%8e%89%e6%88%91%e4%b8%bb%e6%9c%ba%e5%90%8d%e7%9a%84%e9%82%a3%e4%b8%aa%e9%87%8d%e5%ae%9a%e5%90%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Tunnel 连上了，规则也验证过了，我从外部试了一下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>每条路径都返回同一个 301，包括那些 tunnel 本该回 404 的。这说明请求根本没到 <code>cloudflared</code>。<code>Location</code> 头写的是 <code>https://www.example.com/</code>，再加上 <code>server: cloudflare</code> 头，事情就清楚了：是 Cloudflare 自己在应答。</p>
<p>这是我自己挖的坑。几个月前我加了一条 zone 级的重定向规则，“其他任何子域名 → www”，用来当<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >那些遍历编造出来的子域名的扫描器的黑洞</a>。重定向规则在 Cloudflare 的请求流水线里执行，早于请求被发往源站，而 tunnel 不过是一个源站。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >同一条规则也拦住过我的预览环境</a>，让 Workers 根本见不到请求。修法和上次一样：在规则表达式里再加一个例外。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>如果你的 tunnel 主机名返回的是 Cloudflare 的重定向或错误页面，而不是你的应用，那在调试 tunnel 之前，先检查这个 zone 的 <strong>Rules → Redirect Rules</strong>、Page Rules 和所有 WAF 自定义规则。<code>cloudflared</code> 会记录它转发的每个请求，所以日志为空就说明请求根本没走到那一步。</p>

<h2 class="relative group">它做不到的事
    <div id="它做不到的事" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%83%e5%81%9a%e4%b8%8d%e5%88%b0%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>来自公网的 UDP。</strong> 公开的 tunnel 主机名只承载 HTTP(S)（外加 WebSocket 和 gRPC）。我产品的设备平时通过 UDP 上的 CoAP 做 check-in，这走不了 tunnel；连本地副本的设备会退回到 HTTPS check-in，每次要多花几秒钟。如果你的想法是游戏服务器或 VoIP，tunnel 就不是合适的工具。</li>
<li><strong>在线率。</strong> 这是你的笔记本。合上盖子，站点就挂了；Cloudflare 会显示一个 530 错误页。拿来做演示、十个人的内测或者一份 staging 副本都没问题，但不适合任何有人依赖的东西。</li>
<li><strong>编译进构建里的东西。</strong> 移动应用的更新 URL 是原生配置，所以只有用 tunnel URL 构建出来的包才会用它。我的商店构建依然连着生产环境，这也正是这套方案敢拿来试的原因。</li>
</ul>

<h2 class="relative group">成本
    <div id="成本" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%90%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>免费套餐的 Cloudflare zone、tunnel、它的证书和 DNS 记录：0 美元。机器本来就开着。唯一真正的成本是，现在你自己就是运维值班团队。</p>
<p>它帮你省下的，是从“在我机器上能跑”到“值得花钱托管”之间的那个阶段。当天下午你就能把链接发给测试、设计师或者第一个客户。如果没人在意，你什么也没损失。如果有人在意，迁到真正的主机上也只是一处改动：把 CNAME 指到别处。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>用 Cloudflare WARP 做内网穿透：把笔记本、手机和工作站连成一个私有网络</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-01-cloudflare-warp-dev-network/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-01-cloudflare-warp-dev-network/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>WARP</category>
      <category>Zero Trust</category>
      <category>Cloudflare Tunnel</category>
      <category>Networking</category>
      <category>SSH</category>
      <category>DevOps</category>
      <category>AI Agents</category>
      <category>Ollama</category>
      <category>Mobile Development</category>
      <category>IPv6</category>
      <description>我用 Cloudflare WARP 加两条 Cloudflare Tunnel（Zero Trust），把笔记本、连着 5G 的手机和办公室里的 GPU 工作站放进同一个私有网络：固定的私有 IP，不开任何端口，dev server 挂在 Access 后面。</description>
      <content:encoded><![CDATA[<p>Cloudflare WARP 能把你自己的几台机器放进同一个私有网络，一个端口都不用开。在每台想访问的机器上跑一条 Cloudflare Tunnel（<code>cloudflared</code>），让每条隧道各自通告一个私有 IP 网段，再用 WARP 客户端把笔记本和手机注册进一个 Zero Trust 组织，split tunnel 设成 include 模式。之后，连着 5G 的手机直接连 <code>198.18.42.2</code>，就能落到办公室里的那台工作站上。下面是我的具体配置，以及路上踩过的四个坑。</p>
<p>我的工作环境是三台很少待在同一个地方的设备。一台 MacBook Air，我去哪它去哪。一台放在办公室、叫 devbox 的工作站，有块像样的 GPU，上面跑着 Ollama、几样微调工具、一个自带容器镜像仓库的小 Kubernetes 集群，还有我丢在那儿慢慢啃长任务的某个 agent。再就是我的手机：我做的应用真正被使用的地方就是它，而它一天里大部分时间连的是移动数据，不是 wifi。</p>
<p>很长一段时间里，这三台设备之间基本不通。笔记本在办公室局域网里能连上 devbox，出了办公室就不行。手机和笔记本连着同一个 wifi 时，能访问笔记本上的 dev server，前提是我记得绑定 <code>0.0.0.0</code>，还得手动敲进 DHCP 那天早上分给 Mac 的地址。工作站上所有有意思的东西，Ollama API、集群的仪表盘、链路追踪、镜像仓库，都绑在 localhost 或集群网络上，这是故意的，因为它们都不该出现在公网上。</p>
<p>这篇文章讲我怎么用 Cloudflare WARP 和两条 Cloudflare Tunnel 把它们连起来，这对我做 AI 功能和移动应用的方式带来了什么变化，还有那几个坏了却一声不吭的地方。</p>

<h2 class="relative group">我真正想要的
    <div id="我真正想要的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e7%9c%9f%e6%ad%a3%e6%83%b3%e8%a6%81%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>写成需求的话，很短：</p>
<ol>
<li>笔记本应该是个瘦客户端。凡是需要 GPU、大量内存或者要跑几个小时的东西，都应该在 devbox 上跑，而笔记本无论在办公室、在家还是在咖啡馆，都用同一种方式连过去。</li>
<li>手机应该能在任何地方、用移动数据访问我的 dev server 和 shell，而且不需要一个我得记着开关的 VPN。</li>
<li>我用来观察系统运行状态的东西，仪表盘、链路追踪、<code>docker info</code>、模型列表，应该只有我能访问，而且永远不该有公网 URL。</li>
<li>不开入站端口。办公室路由器不开，家里路由器不开，哪儿都不开。</li>
</ol>
<p>第四条排除了老办法，也就是端口转发加动态 DNS。它也排除了很多取巧的方案：devbox 和笔记本都在我控制不了的 NAT 后面，而笔记本每隔几小时就换一个 NAT。</p>

<h2 class="relative group">之前：一个装满 SSH 隧道的仓库
    <div id="之前一个装满-ssh-隧道的仓库" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b9%8b%e5%89%8d%e4%b8%80%e4%b8%aa%e8%a3%85%e6%bb%a1-ssh-%e9%9a%a7%e9%81%93%e7%9a%84%e4%bb%93%e5%ba%93" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最早的版本是我自己写的一个小仓库，到现在它仍然是底层。它在笔记本上只解决一个问题：给通过 SSH 访问的东西起干净的名字，别的不管。dnsmasq 把 <code>*.test</code> 解析到 <code>127.0.0.1</code>，Caddy 按主机名路由到本地端口，用 <code>tls internal</code> 让浏览器满意，再由一组 autossh launch agent 保持 SSH 转发开着，断了就重连。主机名本身说明了东西在哪儿：</p>
<table>
	<thead>
			<tr>
					<th>后缀</th>
					<th>含义</th>
					<th>承载方式</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>*.local.test</code></td>
					<td>这台 Mac</td>
					<td>无，就是个 dev server</td>
			</tr>
			<tr>
					<td><code>*.ssh.test</code></td>
					<td>某台服务器</td>
					<td>一条 autossh 转发</td>
			</tr>
			<tr>
					<td><code>*.cloud.test</code></td>
					<td>托管在云上的应用</td>
					<td>托管平台自带的代理</td>
			</tr>
	</tbody>
</table>
<p>所以 <code>https://devbox-ollama.ssh.test/api/tags</code> 列出的是工作站上的模型，<code>https://api.local.test</code> 是我手上在做的那个应用的 API，跑在我自己的机器上。</p>
<p>用的是 <code>.test</code> 的子域名，而不是 <code>.ssh</code>、<code>.cloud</code> 这样新造的顶级域，也不是 <code>.local</code>。RFC 6761 把 <code>.test</code> 专门保留给这种用途，而 <code>.local</code> 归 mDNS 所有；占用它的话，Mac 上的 Bonjour 发现就会失效。dnsmasq 本来就通配了整个 <code>*.test</code>，所以多一级标签不花任何成本：一个指向 <code>127.0.0.1</code> 的 <code>/etc/resolver/test</code> 文件，此后再也不用改 DNS。</p>

<h3 class="relative group">给不存在的域名上 HTTPS
    <div id="给不存在的域名上-https" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%bb%99%e4%b8%8d%e5%ad%98%e5%9c%a8%e7%9a%84%e5%9f%9f%e5%90%8d%e4%b8%8a-https" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这些名字全都走 HTTPS，证书浏览器也信任，对一个只存在于我笔记本上的主机名来说，这听起来不太可能。Caddy 靠它自带的内部证书颁发机构做到这一点。在某个站点上写 <code>tls internal</code>，Caddy 就会用本地 CA 给这个站点签证书，而不是去找 Let&rsquo;s Encrypt；安装脚本会跑一次 <code>caddy trust</code>，把 CA 的根证书放进 macOS 的系统钥匙串（Chrome 和 Safari 读这里），以及 Firefox 自己的证书库。从此 <code>https://anything.test</code> 打开时都不会有证书警告，普通的 <code>http://</code> 会重定向过去，而且没有任何东西离开这台机器。</p>
<p>这不只是为了好看。现在 Web 平台上有很多东西在纯 HTTP 下直接罢工：secure cookie、service worker、剪贴板 API、任何带 <code>SameSite=None</code> 的东西。在纯 <code>http://localhost</code> 上测 web 构建，会把那些到了真正 HTTPS 域名上才冒出来的 bug 藏起来。在 <code>https://app.local.test</code> 上测，它们就会在我的笔记本上现形。</p>
<p>一个站点块，每个服务除了端口都一样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">devbox-ollama.ssh.test</span>, <span class="gh">devbox-ollama.test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="k">tls</span> <span class="no">internal</span>
</span></span><span class="line"><span class="cl">	<span class="k">import</span> cors
</span></span><span class="line"><span class="cl">	<span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">11435</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">		<span class="k">import</span> proxyhdr
</span></span><span class="line"><span class="cl">		<span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="k">keepalive</span> <span class="no">off</span>
</span></span><span class="line"><span class="cl">			<span class="k">dial_timeout</span> <span class="mi">5s</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>麻烦的部分都在那两个 snippet 里。<code>cors</code> 把调用方的 <code>Origin</code> 原样回传，而不是发 <code>*</code>，因为一旦涉及凭据，<code>*</code> 就不被允许；它还自己用 204 应答 <code>OPTIONS</code> 预检请求，上游永远不用操心。<code>proxyhdr</code> 把 <code>Host</code> 头改写成实际连接的地址。有些挑剔的后端，包括路由器管理页面和 Chrome 的远程调试端点，会校验 <code>Host</code>，否则就会把 <code>devbox-ollama.ssh.test</code> 当成一个从没听说过的名字拒掉。Ollama 自己的 origin 检查接受任意端口的 loopback，所以把 <code>Host</code> 改成 <code>127.0.0.1:11435</code> 就能过。</p>
<p><code>keepalive off</code> 是我原本肯定会搞错的那个。上游是 SSH 转发，网络一变，autossh 就会把它拆掉重建。开着 keep-alive 的话，Caddy 会一直攥着一条通往已经死掉的隧道的池化连接，在重载之前一直返回莫名其妙的 404。关掉之后每个请求都重新建连，在 loopback 上这点开销几乎测不出来。</p>
<p>每个服务也仍然响应它原来的扁平名字（<code>devbox-ollama.test</code>），所以启用后缀方案之前的书签还能用。</p>

<h3 class="relative group">菜单栏
    <div id="菜单栏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%8f%9c%e5%8d%95%e6%a0%8f" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>launchd 撑着十几条转发，我老在问的问题是&quot;它还活着吗？&quot;，而答案散在三个地方：<code>launchctl list</code>、<code>lsof</code> 和一次 <code>curl</code>。于是我写了个小小的菜单栏插件把它们归到一处，每个主机名前面有个圆点：</p>
<ul>
<li>🟢 服务通过隧道响应了请求</li>
<li>🟡 隧道是通的，但后面的服务没反应</li>
<li>🔴 本地端口上没有任何东西在监听，也就是隧道本身断了</li>
</ul>
<p>有用的是黄色这一档。只分绿和红的检查，区分不了&quot;SSH 转发断了&quot;和&quot;远端的 Ollama 崩了&quot;，而这两者的修法完全不同。插件不自己维护服务列表，而是读取和其他部分同一份代理配置和 SSH 配置，所以在那里加一个服务，菜单里也就有了。</p>
<p>第一版太贪了。它每十秒刷新一次，每个服务都要起好几个进程，一次运行大约七十个，macOS 立刻把它列进了&quot;耗电量大的应用&quot;。改成每次运行只取一次快照、每隔几分钟刷新之后，降到了大约一个核的 0.1%。一个状态灯要是费电，就帮了倒忙，所以开销最好一开始就算进去。</p>
<p>它有一个漏洞，恰恰是最要紧的那个。devbox 没有公网域名。它的 SSH 转发连的是一个 <code>192.168.x</code> 地址，所以只有笔记本在办公室局域网里时才管用。一离开办公室，菜单栏就变红，而且一直红着；我为了给笔记本减负而搬到工作站上的所有东西，恰好在我不坐在它旁边的时候全都够不着。</p>

<h2 class="relative group">两条隧道，方向要对
    <div id="两条隧道方向要对" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%a4%e6%9d%a1%e9%9a%a7%e9%81%93%e6%96%b9%e5%90%91%e8%a6%81%e5%af%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>解法分三部分，前两部分是 Cloudflare Tunnel。</p>
<p>Cloudflare Tunnel 就是 <code>cloudflared</code> 保持一条通往 Cloudflare 边缘的出站连接。Cloudflare 把请求沿着这条连接送回来，所以不管机器躲在多少层 NAT 后面，路由器上不用监听任何端口就能访问到它。笔记本连上酒店 wifi 它照样跟着走，CGNAT 也拦不住它。</p>
<p>我跑了两条，每台机器一条，原因值得说一句。显而易见的捷径是只在笔记本上跑一条隧道，再加一条 ingress 规则，指向办公室局域网里 devbox 的 SSH 端口。这样 devbox 上什么都不用装。可它也要经过笔记本，而笔记本正是那台会离开办公室的机器。它会恰好在它本该解决的那种情况下失效。devbox 不挪窝，所以由 devbox 自己保持通往边缘的连接。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># config.yml on devbox (runs as a systemd service)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">auto       </span><span class="w"> </span><span class="c"># prefer QUIC, fall back to HTTP/2 over TCP 443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ha-connections</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">     </span><span class="c"># mains-powered, its job is to stay reachable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">warp-routing</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">connectTimeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ssh-devbox.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">tcp://127.0.0.1:22</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>用 <code>tcp://</code> 而不是 <code>ssh://</code>，是个很容易弄错的地方。<code>ssh://</code> 选的是 Cloudflare 在浏览器里渲染的终端，它会把数据流包装成网页用的格式；普通的 <code>ssh</code> 客户端从这里过，能收到 banner，然后死在密钥交换那一步。<code>tcp://</code> 才是 <code>cloudflared access ssh</code> 期待的那种纯字节管道，顺带还能承载 <code>scp</code>、<code>rsync</code>、走 SSH 的 git 和端口转发。</p>
<p>最后这一点，正好补上了 SSH 这一层的漏洞。现在 SSH 配置会按每次连接选路：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-sshconfig" data-lang="sshconfig">Match originalhost devbox !exec &#34;nc -z -G 1 &lt;office-lan-ip&gt; 22 &gt;/dev/null 2&gt;&amp;1&#34;
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...</code></pre></div>
<p>在办公室局域网里，探测成功，SSH 直连，一跳到位。在其他任何地方探测失败，SSH 就走 Cloudflare，用 Access service token 认证，中间不用在浏览器里登录。这里是 <code>Match originalhost</code>，不是 <code>Match host</code>：<code>host</code> 在 <code>HostName</code> 替换之后才匹配，那时名字已经变成 IP 了，这个块永远不会触发。结果就是 <code>devbox-ollama.ssh.test</code> 在办公室、在家、在火车上都能用，菜单栏上的圆点一直是绿的。</p>
<p>笔记本上那条隧道方向相反。它给 Mac 上的 dev server 分配真正的公网 HTTPS 主机名：我手上在做的每个应用，都有 API、WebSocket 服务和 web 构建这三个。它们每一个都挡在 Cloudflare Access 后面，这一点后面还会讲，因为正是它让这件事变得安全，而不是鲁莽。</p>
<p>菜单栏也学会了认这两条隧道，而且没法靠探测来做。入站隧道没有本地端口可查，两个 connector 里有一个根本不在笔记本上跑，而最直接的测试，curl 一下 <code>ssh-devbox.example.com</code>，不管有没有 connector 挂着，都会从 Cloudflare 边缘拿到响应，所以隧道死了它也会显示绿色。launchd 也好不到哪去：<code>cloudflared</code> 所有边缘连接都断了，它照样显示&quot;loaded, exit 0&quot;。所以隧道这部分改成去问 Cloudflare API 每条隧道有没有健康的 connector，因为决定一次 <code>ssh</code> 能不能落地的就是它。如果连不上 API，这一整部分就干脆不显示，而不是画成红色。菜单是扫一眼就看的东西，那里的一次误报，比少一行的代价更大。</p>

<h2 class="relative group">第三部分：WARP 让它成为一个网络
    <div id="第三部分warp-让它成为一个网络" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac%e4%b8%89%e9%83%a8%e5%88%86warp-%e8%ae%a9%e5%ae%83%e6%88%90%e4%b8%ba%e4%b8%80%e4%b8%aa%e7%bd%91%e7%bb%9c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>带主机名的隧道对任何说 HTTPS 的东西都很好用，对能跑 <code>cloudflared</code> 的机器上的 SSH 也一样。手机不行。而且隧道主机名不是一个 TCP 端点：Cloudflare 边缘在上面只终结 HTTPS 和 WebSocket，所以 <code>ssh.example.com</code> 的 22 端口是关着的，在 Access 上怎么折腾都打不开。</p>
<p>这就是 WARP 的活了。Cloudflare One Agent（企业版 WARP 客户端，不是面向普通用户的&quot;1.1.1.1&quot;应用，那个加入不了组织）装在笔记本和手机上，注册进我的 Zero Trust 组织。每条隧道通告私有路由，运行 WARP 的设备就能直接对这些地址开一个普通 socket，落到正确的机器上。它不再是&quot;按名字访问这个服务&quot;，而是&quot;这几台机器和我在同一个网络里&quot;：</p>
<table>
	<thead>
			<tr>
					<th>地址</th>
					<th>机器</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>198.18.42.1</code> / <code>fd12:3456:789a::1</code></td>
					<td>MacBook Air</td>
			</tr>
			<tr>
					<td><code>198.18.42.2</code> / <code>fd12:3456:789a::2</code></td>
					<td>devbox</td>
			</tr>
			<tr>
					<td><code>198.18.42.3</code> / <code>fd12:3456:789a::3</code></td>
					<td>另一台 Mac</td>
			</tr>
	</tbody>
</table>
<p>这些地址是每台机器上的 loopback 别名，开机时创建，所以跟 DHCP 或者我身后是哪台路由器都没关系。<code>198.18.42.2</code> 就是 devbox，在办公室是，在家是，从连着 5G 的手机上也是。Mac 真正的局域网地址我每换一个地方就变一次；这个永远不变。</p>
<p>split tunnel 用的是 <strong>include 模式</strong>，里面只有这些路由（外加 WARP 自身需要的 Cloudflare 网段）。正是这一点让这个客户端能长期开着。默认是 exclude 模式，WARP 承载除一串私有网段之外的所有流量，于是手机发出的每个字节都要绕道 Cloudflare。这对电量和延迟有实打实的代价，大到我最后只会在需要时才打开它，那就失去意义了。在 include 模式下，发往我这三台机器的流量走 WARP，其他一切照常走原来的路径，完全不受影响。所以客户端就一直开着。笔记本上的 <code>warp-cli status</code> 整天都是 <code>Connected</code>，我根本不用去想它。</p>
<p>传输方式我选的是 MASQUE，也就是跑在 UDP 443 上的 HTTP/3，带 HTTP/2 回退，而不是跑在 UDP 2408 上的 WireGuard。有些运营商和访客网络会封 2408；几乎没人封 443。我组织的策略还给 MASQUE 打开了后量子密钥协商，这不是我特意去找的，但也没理由拒绝。</p>

<h2 class="relative group">为什么是 198.18，为什么还有 IPv6 地址
    <div id="为什么是-19818为什么还有-ipv6-地址" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e6%98%af-19818%e4%b8%ba%e4%bb%80%e4%b9%88%e8%bf%98%e6%9c%89-ipv6-%e5%9c%b0%e5%9d%80" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这些地址看着有点怪，而我最初挑的那几个错得很有教育意义。</p>
<p>我第一次用的是 <code>10.10.0.1</code>，因为大家顺手就会选它。它在两种 split tunnel 模式下都不行。在 exclude 模式下，WARP 的默认列表排除了所有 RFC 1918 网段外加 <code>100.64.0.0/10</code>，所以客户端会在流量离开设备之前就把它丢掉。在 include 模式下，它会和我碰巧接入的任何真实 <code>10.x</code> 网络冲突；咖啡馆、联合办公空间和不少办公室用的正是这个网段。<code>198.18.0.0/15</code> 是 RFC 2544 的基准测试网段。它是保留地址，从不在公网上路由，不在 WARP 的排除列表里，普通网络也不会用它。IPv6 地址用 ULA（<code>fd00::/8</code>），理由相同。</p>
<p>为什么要有 IPv6，这个故事更有意思。v4 地址通了以后，我在手机上连着 wifi 测了一下，没问题，就当完事了。换到 5G 上，不通了。Agent 显示 <em>connected</em> ，隧道健康，Access 也没问题；wifi 上的测试已经证明了这些。问题在于现在的移动网络大多是纯 IPv6。手机根本没有原生 IPv4，是通过运营商的 464XLAT 转换器去访问那个 v4 字面地址的，而当 include 列表里只有一个 v4 前缀时，这个转换器位于隧道 <em>之外</em> 。流量就这么出了公网，而 <code>198.18.0.0/15</code> 在公网上不路由，于是被悄无声息地丢掉了。手机上没有报错，connector 的日志里什么也没有。</p>
<p>解法是给每台机器再配一个手机能原生通过 IPv6 访问的地址，并把它加进路由和 include 列表。现在我手机上的 SSH 客户端在移动数据下真正用的，就是 <code>fd12:3456:789a::</code> 这组地址。</p>

<h2 class="relative group">卸载：把笔记本当终端用
    <div id="卸载把笔记本当终端用" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8d%b8%e8%bd%bd%e6%8a%8a%e7%ac%94%e8%ae%b0%e6%9c%ac%e5%bd%93%e7%bb%88%e7%ab%af%e7%94%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>整套配置就是为这一部分存在的。</p>
<p><strong>模型和微调。</strong> Ollama 跑在 devbox 上，绑定 <code>127.0.0.1:11434</code>。办公室局域网里的其他机器都看不见它，更别说公网了。在笔记本上，它出现在 <code>https://devbox-ollama.ssh.test</code>，或者给需要端口的工具一个裸端口：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">OLLAMA_HOST</span><span class="o">=</span>127.0.0.1:11435 ollama list</span></span></code></pre></div></div>
<p>用 11435 而不是 11434 是故意的：11434 是 Ollama 自己的默认端口，要是笔记本哪天也跑了个本地 Ollama，两者就会抢这个端口，而我会根据谁先启动，悄悄地从&quot;本地&quot;那个拿到 devbox 的回答。</p>
<p>实际效果是，我做 AI 功能时，它调用的模型跑在工作站的 GPU 上，而不是笔记本上，不管我人在哪儿。评估跑批、给一大堆文档做 embedding、跑一次微调：都在 devbox 上跑，笔记本的风扇一声不响。做 agent 原型时，可以让它指向工作站上的本地模型，反复跑工具调用也不产生 API 账单，等循环调顺了再换成托管模型。</p>
<p><strong>长时间运行的 agent。</strong> 一个要跑一小时的 agent 会话，不该取决于我的笔记本盖子是不是一直开着。改在 devbox 上跑，笔记本就只是一扇看它的窗口：合上盖子，过会儿不管在哪儿 <code>ssh devbox</code>，或者从手机上连，它都还在。有隧道之前，这只在办公室里管用，而办公室恰恰是我最不需要它的地方。</p>
<p><strong>构建和集群。</strong> devbox 上的集群有自己的镜像仓库，所以镜像可以在工作站上构建和推送，就在它们运行的地方旁边，而不是用笔记本通过酒店 wifi 上传。集群仪表盘、ingress 仪表盘和镜像仓库的 UI，在笔记本上都是 <code>*.ssh.test</code> 主机名。</p>
<p><strong>移动端的开发循环。</strong> 手机和笔记本在同一个私有网络里，笔记本的 dev server 还有挂在 Access 后面的公网主机名。所以我可以带着应用的原生构建走出大楼，连着 5G，它照样和我桌上跑着的 API 通信。或者和 devbox 上那个通信，如果我把它留在那儿的话。在真机、真实移动网络上的反馈循环，以前意味着部署到共享的 staging 服务器。现在意味着保存一个文件。</p>
<p>这里有个坑，最好在你把它当成应用 bug 去调之前就知道。Access 也保护着 WebSocket 主机名，而原生的 WebSocket 客户端不是浏览器。它不带 <code>CF_Authorization</code> cookie，所以原生构建连到 WebSocket 服务的连接会在边缘被一个 403 拒掉，看起来跟你自己服务器报的错完全不像。浏览器标签页没问题，因为登录后它们已经有了那个 cookie。对任何不是浏览器的东西（原生应用、脚本、调用 dev API 的 agent），答案是 Access 的 <strong>service token</strong>，以两个请求头的形式发送：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">CF-Access-Client-Id:     &lt;client_id&gt;
</span></span><span class="line"><span class="cl">CF-Access-Client-Secret: &lt;client_secret&gt;</span></span></code></pre></div></div>
<p>这个 token 本身什么权限都不给。Access 只在策略里点了它名的应用上认它，所以它能触及的范围恰好就是那串 dev 主机名，一点不多。我把它的策略归在 <code>non_identity</code> 决策下，而不是 <code>allow</code>，这样在审计日志里它始终显示为一个持有者凭据，而不是某个登录进来的人。</p>

<h2 class="relative group">公网永远看不到的数据
    <div id="公网永远看不到的数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ac%e7%bd%91%e6%b0%b8%e8%bf%9c%e7%9c%8b%e4%b8%8d%e5%88%b0%e7%9a%84%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我想要的另一半是可观测性。能告诉你系统运行状况的东西，大多数本就不该公开，这没错。在 devbox 上，这些东西是：</p>
<ul>
<li>ingress 代理的仪表盘，以及装着它的链路追踪的追踪界面</li>
<li>管 Kubernetes 集群的仪表盘</li>
<li>容器镜像仓库的 UI（只读）</li>
<li>Ollama 的模型列表和当前加载了哪些模型</li>
<li><code>docker info</code> 和容器统计数据</li>
</ul>
<p>这些都没有公网主机名，以后也不会有。它们绑定在 loopback 或集群的 service IP 上，通过 SSH 转发访问，而这条转发本身又跑在隧道上。我手机上有个监控应用，经 WARP 通过 SSH 读取 <code>docker info --format '{{json .}}'</code>，所以不管在哪儿，我都能看到容器是不是都在跑，或者有没有什么东西把内存吃光了。</p>
<p>也正是这个应用让我发现了一个值得一提的小 bug。Mac 上那个隧道用的 sshd，还停留在我调试时设的 <code>LogLevel DEBUG2</code>。带 <code>-e</code> 时 sshd 把日志写到 stderr，而对一个会话来说，这个 stderr <em>就是</em> SSH 通道的 stderr。每条命令的输出后面都多了一行 <code>debug2: do_setup_env: set TMPDIR</code>，而手机应用会把两个流合并，于是拿到的是合法 JSON 后面跟着一行日志，解析失败，把整段显示成错误。改成 <code>LogLevel INFO</code> 就好了。调试完记得把日志级别改回去。</p>
<p>同样是刻意为之的，是我 <em>不</em> 放进这个网络的东西。旧的 autossh 隧道还能连到几台别人网络里的路由器，我帮忙照看着。它们没有公网主机名，也没有 WARP 路由。路由器管理页面挂在公网上，哪怕有登录挡着也是个隐患，而且那不是我的网络，轮不到我去扩大它的暴露面。</p>

<h2 class="relative group">Access 不是可选项
    <div id="access-不是可选项" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#access-%e4%b8%8d%e6%98%af%e5%8f%af%e9%80%89%e9%a1%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Cloudflare Tunnel 的主机名，DNS 记录一建好就在公网上了。它前面没有防火墙；不开端口的全部意义就在这里。保护必须来自 Cloudflare 边缘，而这正是 Access 做的事。在访问者证明自己是谁之前，它根本不转发请求，所以没认证的扫描器永远到不了笔记本。这很重要，因为这些是 dev server：带着 settings 的调试模式报错页，一个敞开的 JavaScript bundler。这些主机名后面的东西，没有一样是为面对公网而做的。</p>
<p>策略只放行我自己的几个邮箱地址，别的一概不放，证明身份的方式有两种：邮件一次性 PIN 码和 SSO 单点登录。会话持续八小时，因为现实中的威胁是一台在咖啡馆里开着、带着一整天有效 cookie 的笔记本，而不是有人暴力破解 PIN 码。</p>
<p>SSH 被认证了两次，由两个互不知情的系统分别负责。Access 决定一条 TCP 流能不能到达 sshd；sshd 照样要我的密钥。丢了其中任何一个，机器都不会因此敞开。Mac 上的隧道 sshd 还信任 Cloudflare 的 SSH CA，所以 Access 可以用从我的 SSO 会话签发的短期证书让我登录，同时 <code>authorized_keys</code> 也照样能用，这样一份坏掉的 Access 配置也不会把我锁在自己的笔记本外面。</p>
<p>我最满意的是一个很小的部分。暴露发生在三件事同时成立的时候：一条 DNS 记录、一个在跑的 connector、一个已激活的 zone。它们在不同时间变为真，顺序也不由我控制；zone 会在 nameserver 改完之后过一阵子自己变成 active，那时 Mac 很可能正睡着。所以 launch agent 不直接跑 <code>cloudflared</code>。它先跑一个预检：从隧道自己的配置里读出主机名，问 Access API 哪些已经被覆盖，只要有一个没被覆盖就拒绝启动：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">HOSTNAMES</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^\s*-\s*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*hostname:\s*//&#39;</span> <span class="p">|</span> sort -u<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROTECTED</span><span class="o">=</span><span class="k">$(</span>curl -fsS -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CLOUDFLARE_API_TOKEN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;https://api.cloudflare.com/client/v4/accounts/</span><span class="nv">$CLOUDFLARE_ACCOUNT_ID</span><span class="s2">/access/apps?per_page=100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> python3 -c <span class="s1">&#39;import sys,json; print(&#34;\n&#34;.join(a.get(&#34;domain&#34;,&#34;&#34;) for a in json.load(sys.stdin)[&#34;result&#34;]))&#39;</span><span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;could not reach the Access API. refusing (fail closed).&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="nv">$HOSTNAMES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    grep -qxF <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$PROTECTED</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;NOT starting: </span><span class="nv">$h</span><span class="s2"> has no Access app&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> cloudflared tunnel --config <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> run</span></span></code></pre></div></div>
<p>launch agent 设了 <code>KeepAlive</code>，所以被拒之后它每 30 秒自己重试一次，Access 应用一建好，隧道就自己起来了。失败时关闭，代价是一次宕机。失败时敞开，代价是一次暴露。我很清楚这两件事里我更愿意解释哪一件。</p>

<h2 class="relative group">踩过的四个坑
    <div id="踩过的四个坑" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%b8%a9%e8%bf%87%e7%9a%84%e5%9b%9b%e4%b8%aa%e5%9d%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上面这些现在都能用了。一路走来，四个坑里有三个是同一个模式：坏掉的那个东西在任何地方都不报错，而我能看到的部分全都显示健康。</p>
<p><strong>路由和 include 列表是两份不同的列表。</strong> 隧道路由让 Cloudflare <em>愿意</em> 把某个地址的流量送到正确的 connector。WARP 的 include 列表才决定客户端一开始会不会 <em>发送</em> 它。devbox 从手机上连不通，就是因为它的路由已经存在，但 include 列表里还只有 Mac 的地址。手机在本地就把包丢了，connector 从没见到过任何流量，所有仪表盘都是绿的。每加一台新机器，都要把它的地址同时加进这两处。</p>
<p><strong>纯 IPv6 移动网络上的 IPv4 字面地址。</strong> 上面讲过了：wifi 上能用，5G 上失败，没有报错。如果一条 WARP 路由在 wifi 上能用、移动数据上不行，而 Agent 显示已连接，那是路由的问题，不是隧道的问题。</p>
<p><strong>cloudflared 后面的 macOS sshd。</strong> macOS 用 launchd 的 socket activation 启动 sshd，每个连接一个短命的 <code>sshd -i</code>。放在 cloudflared 后面时，banner 能到达客户端，但在客户端的标识字符串传回来之前，数据流就被拆掉了，所以每次登录都死在 <code>kex_exchange_identification</code>。我从两端都确认过，还用一个普通的 TCP 中继复现了出来，排除了 Cloudflare 的嫌疑。解法是另起一个独立的 <code>sshd -D</code>，监听 <code>127.0.0.1:2222</code> 和 WARP 的 loopback 地址，以我的用户身份运行，唯一的用途就是充当隧道的源站。系统设置里的远程登录原封不动。devbox 是 Linux，跑的是正常的常驻 sshd，从来没有这个问题。</p>
<p><strong>不复存在的配置字段。</strong> 旧的示例在隧道配置里写着 <code>warp-routing: enabled: true</code>。cloudflared 2026.8 会直接拒绝这个字段。只要这个块存在、且有路由指向这条隧道，WARP 路由就是开着的。至少这个报了错，跟前面三个比起来，简直称得上厚道。</p>

<h2 class="relative group">它改变了什么
    <div id="它改变了什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%83%e6%94%b9%e5%8f%98%e4%ba%86%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>老实说，总结起来就是：我不再根据硬件在哪儿来决定在哪儿干活。笔记本是一副好键盘、一块好屏幕，续航很长，它不需要比这更多。工作站不管我在哪儿都替我扛重活。手机是在真实网络上的真实测试设备，而不是拴在办公室 wifi 上的东西。而我用来观察系统在干什么的那些部分，对我来说一点就开，对别人来说根本不存在。</p>
<p>这个规模下，它在 Cloudflare 免费的 Zero Trust 套餐以内。花掉的是时间，外加一份我很庆幸写了的 README，因为上面那些故障，每一个第二次遇到时都会是一个全新的谜。</p>
<p>如果你在考虑搭类似的东西，下面是我会拿来问你的配置的几个问题：</p>
<ol>
<li>有哪些东西跑在你的笔记本上，只是因为笔记本是你唯一随时能连上的机器？</li>
<li>你的手机能用移动数据访问你的 dev server 吗，而不只是在办公室 wifi 上？用 5G 测，别用 wifi。</li>
<li>你的仪表盘里，有哪些是公开的，只因为那是从外面看它们最省事的办法？每一个都可以改成私有路由。</li>
<li>如果明天某个隧道主机名起来了却没有对应的 Access 应用，什么会拦住它？如果答案是&quot;我会记得&quot;，那就把预检写出来。</li>
<li>你的私有地址所在的网段，是你去过的那些网络永远不会用的吗？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
