↓ メインコンテンツへスキップ

Cloudflare WARP Zero Trust

ひとりのためのプライベートネットワーク: Cloudflare WARP と Tunnel、そして開発に使うマシンたち

開発は MacBook Air でしていますが、重い仕事(ローカルモデル、ファインチューニング、k3s クラスタ、エージェントの実行)は、公開された名前を持たないオフィスのワークステーションに置いてあり、テストに使うスマホはたいてい 5G につながっています。Cloudflare WARP と二本の Cloudflare Tunnel で、この三台をひとつのプライベートネットワークにしました。ほかの何とも重ならない範囲の固定アドレス、Access のログインの背後に置いた dev サーバー用の公開 HTTPS、そしてオープンなインターネットには一切出ないダッシュボード。Caddy による `.test` 名でのローカル HTTPS と、何が動いているかを示す SwiftBar のメニューから始めて、アドレスがなぜ 198.18.0.x と fd00:198:18::x なのか、そして途中でつまずいた四つのことまで、組み立て方を順に書きます。

私の作業環境は、同じ場所にそろうことがほとんどない三台のマシンでできています。一台目は MacBook Air で、私の行くところにはどこへでもついてきます。二台目はオフィスにある devbox という名前のワークステーションで、ちゃんとした GPU を積んでいて、Ollama、ファインチューニング用の Unsloth Studio、専用のコンテナレジストリを持つ小さな k3s クラスタ、そして長いジョブをこつこつ処理させたまま置いてきたエージェントが動いています。三台目はスマホです。私が作ったアプリが実際に使われるのはここで、一日の大半を wifi ではなくモバイルデータで過ごしています。

長いあいだ、この三台はまともに会話できていませんでした。ラップトップから devbox に届くのはオフィスの LAN にいるときだけ。スマホからラップトップの dev サーバーに届くのは両方が同じ wifi にいるときだけで、しかも 0.0.0.0 にバインドするのを忘れず、その朝 DHCP が Mac に割り当てたアドレスを打ち込んだ場合に限られました。ワークステーション上の面白いものはすべて、Ollama の API も、Traefik のダッシュボードも、トレースも、レジストリも、localhost かクラスタのネットワークにバインドしてありました。わざとです。どれもインターネットに置くべきものではないからです。

この記事は、Cloudflare WARP と二本の Cloudflare Tunnel でこれらをつなげた方法と、それで AI 機能やモバイルアプリの作り方がどう変わったか、そして何も言わずに壊れたいくつかのことについて書いたものです。

本当に欲しかったもの
#

要件として書き出すと短いです。

  1. ラップトップはシンクライアントにする。GPU や大量の RAM、何時間もの実行時間が必要なものはすべて devbox で動かし、ラップトップからはオフィスでも自宅でもカフェでも同じ方法で届くようにする。
  2. スマホからは、どこにいても、モバイルデータでも、dev サーバーとシェルに届くようにする。いちいちオンオフを覚えておかなければならない 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 サーバーそのもの
*.ssh.testサーバーautossh のフォワード
*.fly.testFly.ioWireGuard 越しの fly proxy

つまり https://devbox-ollama.ssh.test/api/tags はワークステーション上のモデル一覧を返し、https://api.local.test は自分のマシン上で動いている、開発中のアプリの API です。

.ssh や .fly のような新しいトップレベル名ではなく .test のサブドメインにしていて、.local も使いません。RFC 6761 はまさにこの用途のために .test を予約していて、.local は mDNS のものです。.local を乗っ取ると Mac で Bonjour の検出が動かなくなります。dnsmasq はもともと *.test 全体をワイルドカードで受けているので、ラベルがひとつ増えても何の負担もありません。127.0.0.1 を指す /etc/resolver/test ファイルがひとつあれば、その後 DNS の変更は一切不要です。

存在しない名前で HTTPS
#

これらの名前はすべて、ブラウザが信頼する証明書付きの HTTPS で配信しています。私のラップトップの中にしか存在しないホスト名でそれは無理そうに聞こえますが、Caddy は自前の内部認証局でこれをやってのけます。サイトに tls internal と書くと、Caddy は Let’s Encrypt に頼む代わりにローカルの CA からそのサイトの証明書を発行します。インストーラーが一度だけ実行する caddy trust が、その CA のルートを macOS のシステムキーチェーン(Chrome と Safari はここを読みます)と Firefox 独自のストアに入れます。それ以降は https://anything.test は証明書の警告なしに開き、素の http:// はそちらへリダイレクトされ、マシンの外には何も出ていきません。

見た目だけの話ではありません。今の Web プラットフォームは、素の HTTP では動かないものがたくさんあります。secure cookie、service worker、クリップボード API、SameSite=None が絡むものすべて。Expo の Web ビルドを http://localhost:8081 でテストすると、本番の HTTPS ドメインで出るバグが隠れてしまいます。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
		}
	}
}

面倒な部分は二つのスニペットが受け持っています。cors は * を返す代わりに呼び出し元の Origin をそのまま返します。認証情報が絡むと * は許されないからです。そして OPTIONS のプリフライトには自分で 204 を返すので、上流が相手をする必要はありません。proxyhdr は Host ヘッダーを接続先のアドレスに書き換えます。ルーターの管理画面や Chrome のリモートデバッグ用エンドポイントのような気難しいバックエンドは Host を検証するので、書き換えなければ devbox-ollama.ssh.test を聞いたこともない名前として弾いてしまいます。Ollama 自身のオリジンチェックはどのポートでもループバックなら通すので、Host を 127.0.0.1:11435 に書き換えれば通ります。

keepalive off は、私なら間違えていたはずの設定です。上流はどれも SSH のフォワードで、ネットワークが変わるたびに autossh が壊して作り直します。keep-alive がオンだと、Caddy はとっくに死んだトンネルへのプール済みコネクションを握り続け、リロードするまで訳の分からない 404 を返します。オフにすればリクエストごとに新しく接続し、ループバックならそのコストは測れないほど小さいです。

各サービスは昔のフラットな名前(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 のスナップショットを一回ずつ取り、すべての問い合わせにその文字列から答え、リモートへの確認は並行で行い、実行は五分ごとです。一回の実行で CPU 時間は約 0.4 秒、コア一つの 0.1% ほどです。メニューを開いたときに更新しないのはわざとです。Fly のフォワードは WireGuard 越しに応答が返るまでそれぞれ 1.5 秒ほどかかり、クリックするたびにそこで固まってしまうからです。件数の下に出る checked 14:05 の行が表示の古さを示し、Refresh の項目で強制的に更新できます。

穴がひとつあって、それがいちばん肝心なところでした。devbox には公開された名前がありません。tunnel-devbox は 192.168.x のアドレスに接続していたので、ラップトップがオフィスの LAN にいるあいだしか動きませんでした。オフィスを離れるとメニューバーは赤くなって赤いままで、ラップトップを楽にするためにワークステーションへ移したものすべてに、まさにその隣に座っていないときに限って届かなかったのです。

正しい向きの二本のトンネル
#

解決策は三つの部分からなり、最初の二つが Cloudflare Tunnel です。

Cloudflare Tunnel とは、cloudflared が Cloudflare のエッジへのアウトバウンド接続を保ち続けるものです。Cloudflare はその接続を通してリクエストを送り返してくるので、何重の NAT の内側にあるマシンでも、ルーターで何かを待ち受けさせることなく到達できるようになります。ホテルの wifi に移ったラップトップにもついていき、CGNAT も乗り越えます。

私は二本、マシンごとに一本ずつ動かしていて、その理由は一言書いておく価値があります。手っ取り早いのは、ラップトップに一本だけトンネルを置いて、オフィス LAN 上の devbox の SSH ポートを指す ingress ルールをひとつ足すことでした。これなら 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

ssh:// ではなく tcp:// にするのは、間違えやすいところです。ssh:// を選ぶと Cloudflare のブラウザ上で描画するターミナルになり、ストリームは Web ページ用に包まれます。そこに普通の ssh クライアントを通すと、バナーまでは届いて、鍵交換で死にます。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 ...

オフィスの LAN にいれば確認が成功し、SSH は直接、1 ホップで接続します。それ以外の場所では確認が失敗し、SSH は Cloudflare を経由します。認証には Access のサービストークンを使うので、ブラウザでのログインが割り込むこともありません。Match host ではなく Match originalhost です。host は HostName が置き換えられた後にマッチするので、その時点で名前はもう IP になっていて、ブロックが発動しません。結果として、devbox-ollama.ssh.test はオフィスでも自宅でも電車の中でも動き、メニューバーの丸は緑のままです。

ラップトップのトンネルは逆向きです。Mac 上の dev サーバーに本物の公開 HTTPS ホスト名を与えます。開発中のアプリそれぞれについて、Django API、その WebSocket アプリ、Expo の Web ビルドです。どれも Cloudflare Access の背後にいます。これについては後で戻ってきます。ここが、この仕組みを無謀ではなく安全なものにしている部分だからです。

メニューバーも両方のトンネルを把握するようになりましたが、確認の問い合わせではそれができませんでした。インバウンドのトンネルには確認すべきローカルのポートがなく、二つのコネクタの片方はそもそもラップトップで動いていません。すぐ思いつくテスト、ssh-devbox.example.com に curl することは、コネクタがつながっていてもいなくても 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 を動かしているデバイスはそのアドレスに素のソケットを開けば正しいマシンにたどり着けます。「このサービスに名前で届く」ではなく、「これらのマシンが私と同じネットワークにいる」になるわけです。

アドレスマシン
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

これらのアドレスは各マシンのループバックのエイリアスで、起動時に作られます。なので DHCP にも、どのルーターの内側にいるかにも左右されません。198.18.0.2 は、オフィスでも、自宅からでも、5G のスマホからでも devbox です。Mac の本当の LAN アドレスは移動するたびに変わりますが、こちらは決して変わりません。

スプリットトンネルは include モード にしていて、中身はこれらのルート(と、WARP 自身が必要とする Cloudflare の範囲)だけです。これがクライアントを常用できるものにしています。デフォルトは exclude モードで、WARP はプライベートな範囲のリスト以外のすべてを運ぶので、スマホが送るすべてのバイトが Cloudflare を回り道します。これにはバッテリーとレイテンシに実際のコストがあって、必要なときだけオンにするようになっていたはずで、それでは意味がありません。include モードなら、三台のマシン宛ての通信は WARP を通り、それ以外はいつもの経路で手つかずのまま出ていきます。だからクライアントはつけっぱなしです。ラップトップの warp-cli status は一日中 Connected と言っていて、私はそのことを考えもしません。

トランスポートは、UDP 2408 の WireGuard ではなく、UDP 443 上の HTTP/3 で HTTP/2 へのフォールバックを持つ MASQUE にしています。キャリアやゲストネットワークの中には 2408 を塞ぐところがありますが、443 を塞ぐところはまずありません。私の組織のポリシーでは MASQUE の耐量子鍵合意もオンになっています。探しに行ったわけではありませんが、断る理由もありません。

なぜ 198.18 なのか、なぜ IPv6 アドレスがあるのか
#

このアドレスは妙に見えますし、最初に選んだアドレスは、学ぶところのある形で間違っていました。

最初に試したのは 10.99.99.1 でした。誰でもまずそれに手を伸ばすからです。これはスプリットトンネルのどちらのモードでも失敗します。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 がまったくなく、v4 のリテラルにはキャリアの 464XLAT トランスレーター経由で届いていました。include リストに v4 のプレフィックスしかないと、このトランスレーターはトンネルの 外側 にいます。通信は公開インターネットに出ていき、そこでは 198.18.0.0/15 はルーティングされないので、音もなく捨てられていました。スマホにはエラーもなく、コネクタのログにも何もありません。

解決策は、スマホが IPv6 でネイティブに届く二つ目のアドレスをすべてのマシンに持たせ、それをルートと include リストの両方に足すことです。今では、モバイルデータのときにスマホの SSH クライアントが実際に使っているのは fd00:198:18:: のアドレスのほうです。

オフロード: 端末としてのラップトップ
#

この仕組み全体は、この部分のためにあります。

モデルとファインチューニング。 Ollama は devbox 上で動き、127.0.0.1:11434 にバインドしています。オフィス LAN のほかのマシンからも見えませんし、インターネットからなど言うまでもありません。ラップトップ上では https://devbox-ollama.ssh.test として、あるいはポートそのものを欲しがるツール向けには素のポートとして現れます。

OLLAMA_HOST=127.0.0.1:11435 ollama list

11434 ではなく 11435 なのはわざとです。11434 は Ollama 自身のデフォルトで、もしラップトップでもローカルの Ollama を動かすことがあれば、二つがポートを奪い合い、どちらが先に起動したかによって「ローカル」のはずのものから devbox の答えが黙って返ってくることになります。

実際の効果として、AI 機能を作っているとき、呼び出すモデルはどこにいてもラップトップではなくワークステーションの GPU で動きます。評価の実行、大量のドキュメントの埋め込み、Unsloth Studio でのファインチューニング。これらは devbox で動き、ラップトップのファンは回らないままです。エージェントを試作しているときは、ワークステーション上のローカルモデルを指して、API の請求を気にせずツール呼び出しを何度でも繰り返し、ループが固まってからホスト型のモデルに切り替えられます。

長時間動くエージェント。 一時間かかるエージェントのセッションが、ラップトップの蓋が開いているかどうかに左右されるべきではありません。代わりに devbox で動かせば、ラップトップはそれを覗く窓にすぎません。蓋を閉じて、あとでどこからでも、あるいはスマホからでも ssh devbox すれば、まだそこにいます。トンネル以前はこれがオフィスでしか使えませんでした。まさに必要のない場所です。

ビルドとクラスタ。 devbox の k3s は専用のレジストリを持っているので、イメージはラップトップからホテルの wifi でアップロードするのではなく、ワークステーション上で、それが動く場所のすぐ隣でビルドして push できます。Headlamp のダッシュボード、Traefik のダッシュボード、レジストリの UI は、どれもラップトップ上では *.ssh.test のホスト名です。

モバイルの開発ループ。 スマホはラップトップと同じプライベートネットワークにいて、ラップトップの dev サーバーには Access の背後の公開ホスト名もあります。なので Expo のネイティブビルドを建物の外へ持ち出して、5G で、自分の机で動いている Django API と話させることができます。あるいは devbox 上のほうと話させることも、そちらに置いてきたのなら。実機で本物のモバイルネットワーク越しに回すフィードバックループは、以前は共有のステージングサーバーへのデプロイを意味していました。今はファイルを保存することを意味します。

ここにはひとつ、アプリのバグとしてデバッグし始める前に知っておく価値のある罠があります。Access は WebSocket のホスト名も守っていて、ネイティブの WebSocket クライアントはブラウザではありません。CF_Authorization の cookie を持っていないので、Expo ビルドから Channels アプリへの接続はエッジで 403 で拒否され、その 403 は Channels のエラーとは似ても似つきません。ブラウザのタブは、ログインしたときの cookie をすでに持っているので問題ありません。ブラウザ以外のもの(ネイティブアプリ、スクリプト、dev API を呼ぶエージェント)への答えは Access の サービストークン で、二つのヘッダーとして送ります。

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

トークンはそれ自体では何の権限も与えません。Access がそれを受け入れるのは、ポリシーでそのトークンを指名しているアプリだけなので、届く範囲はちょうど dev ホスト名の一覧で、それより広くはありません。私はそのポリシーを allow ではなく non_identity の判定に分類しています。こうすると監査ログの中で、ログインした人ではなくベアラーシークレットとして見分けがつきます。

インターネットが決して見ない統計
#

欲しかったもののもう半分は、可視性でした。システムの振る舞いを教えてくれるものの大半は、正しく、公開されていません。devbox では次のものです。

  • Traefik のダッシュボードと、Traefik のトレースが入った Jaeger
  • k3s クラスタ用の Headlamp
  • コンテナレジストリの UI(読み取り専用)
  • Ollama のモデル一覧と、今ロードされているもの
  • docker info とコンテナの統計

どれにも公開ホスト名はなく、これからも付けることはありません。ループバックか k3s のサービス IP にバインドされていて、届くのは SSH のフォワード経由で、そのフォワード自体がトンネルに乗っています。スマホの監視アプリは、WARP をまたいだ SSH で docker info --format '{{json .}}' を読むので、コンテナが動いているか、何かがメモリを食い尽くしていないかを、どこからでも確認できます。

このアプリのおかげで、書いておく価値のある小さなバグを見つけました。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 サーバーだからです。設定が載った Django の DEBUG トレースバック、開きっぱなしの Metro バンドラー。これらのホスト名の背後にあるものは、どれもインターネットに面するように作られていません。

ポリシーは私自身のメールアドレスだけを許可し、ほかは何も許可しません。証明の手段は、メールでのワンタイム PIN と GitHub の SSO の二つです。セッションは八時間です。現実的な脅威は、誰かが PIN を総当たりすることではなく、一日有効な cookie を持ったままカフェで開きっぱなしにしたラップトップだからです。

SSH は、お互いを知らない二つの仕組みによって二重に認証されます。Access は TCP のストリームが sshd に届いてよいかどうかを決め、sshd はそれでも私の鍵を要求します。どちらかを失っても、マシンは開きません。Mac のトンネル用 sshd は Cloudflare の SSH CA も信頼しているので、Access は SSO のセッションから発行した短命の証明書で私をログインさせられます。一方で authorized_keys もそれと並んで使えるままなので、Access の設定が壊れても自分のラップトップから締め出されることはありません。

いちばん気に入っているのは小さな部品です。公開は三つのことがそろったときに起きます。DNS レコード、動いているコネクタ、アクティブなゾーン。この三つは別々のタイミングで、私が制御できない順番で成り立ちます。ゾーンはネームサーバーを変えてからしばらくして勝手にアクティブになります。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 があるアドレスを正しいコネクタまで運ぶ 気になる ようにするものです。WARP の include リストは、そもそもクライアントがそれを 送る ようにするものです。devbox にスマホから届かなかったのは、ルートは存在していたのに、include リストにはまだ Mac のアドレスしかなかったからです。スマホはパケットをローカルで捨て、コネクタは通信をひとつも見ず、ダッシュボードはすべて緑でした。新しいマシンを足すたびに、そのアドレスを両方に足す必要があります。

IPv6 オンリーのモバイルネットワークでの IPv4 リテラル。 上で書いたとおりです。wifi では動き、5G では失敗し、エラーはなし。WARP のルートが wifi では動いてモバイルデータでは動かず、Agent が connected と表示しているなら、原因はトンネルではなくルーティングです。

cloudflared の背後の macOS の sshd。 macOS は sshd を launchd のソケットアクティベーションで起動し、接続ごとに短命の sshd -i をひとつ立てます。cloudflared の背後だと、バナーはクライアントに届くのに、クライアントの識別文字列が戻る前にストリームが壊され、すべてのログインが kex_exchange_identification で死にます。両端から確認し、素の TCP リレーでも再現したので、Cloudflare は原因から外れました。解決策は、二つ目の独立した sshd -D を 127.0.0.1:2222 と WARP のループバックアドレスで、自分のユーザーとして動かすことです。トンネルのオリジンになるためだけに存在する sshd です。システム設定のリモートログインには手を付けていません。devbox は Linux で、普通の長生きする sshd が動いているので、この問題は一度も起きませんでした。

存在しなくなる設定フィールド。 古い例には、トンネルの設定に warp-routing: enabled: true と書いてあります。cloudflared 2026.8 はこのフィールドをはっきり拒否します。WARP ルーティングは、ブロックが存在し、ルートがトンネルを指していれば常にオンです。少なくともこれはエラーを出してくれたので、ほかの三つのあとでは、ほとんど親切に感じました。

何が変わったか
#

正直にまとめると、ハードウェアがどこにあるかで作業場所を選ぶのをやめました。ラップトップは良いキーボードと良い画面と長いバッテリーで、それ以上である必要はありません。ワークステーションは、私がどこにいても力仕事を引き受けます。スマホは、オフィスの wifi につながれたものではなく、本物のネットワーク上の本物のテスト端末です。そして、システムが何をしているかを見るための部分は、私にとってはクリックひとつの距離にあり、ほかの誰にとっても存在しません。

この規模なら Cloudflare の無料の Zero Trust プランに収まります。かかったのは時間と、書いておいてよかったと思っている README です。上に書いた失敗はどれも、二回目にはまた新鮮な謎になっていたはずだからです。

似たようなことを考えているなら、自分の環境にこう問いかけてみてください。

  1. ラップトップで動いているもののうち、ラップトップがいつでも届く唯一のマシンだという理由だけでそこにあるものは何か?
  2. スマホから dev サーバーに、オフィスの wifi だけでなくモバイルデータでも届くか? wifi ではなく 5G で試してください。
  3. ダッシュボードのうち、外から見るのにそれが楽だったという理由で公開しているものはどれか? どれもプライベートルートに置き換える候補です。
  4. 明日、あるトンネルのホスト名が Access のアプリなしで上がってきたら、何がそれを止めるか? 答えが「自分が覚えているはず」なら、プリフライトを書いてください。
  5. プライベートアドレスは、訪れる先のネットワークが決して使わない範囲にあるか?