mcpField
T
tunnel-doctor

tunnel-doctor

github.com/daymadeクラウド・DevOps1.5k683 回インストール今日更新
リポジトリ

tunnel-doctor について

tunnel-doctor は、macOS で Tailscale とプロキシまたは VPN ツールが競合する問題を診断・修復し、WSL への SSH 接続も扱います。症状別の証拠に基づく手順と、リモートアクセスや影響の大きい変更に関する制約を示します。

主な機能

  1. 01ルート、DNS、プロキシの競合診断
  2. 02SSH と Git の接続調査
  3. 03VM とコンテナのプロキシ障害対応
  4. 04TUN 転送とプロキシ容量の調査
  5. 05リモートアクセス範囲の管理
  6. 06影響の大きい変更前の復旧計画

活用シーン

  1. 01Tailscale の ping 成功後に起きる接続障害の調査
  2. 02SSH 経由の Git 転送が断続的に失敗する問題の調査
  3. 03Docker や WSL のプロキシ障害の調査
  4. 04許可された VPN またはルート修復の計画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/daymade/claude-code-skills --skill tunnel-doctor

インストール後、Claude Code で/tunnel-doctorを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
dns_resolver_chain_stall.md
network_change_recovery.md
node-liveness-verification.md
provider_account_suspension.md
proxy_conflict_reference.md
proxy_node_chain_throughput.md
single-host-dns-migration.md
ssh_tunnel_source_restriction.md
tailscale_address_family_ssh.md
windows_host_tun_wsl_cascade.md
scripts/
quick_diagnose.py
tests/
test_quick_diagnose.py

詳細

トンネル診断

macOS で Tailscale とプロキシ/VPN ツールを併用する際の競合を診断して解決します。WSL インスタンスへの SSH アクセスに関する具体的なガイダンスも提供します。

**方法論の基盤:**このスキルが基礎とする一般的な診断手法――推測より証拠を重視し、確認より反証を重視すること、段階的に切り分けること、相互レビューを行うこと――は、debugging-network-issues スキルにあります。このスキルは、その上に重なる macOS の Tailscale とプロキシに関する ドメイン レイヤーです。症状が既知の Tailscale/プロキシ競合でない場合は、基盤となるスキルを利用してください。

**担当範囲:**このスキルは、ローカルのネットワーク経路に関する具体的な操作を担当します。対象は Tailscale、ルート/DNS/プロキシのインターセプト、VM/WSL への伝播、TUN 転送プレーン、プロキシノード/出口ノード/チェーンの容量です。担当範囲がまだ不明な場合や、症状が SSE/CDN/アプリケーションプロトコルの動作に関する場合は、一般的な手法として debugging-network-issues を使用してください。これにより、ユーザーに重複するプロキシ診断ツールから選ばせるのではなく、具体的な操作を一つの担当者に集約できます。

対象とタスクの範囲

リモートでの調査や変更を行う前に、対象が指定されたテストマシン、明示的に依頼された同僚の作業用コンピューター、または不明なマシンのいずれかを確認してください。所有者が使用している既存のマシン用途台帳と現在のタスクを参照してください。同僚のマシンにログインして分類してはいけません。SSH アクセスが保存されていることや、以前にテストが成功したことは、継続的な使用を許可するものではありません。通常のテストには指定されたテストマシンを使用してください。それらが利用できなくても、他人のコンピューターを使う許可にはなりません。

同僚のコンピューターで明示的に依頼された単発のテストは、指定された結果と必要な検証の範囲内で行ってください。完了、キャンセル、または未承認の環境変更が必要になった時点で停止してください。その後のパフォーマンス、移行、回帰テストは指定されたテストマシンで行ってください。テストが失敗しても、その人のプロキシ/VPN を無効にする、アプリを終了する、ルートを変更する、バックグラウンドタスクをインストールする許可にはなりません。ユーザーがその修復と中断を明示的に依頼した場合に限り、そこで破壊的な修復を進めてください。所有者のテストマシンの保守について既存の許可がある場合は引き続き有効です。通常の手順について再度確認する必要はありません。

この境界は、直接呼び出された場合、別の Skill を通じて呼び出された場合、コンテキスト圧縮後、またはピアへの引き継ぎ後にも適用してください。アクセス停止後は、停止したことを確認するために別の SSH 調査を行わず、既存の記録を使用してください。

影響を伴うネットワーク変更

許可された VPN の切断、アプリの終了、ルートの切り替え、サービスの再起動を行う前に、network_change_recovery.md を読んでください。中断前に前提条件を検証し、最初の影響を伴う操作の前に、時間を限定した復旧手段を有効にしてください。修復後の経路と、ユーザーが元々使用していたネットワークの両方を検証してください。停止コマンドが成功したこと、プロセスがデタッチされたこと、スクリプトの末尾に再起動を配置したことは、復旧の証拠にはなりません。

競合レイヤー

macOS のプロキシ/VPN ツールは、複数の独立したレイヤーで競合を引き起こします。レイヤー 1~3 は Tailscale の接続に影響し、レイヤー 4 は SSH の git 操作に、レイヤー 5 は VM/コンテナのランタイムに影響します。この表に記載されていない TUN 状態の障害モード(SSH/git の接続切断、リゾルバーの停止、DIRECT のスプリットブレイン)は、手順 2H~2J で扱います。

レイヤー動作しなくなるもの引き続き動作するもの根本原因
1. ルーティングテーブルすべて(SSH、curl、ブラウザー)tailscale pingtun-excluded-routes により、Tailscale utun を上書きする en0 ルートが追加される
2. HTTP 環境変数curl、Python の requests、Node.js の fetchSSH、ブラウザーTailscale 用の NO_PROXY を設定せずに http_proxy が設定されている
3. システムプロキシ(ブラウザー)ブラウザーのみ(HTTP 503)SSH、curl(プロキシの有無を問わず)ブラウザーは VPN のシステムプロキシを使用し、DIRECT ルールが Tailscale utun ではなく Wi-Fi 経由でルーティングする
4. SSH ProxyCommand の二重トンネルgit push/pull(断続的)ssh -T(少量のデータ)connect -H が Shadowrocket TUN と重複する HTTP CONNECT トンネルを作成し、接続先のプロキシが大容量または長時間の転送を切断する
5. VM/コンテナへのプロキシ設定の伝播docker pull、docker buildホストの curl、実行中のコンテナVM ランタイム(OrbStack/Docker Desktop)がプロキシ設定を自動注入またはキャッシュする。プロキシを削除すると、さらに悪化する(VM のトラフィックが TUN 経由になり、TLS がタイムアウトする)

診断ワークフロー

ステップ 1:症状を特定する

修復方法を選ぶ前に、実際に使用しているコマンドまたはサービスと、それに必要なプロトコルを明確にします。認可済みで直接到達可能なピアが遅い接続先に到達でき、そのピア経由の認証済み SSH と代表的なデータ転送がすでにより良好に動作している場合は、ゲートウェイを統合候補として扱います。まず、利用側で有効な SSH/インベントリーの引数を調べ、通常のエントリからその経路を利用できるかテストします。手動で記述した ssh -J が成功しても、統合できることの証明にはなりません。

ゲートウェイが必要なワークロードを担える場合は、既存の管理されたトランスポートを介して接続し、リクエストごとのジャンプ引数を使わずに元の操作を再実行します。接続先の識別、ホストキーの信頼性、コンテンツの整合性、所要時間、および宣言された障害時/復旧時の動作を検証します。ユーザーの目的に必要でないネイティブリレーについて、さらに作業を進める前にこれを行います。正確なホスト、ポート、および実際のルート状態は、それらを所有するインフラストラクチャ設定で管理します。

満たされていないプロトコル要件を明示します。SSH ゲートウェイでは、UDP や任意のアプリケーション接続が確立されるわけではありません。それらが必要な場合は、該当するネットワーク修復を続け、SSH の成功で代用しないでください。実行するエージェントがプロトコルへの適合性とユーザーの意図を判断する必要があります。これらの判断を行うバンドル済みチェッカーはありません。

該当するシナリオを特定します。

  • 公開ゲートウェイが502を返す一方でバックエンドは正常に応答する場合、または専用SSHトンネルキーが correct key but not from a permitted host として拒否される場合 → TUN/ファイアウォールのルールを変更したり、実行中のプロセスを復旧と見なしたりする前に、SSH トンネルの接続元制限をお読みください。
  • ブラウザーはHTTP 503を返すが、curl とSSHは両方とも機能する場合 → システムプロキシのバイパス競合(ステップ2C)
  • ブラウザー/デフォルトの curl では local.<domain> にアクセスできないが、プロキシを介さない直接リクエストは機能する場合 → ローカルのバニティドメインに対するプロキシの横取り(ステップ2C-1)
  • TailscaleのpingとSSHは機能するが、curl/HTTPがタイムアウトする場合 → HTTPプロキシ環境変数の競合(ステップ2A)
  • Tailscaleのpingは機能するが、SSH/TCPがタイムアウトする場合 → ルートの所有者を確認します(ステップ2B)。症状が一致するだけでは、ルートの競合が起きているとは限りません。
  • TailscaleのIPv4 SSHがタイムアウトする場合、または別のピアアドレスでホストキー検証が発生する場合 → ホストをオフラインと判断したり、信頼/ネットワークの状態を変更したりする前に、同一ピアのIPv4/IPv6 SSH復旧をお読みください。
  • リモート開発サーバーの認証で localhost にリダイレクトされ、ブラウザーが追従できない場合 → SSHトンネルが必要です(ステップ2D)
  • プロキシの影響で make status /スクリプトのlocalhostへのcurlが失敗する場合 → localhostへのプロキシの横取り(ステップ2E)
  • git push/pull が FATAL: failed to begin relaying via HTTP で失敗する場合 → SSHの二重トンネル(ステップ2F)
  • docker build の RUN apk/apt が Connection refused ですぐに失敗する場合 → OrbStackの透過プロキシとTUNの競合(ステップ2G-1、修正方法:--network host)
  • docker pull が TLS handshake timeout で失敗する場合 → VMのプロキシ設定ミス(ステップ2G-2、修正方法:host.internal を使った docker.json)
  • コンテナーのヘルスチェックは (unhealthy) だが、アプリは正常に動作する場合 → 小文字のプロキシ環境変数の漏出(ステップ2G-4、修正方法:http_proxy と HTTP_PROXY をクリア)
  • docker build でベースイメージを取得できない場合 → VM/コンテナーへのプロキシ設定の伝播(ステップ2G)
  • docker pull は機能するが、WSL2 + Docker Desktopのホストで docker build がベースイメージのトークン取得中に(TLS/i-oタイムアウトで)失敗し続ける場合、またはWindows + v2rayNのホストでv2rayNのアップグレード後にランブックのプロキシポートがすべて気付かれないまま停止した場合 → buildkitがDDプロキシの上書きを迂回して直接接続する/v2rayN ≥7.17でLANポートが分離される(references/windows_host_tun_wsl_cascade.md §2026-08-23)
  • git clone が Connection closed by 198.18.x.x で失敗する場合 → これを引き起こす仕組みは2種類あります。ステップ2Hで切り分けます。
  • 国内サイト/DIRECTルールのサイトが一斉に失敗する場合(TLSハンドシェイク途中で unexpected EOF が発生し、プロキシポートへのCONNECTが503を返し、Node CLIが UNKNOWN_CERTIFICATE_VERIFICATION_ERROR を報告する一方、プロキシ経由の海外サイトは引き続き機能する場合) → TUNのDIRECTルールのスプリットブレイン(ステップ2J)
  • DNS/委任の変更後に1つのホスト名だけが失敗し、ほかのサイトは機能する場合 → フォワーディングプレーンの障害を疑ったり、共有トンネルを再起動したりする前に、単一ホストのDNS移行をお読みください。
  • macOSで国内外を問わずすべてが一斉に失敗し、ステップ2Jの実IPプローブまで失敗する場合 → まだ障害と判断しないでください。「TUNの全面停止と実際の障害の見分け方」(ステップ2Aの前)にある物理インターフェイスの判別方法を実行します。
  • SSHは接続するが operation not permitted が発生する場合 → Tailscale SSHの設定問題(ステップ4)
  • SSHは接続するが be-child ssh が終了コード1を返す場合 → WSLのsnapサンドボックスの問題(ステップ5)
  • TCPポート22に到達できる(nc -z が成功する)が、SSHが kex_exchange_identification: Connection closed で失敗する場合 → WSL上でTailscale SSHプロキシに横取りされています(ステップ5A)
  • 同じ kex_exchange_identification / Connection closed by UNKNOWN port 65535 が発生するが、Tailscaleは使用しておらず、TUNの背後にいてホストがHTTPSは引き続き機能するパブリッククラウドVMの場合 → TUNか、接続先のファイアウォールの許可リストが原因である可能性があります。クライアント側からは両者を区別できません。ステップ2Kで帯域外から確認します。
  • 1つのプロキシプロバイダーのノードがすべて同じように停止し(TCP接続は成立するが、TLSの appconnect=0.000 が続く)、ローカルのプロキシアプリとパケットトンネル拡張機能を再起動しても変化がない場合 → プロバイダー側の事象であり、トンネルの問題ではありません。プロバイダーのコントロールプレーンで確認し、別のプロバイダーのノードに切り替えます(ステップ2L)。
  • 1つのプロキシプロバイダーでテストしたノードがすべてTLSの unrecognized name ですぐに失敗する一方、サブスクリプションの更新は機能する場合 → まずプロバイダーのアカウントダッシュボードで、通信量の上限、請求、利用停止を確認してください。この症状だけでは原因を特定できません。トンネルを再構築したりプロキシプロセスを再起動したりする前に、プロバイダーアカウントの確認をお読みください。
  • tailscale ssh が「App Store版では利用できません」と返す場合 → macOSで誤ったTailscale配布版を使用しています(ステップ5B)
  • システムDNSを使うツール(ssh、curl、git)は名前解決まで約60秒停止するが、nslookup はすぐに結果を返す場合 → getaddrinfo の処理チェーンでリゾルバーが停止しています(ステップ2I)
  • Windows + WSLのホストで国内外を問わずすべてが一斉にオフラインになり、WSLも停止し、「Tailscaleすらオンラインにならない」場合、および/またはユーザーが複数の復旧操作(NICの切り替え、TUNの切り替え)を試して、どの操作で直ったのか分からない場合 → WindowsホストのTUN連鎖障害とイベントログのフォレンジック調査(ステップ5C)
  • Tailscale、DNS、ルートの所有者、プロキシへの到達性はすべて正常だが、大容量転送は引き続き遅く、アクティブなプロキシノードを切り替えるとスループットが変化する場合 → references/proxy_node_chain_throughput.mdのプロキシノード/出口/チェーンの容量に関するワークフロー
  • アプリ1つの大量通信(BaiduNetdiskのダウンロード、Feishu/Larkのアップロード)を従量制プロキシの対象外にしたい場合、またはステータスバーのカウンターにも表示させたくない場合 → 競合ではなく、トラフィックの振り分け作業です。既存のローカルトラフィック振り分けワークフローが導入・検証済みの場合は、それを使用してください。このスキルには、振り分けツール、対象リスト、再実行ヘルパー、サブスクリプション消去防止機能は含まれていません。仕組みを診断するには、references/proxy_conflict_reference.md §「macOSでのアプリ別トラフィック振り分け」をお読みください。3つの仕組み(DIRECTルール/tun-excluded-routes / always-real-ip)のうち、アプリで機能するものは、そのアプリがIPアドレスを直接指定するか、システムリゾルバーを使うかによって異なります。また、DNSレイヤーのキーはmacOSのCatalyst版では機能しません。 主な見分け方:
  • SSH は http_proxy/NO_PROXY 環境変数を使用しません。SSH は機能するのに HTTP が機能しない場合 → レイヤー 2。
  • curl はシステムプロキシではなく、http_proxy 環境変数を使用します。ブラウザーは(VPN によって設定された)システムプロキシを使用します。curl は機能するのにブラウザーが機能しない場合 → レイヤー 3。
  • tailscale ping は機能するのに通常の ping が機能しない場合 → レイヤー 1(ルーティングテーブルが破損しています)。
  • ssh -T [email protected] は機能するのに git push が断続的に失敗する場合 → レイヤー 4(二重トンネル)。
  • ホストの curl https://... は機能するのに docker pull がタイムアウトする場合 → レイヤー 5(VM へのプロキシ設定の伝播)。
  • docker pull は機能するのに docker build の RUN apk add が Connection refused で即座に失敗する場合 → TUN によって OrbStack の透過プロキシが壊れています(Step 2G-1)。
  • コンテナーのヘルスチェックに (unhealthy) と表示されるのにアプリは機能する場合 → 小文字の http_proxy がコンテナーに漏れています(Step 2G-4)。
  • DNS が 198.18.x.x の仮想 IP に解決される場合 → TUN が有効です。ただし、これだけでは原因を特定できません。TUN の使用中は、成功時も失敗時もすべてのホスト名がこのように解決されます。使用している仕組みを特定するには Step 2H に進んでください。
  • フェイク IP への接続が失敗する一方で、新しい IP を指定した curl --resolve が成功する場合、ホスト名に依存する名前解決/転送が疑われますが、原因と確定したわけではありません。複数ドメインで発生する DIRECT 失敗パターンについては Step 2J を使用してください。最近移転したドメインが 1 つだけの場合は、単一ホストの DNS 移行を使用してください。
  • nc -z はポート 22 で成功するのに SSH がバナーを受信できず(kex_exchange_identification)、接続が止まる場合 → Tailscale SSH プロキシによる割り込みです(Step 5A)。リモート側で tcpdump -i any port 22 を実行して確認してください。パケットが 0 個なら、Tailscale がカーネルより上の層で割り込んでいます。
  • 接続先が待ち受けていないポート(65001 を調査)でも nc -z が成功する場合 → TUN の使用中はローカルスタックが接続先の代わりに応答するため、そのホストでの nc -z の結果には何の意味もありません。この方法で事前に確認していないポート調査の結果を、決してそのまま解釈しないでください。TUN(Tailscale に限りません)が経路にある場合は、上の箇条書きにある単純な nc -z の解釈よりこちらを優先してください(Step 2K)。
  • 接続先自身の journalctl -u ssh に試行の記録が一件もない場合 → sshd より前の何かが接続を遮断していますが、必ずしもネットワークが原因とは限りません。ホスト上のパケットフィルターも、同じように何の痕跡も残さず接続を破棄します。ホスト上で tcpdump を実行すれば、「到達していない」のか「到達したがローカルで破棄された」のかを切り分けられます。TUN が原因となるのは前者だけです。到達できないポートを使わないクラウドプロバイダーのゲストエージェント経由でマシンにアクセスし、調査を依頼してください。auth.log ではなくジャーナルを確認してください。Ubuntu 24.04 では sshd は auth.log に何も書き込まないため、ファイルが空でも証拠にはなりません(Step 2K)。
  • クラウドホストでは、受信ポートに対する制限が二重に適用されます。プロバイダーのセキュリティグループと、ホストファイアウォール(ufw/firewalld/nftables)の両方です。一方だけに自分の IP を追加しても、もう一方が引き続き遮断するため、作業が半分しか完了していないことを示す通知はありません(Step 2K)。
  • tailscale ssh が「App Store ビルドでは利用できません」というエラーで失敗する場合 → Standalone Tailscale をインストールしてください(Step 5B)。
  • nslookup <host> は高速(0.1 秒未満)なのに dscacheutil -q host -a name <host> が 60 秒以上かかる場合 → scutil --dns にある補助リゾルバーが停止しています(Step 2I)。
  • ping <resolver-ip> は成功するのに dig @<resolver-ip> がタイムアウトする場合 → デーモンが停止し、utun インターフェイスがゾンビ化しています。ICMP にはインターフェイスが応答していますが、実際のポート 53 サービスは停止しています(Step 2I)。
  • ssh -vvv が debug2: resolving "<host>" port <port> の直後に停止し、debug1: connect to address まで進まない場合 → ネットワーク接続段階ではなく、DNS 解決段階の問題です。Step 2B/2H ではなく Step 2I を参照してください。
診断の原則(仮説に決めつける前にお読みください)

症状から特定のコンポーネント(プロキシ、VPN、ルートテーブル、DNS)が疑われる場合は、**状況証拠だけで仮説に決めつけず、まずそのコンポーネント固有のヘルスチェックエンドポイントで確認してください。**各コンポーネントには、周辺要因を切り分けるよりも速く、確実な1行のヘルスチェックがあります。

疑わしいコンポーネント信頼できるヘルスチェック(最初に実行)
HTTP プロキシ(Shadowrocket / Clash / Surge)curl -x http://127.0.0.1:<port> -m 10 https://api.github.com が 200 を返す
Tailscale デーモンtailscale status がピアの一覧を返す(接続エラーではない)
特定の DNS リゾルバーdig @<nameserver-ip> +tries=1 +timeout=3 example.com が 100ms 未満で応答する
IP アドレスへのルーティングroute -n get <ip> が想定されるインターフェースを示す
リゾルバーごとの二分探索(DNS が疑わしい場合)ステップ 2I の for ns in ...; do dig @$ns ... ループ
あるプロキシプロバイダーのすべてのノードが停止しているが、サブスクリプションの更新は機能する場合ローカルで修復を試みる前に、プロバイダーのアカウントダッシュボードを確認する(アカウント停止/通信量上限/請求)
エージェント CLI が「接続できない/再接続を繰り返す」と訴える場合(Codex など)クライアント自身のランタイムログを、エラーのパターンと時間帯ごとに分類して確認する。Codex の場合は、最新の ~/.codex/logs_*.sqlite にある logs テーブルを確認します。特定の時間帯にエラーが集中している場合は、断続的な通信の問題です(続きをご覧ください)。正常なものの中に失敗している process_uuid が1つだけある場合は、ネットワーク全体ではなく、プロセス固有の原因(古いトークンなど)です

重要な理由:症状がステップ 2X の説明に当てはまっても、それだけでコンポーネント X が問題だとは証明できません。複数のレイヤーで似た症状が発生することがあります(git push 中に60秒間停止する場合、プロキシノードの停止、fakeip のルート破損、DNS リゾルバーの遅延などが考えられます。ユーザーから見える症状だけでは、どれもあり得ます)。最も具体的な確認を先に行えば、誤ったレイヤーに決めつけて行き止まりにたどり着くのを避けられます。

失敗した操作で DNS が少しでも関係している場合は、プロキシやルーティングを疑う前に、ステップ 2I のネームサーバーごとの二分探索を実行してください。macOS を中国のネットワークで利用する場合に起きる障害のうち、最大の単一カテゴリを15秒以内に切り分けられます。

TUN による測定値の汚染(TUN プロキシ稼働中にプローブが示す誤った値)

プロキシツールがTUN/グローバルモード(Shadowrocket、Clash、Surge)で動作すると、ルーティングレイヤーでトラフィックを横取りし、ネットワークスタックの一部をローカルで偽装します。その結果、日常的に使う診断コマンドのいくつかが偽の値、または誤った経路の値を返します。それを信じると、調査全体が誤った方向に進みます。TUN 環境では、各プローブが実際に何を測定するのか把握しておきましょう。

プローブ見かけ上の意味TUN環境での実際の動作信頼できますか?
nc -z <node-ip> <port> / 0.00s と表示される生のTCP接続「ノードに到達でき、即座に応答した」TUNはトンネリングする前に、TCPハンドシェイクをローカルで完了します。海外のホストへの接続が 0.00s というのは物理的に不可能です(光だけでも片道に数十ミリ秒かかります)。接続先はノードではなくTUNです。❌
損失がほぼゼロ/RTTが1ミリ秒未満の ping <host>「リンクは正常」TUNはICMPにローカルで応答できます。損失率とRTTは捏造された値であり、TCPとは無関係です。(それとは別に、TUNがなくてもICMP ≠ TCPです。)❌
curl … -w '%{remote_ip}'「ピアXに接続した」表示されるのは常にローカルTUNエンドポイント(127.0.0.1 /ループバック)であり、実際のリモートピアではありません。❌
国外のサービス(ip-api のようなエンドポイント)を使ったIPジオロケーション検索「自分の出口IP/自宅IPは……」外国ドメインへのリクエストはプロキシ経由でルーティングされるため、報告されるのは出口IPであり、実際のローカルIP/自宅IPではありません。「実際のローカルIPは何か」を調べる用途では ❌
IPv4とIPv6の経路選択、HTTP/3 / QUICの高速化状況によりますTUNは通常UDP/443を転送しないため、QUICは外部に出ません。この比較に意味はありません。❌
DIRECTルール/国内ドメインがプロキシ設定経由で成功したことを、トンネル/出口ノードが稼働中である証拠として挙げる「プロキシには少なくとも1つ動作する経路があるので、トンネルもおそらく正常」DIRECTに一致するトラフィックはトンネルに入りません。ローカルから実際の最終区間を通って外部に出るため、プロキシがまったく動作していない場合と同じです。成功しても、プロキシ経由の通信経路については何もわかりません。(2026-09-22に確認:収集状況のヘルスチェック中に「プロキシ経由で動作した」海外APIエンドポイントは、実際にはDIRECTバイパスルールに一致していました。プロキシをまったく使わずに再テストしたところ同じように成功し、トンネル自体が一度も使われていなかったことが証明されました。)「トンネル/出口ノードは正常か」を調べる用途では ❌
-I フラグなしの networkQuality「実際の下り/上り容量」デフォルトの実行では、デフォルトルートを持つ経路を使います。TUN環境ではそれは回線ではなくトンネルです(2026-10-23に確認:ノードを切り替えた後、同じ実行の結果が64.7 → 472.6 Mbpsに変化しました)。「トンネルが遅いか」の確認には有効ですが、回線の基準値として使うのは誤りです。ノードを通るかどうかは、テスト対象エンドポイントに対するツールのルール次第です。ノードを切り替えたときに数値が変われば、トンネルを通った証拠です。トンネル経由の容量の測定には ✅ /物理回線の基準値としては ❌
curl ifconfig.me / ipifyの出口IPを使って、アクティブなノードを特定する「出口IPが1.2.3.4なので、ノードXを使っている」出口IPは入口ノードではなく出口に属します。レジデンシャルIP製品では、ノードのアドレスとは何の関係もないISPアドレスから外部に出ます(2026-10-23に計測:ComcastのレジデンシャルIP出口は「静的レジデンシャル」製品に属し、経由したデータセンターノードのものではありませんでした。アドレス全体は非公開です)。出口IPからアクティブなノードを特定すると、成功や失敗を誤ったサブスクリプションに帰属させてしまいます。ノードの特定には ❌
TUN 使用中に信頼できるもの:
  • curl の time_appconnect / time_starttransfer(アプリケーション層のハンドシェイク/TTFB)。これらはトンネル経由の接続が実際に確立してから完了するため、実際のエンドツーエンド経路を反映します。
  • networkQuality -s -I <physical-if> の容量値(-I はヘルプに記載されているとおり「テストをインターフェースにバインドする」)を有線接続の基準値として使います。curl の --interface と同じようにトンネルを迂回します。容量の数値は信頼できますが、バインド方式をまたいで遅延/応答性のサブ指標を比較しないでください。正常なホストで、インターフェースにバインドした実行のアイドル時遅延がトンネル経由の実行より高く報告された例があります。
  • 「実際に使っているローカル ISP」を調べるには、リージョン内/国内の IP ジオロケーション情報源を使います。リージョン内のドメインはプロキシの DIRECT ルールに一致し、実際のラストマイル経由で外部に出ます(国外の情報源はトンネル経由になり、誤った結果を返します。表を参照)。同じ DIRECT ルールへの一致は、トンネル自体の健全性を示す証拠にはなりません。上の表の行を参照し、ホストの成功をトンネルによるものと判断する前に proxyBypassHosts/DIRECT ルールのリストを確認してください。
  • ディスクから読み取ったプロキシ/TUN 設定と、ツール独自の GUIは、実際にアクティブなノード/ルートを確認するための信頼できる情報源です。ファイルの解析結果を GUI と照合してください。ネットワークプローブからアクティブなノードを推測しないでください。

対抗策:TUN が有効な状態で遅延/到達可能性の数値を提示する前に、「パケットが実際に宛先まで到達していたとして、この数値は物理的にあり得るだろうか?」と確認してください。別の大陸への接続が 0.00s、または ping が 0.2ms なら、ネットワークではなく TUN を測定していた証拠です。time_appconnect に切り替えるか、TUN を一時的に無効にして、正確な基準値を取得してください(TUN を無効にすれば、通常のプローブが再び意味を持つようになります)。

TUN の完全停止と実際の障害の見分け方(すべての通信が失敗したら、停止を決める前に有線接続が切れていることを証明する)

汚染に関する表には、システム上の限界があります。TUN を経由するすべてのプローブは、同じ影響を受けます。「実 IP」プローブ(dig @<resolver> + curl --resolve)も例外ではなく、TUN は UDP/53 も横取りします。TUN のフォワーダーが完全に停止すると、ドメインのプローブ、実 IP のプローブ、プロキシ経由のプローブがすべて同時に失敗し、その状態は実際の物理的な障害と区別できません。「すべてのプローブが停止」と読んで「ネットワークがダウン」と判断する監視機構や調査担当者は、復旧可能な TUN の停止中に処理を中断することになります。さらに悪いことに、ルーターのせいにする可能性もあります(2026-09-10 に確認:プロキシのヘルスデーモンが、1 日で「実際のネットワーク障害。処理を中断」という時間帯を 13 回記録しました。物理経路プローブを追加するまで、どのレイヤーに問題があったかは一度も確認されていませんでした)。

見分けるには、TUN を完全に迂回するプローブを使います。物理インターフェースにバインドすると(curl --interface = IP_BOUND_IF)、スコープ付きルートによりパケットは物理 NIC から直接送出されます。

# 1) Find the physical interface that actually carries the LAN. Do NOT use
#    `route -n get default` here — under TUN it answers with the utun itself.
for i in en0 en1 en2 en3; do
  addr=$(ipconfig getifaddr $i 2>/dev/null) && echo "$i $addr"
done
ping -c 1 -t 2 <gateway-ip>   # sanity: is the LAN itself alive at all

# 2) Probe bare-IP targets through that interface — two independent ones.
#    Both verified answering on a healthy link 2026-09-10 (AliDNS 443 → 404,
#    DNSPod 80 → 404). Targets rot: 114.114.114.114 and 180.76.76.76 returned
#    000 on a healthy wire that same day — re-verify a target before trusting it.
for u in "https://223.5.5.5/" "http://119.29.29.29/"; do
  curl -s --noproxy '*' --interface en0 -k --connect-timeout 3 --max-time 6 \
    -o /dev/null -w "$u: %{http_code}\n" "$u"
done
  • どちらかが 000 以外の応答を返す(404 でも可)→ 物理経路(NIC → AP → ゲートウェイ → ISP)は生きており、TUN が疑わしい状態です。復旧契約に従ってトンネルを再接続してください(切断/接続の仕組みは Step 2J にあります)。処理を中断したり、ルーターを再起動したりしないでください。
  • 両方のターゲットが 000 を返す → 実際の障害と判断できます。ただし、1 つのターゲットだけが 000 を返しても、証明できるのはそのターゲットに限られます。企業/キャンパスの出口側の許可リストや、リゾルバー側の変更があれば、正常な有線接続でも 1 つだけ 000 が返ることがあります。

信頼する前に調整してください。正常な接続では、各応答は約 0.1 秒で返ります。意図的に誤ったインターフェースを指定した場合は失敗する必要があります。--interface en9 は、インターフェースが存在しない場合はゼロ以外の終了コード(45)、存在するが停止中または経路がない場合は 7/28 とともに 000 を返します。判定の手掛かりは終了コードではなく 000 です。

クイック手順:自動チェックを実行する

よくある macOS の競合(環境変数のプロキシ、システムプロキシの例外、直接接続とプロキシ経由の経路分岐、ローカル TLS の信頼設定)を確認するには、次を実行します。

python3 scripts/quick_diagnose.py --host local.example.com --url https://local.example.com/health

Tailscale の宛先について、必要に応じて経路の所有者を確認します。

python3 scripts/quick_diagnose.py --host <target-host> --url http://<target-host>:<port>/health --tailscale-ip <100.x.x.x>

解釈:

  • direct=PASS + ambient=FAIL + forced_proxy=FAIL の場合は、シェルのプロキシ経路を調査する根拠になります。direct=PASS + system_proxy=FAIL の場合は、影響を受けるブラウザーまたはシステムクライアントでも失敗するまでは警告にとどめてください。その場合に限り、ホスト固有のバイパスを検討します。
  • forced_proxy は、対象 URL に対するプロキシ環境変数(HTTPS では https_proxy、HTTP では http_proxy、次に all_proxy)を使用します。強制プローブが失敗しただけでは、正常に機能している環境のプロキシ経路を変更する理由にはなりません。
  • direct=FAIL は、バイパスを推奨する根拠にはなりません。000 以外の HTTP レスポンス(403 を含む)は、レスポンスが届いたことを示すだけで、アプリケーションがリクエストを受け入れたことを示すものではありません。
  • strict_tls=FAIL + direct=PASS = 経路には到達できます。問題は信頼設定のみです(ローカル CA をインストールして信頼する必要があります)。
  • host in scutil exceptions: no は状況を示すものであり、それ自体が障害ではありません。スクリプトの direct プローブは curl のプロキシを迂回しますが、システムの TUN を経由する場合があります。物理的に独立した経路を証明するものではありません。
ステップ 2A:HTTP プロキシ環境変数を修正する

プロキシ環境変数が Tailscale の HTTP 通信を横取りしていないか確認します。

env | grep -i proxy

問題のある出力 — プロキシは設定されていますが、NO_PROXY で Tailscale が除外されていません。

http_proxy=http://127.0.0.1:1082
https_proxy=http://127.0.0.1:1082
NO_PROXY=localhost,127.0.0.1          ← Missing Tailscale!

修正 — Tailscale の MagicDNS ドメインと CIDR を NO_PROXY に追加します。

export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*
項目対象理由
.ts.netMagicDNS ドメイン(host.tailnet.ts.net)DNS 解決の前に照合されます
100.64.0.0/10Tailscale の IP(100.64.* ~ 100.127.*)正確な CIDR であり、パブリック IP の誤検出がありません
192.168.*,10.*,172.16.*RFC 1918 のプライベートネットワークLAN はプロキシ経由にしないでください

2 つのレイヤーが相互に補完します:.ts.net はドメインベースのアクセスに対応し、100.64.0.0/10 は IP アドレスへの直接アクセスに対応します。

NO_PROXY の構文に関する注意点 — 互換性マトリックスについては、references/proxy_conflict_reference.md を参照してください。

Go の net/http における CIDR の注意点:Go の標準 net/http は、NO_PROXY の CIDR 表記をサポートしていません。NO_PROXY=100.64.0.0/10 は curl と Python では機能しますが、Go プログラム(Tailscale 関連ツールを含む)では引き続きプロキシ経由で通信します。修正するには、生の IP アドレスではなく MagicDNS のホスト名(例:my-wsl-box)を使用するか、ホスト名を明示的に NO_PROXY に追加します。

# WRONG for Go programs — CIDR is silently ignored
NO_PROXY=100.64.0.0/10 go-program http://100.101.102.103:8002/health  # → goes through proxy

# CORRECT — use hostname (matched as suffix) or explicit IP
export NO_PROXY=localhost,127.0.0.1,.ts.net,my-wsl-box,100.101.102.103,192.168.*,10.*,172.16.*

これは、Go ベースのツール(カスタム CLI、リモート API にアクセスする Go のテストスイートなど)から Tailscale のサービスにアクセスする場合に特に重要です。

修正を確認します。

# Both must return HTTP 200:
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<host>.ts.net:<port>/health -w "HTTP %{http_code}\n"
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<tailscale-ip>:<port>/health -w "HTTP %{http_code}\n"

その後、シェル設定(~/.zshrc または ~/.bashrc)に保存します。

ステップ 2B:経路の競合を検出する

プロキシツールが Tailscale の CGNAT 範囲を乗っ取っていないか確認します。

route -n get <tailscale-ip>

正常な出力 — 通信は Tailscale インターフェースを経由します。

destination: 100.64.0.0
interface: utun7    # Tailscale interface (utunN varies)

問題のある出力 — プロキシが経路を乗っ取っています。

destination: 100.64.0.0
gateway: 192.168.x.1    # Default gateway
interface: en0           # Physical interface, NOT Tailscale

重要:すべての utun インターフェースが Tailscale のものとは限りません。経路が正しいと判断する前に、どの utun が Tailscale のものか確認してください。

# Find Tailscale's utun interface (has a 100.x.x.x IP)
ifconfig | grep -A2 'inet 100\.'

MTU による簡単な目安:

  • MTU 1280 → 通常は Tailscale
  • MTU 4064 → 通常は Shadowrocket TUN

route -n get で、通信が MTU 4064 の utun に向かっている場合、Tailscale ではなく Shadowrocket の TUN に到達しています。インターフェース名が utun で始まっていても、これは依然として経路の競合です。

経路テーブル全体で確認します。

netstat -rn | grep 100.64

競合する 2 つの経路がある場合は、競合を示しています。

100.64/10  192.168.x.1   UGSc  en0       ← Proxy added this (wins)
100.64/10  link#N        UCSI  utun7     ← Tailscale route (loses)

根本原因:macOS では、同じプレフィックス長の場合、UGSc(静的ゲートウェイ)が UCSI(複製された静的インターフェース)より優先されます。

ステップ 2C:システムプロキシのバイパスを修正する(ブラウザーで 503)

症状:ブラウザーでは http://<tailscale-ip>:<port> に対して HTTP 503 が表示されますが、curl --noproxy '*' と curl(プロキシ環境変数を設定した状態)では 200 が返り、SSH も動作します。

根本原因:ブラウザーは、VPN プロファイル(Shadowrocket/Clash/Surge)で設定されたシステムプロキシを使用します。プロキシは IP-CIDR,100.64.0.0/10,DIRECT に一致して直接接続しようとしますが、「直接」とは Tailscale の utun インターフェイスではなく、Wi-Fi インターフェイス(en0)経由で接続することを意味します。プロキシプロセス自体には Tailscale の IP へのルートがないため、接続は 503 で失敗します。

診断:

# curl with proxy env var works (curl connects to proxy port, but traffic flows differently)
curl -s -o /dev/null -w "%{http_code}" http://<tailscale-ip>:<port>/
# → 200

# Browser gets 503 because it goes through the VPN system proxy, not http_proxy env var

修正 — プロキシツールの設定で skip-proxy に Tailscale の CGNAT 範囲を追加します。

Shadowrocket の場合は、[General] に次の設定を追加します。

skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, localhost, *.local, captive.apple.com

skip-proxy は、「これらのアドレスではプロキシを完全にバイパスする」とシステムに指示します。するとブラウザーは OS のネットワークスタック経由で直接接続し、Tailscale のルーティングテーブルが通信を正しく処理します。

tun-excluded-routes ではなく skip-proxy が機能する理由:

  • skip-proxy:HTTP プロキシ層のみをバイパスします。通信は引き続き TUN インターフェイスを経由し、Tailscale の utun が処理します。安全です。
  • tun-excluded-routes:CIDR を TUN のルーティングから完全に除外します。これにより en0 の競合ルートが作成され、Tailscale のルートが上書きされます。すべての接続が機能しなくなります。
ステップ 2C-1:ローカルのバニティドメインの横取りを修正する(local.<domain>)

症状:https://local.<domain> はブラウザーまたはデフォルトの curl では失敗しますが、直接接続/プロキシなしのコマンドでは成功します。

env -u http_proxy -u https_proxy curl -k -I https://local.<domain>/health
# -> 200
curl -I https://local.<domain>/health
# -> proxy CONNECT then TLS reset/failure

根本原因:ドメインが、ローカルへの直接経路ではなく、システムまたはシェルのプロキシ経由でルーティングされています。

修正:

  1. ドメインをプロキシアプリのバイパスリストに追加します(Shadowrocket の場合は skip-proxy)。
  2. ドメインをシェルのバイパスリスト(NO_PROXY/no_proxy)に追加します。
  3. ローカル TLS で内部 CA を使用している場合は、ローカルのルート証明書を信頼するように設定します。
# ~/.zshrc
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*,local.<domain>,www.local.<domain>
export no_proxy="$NO_PROXY"

確認:

python3 scripts/quick_diagnose.py --host local.<domain> --url https://local.<domain>/health

期待される結果:

  • host in NO_PROXY: yes
  • host in scutil exceptions: yes
  • ambient=PASS および direct=PASS
ステップ 2D:リモート開発の認証リダイレクトを修正する(SSH トンネル)

症状:リモートマシン(例:Tailscale 経由の Mac Mini)で開発サーバーを実行しています。ブラウザーで http://<tailscale-ip>:3010 にアクセスすると、ログイン/サインアップは成功しますが、認証後にアプリが http://localhost:3010/ にリダイレクトされ、失敗します。手元のマシンでは開発サーバーが動作していないためです。

根本原因:アプリの APP_URL(または同等の設定)が http://localhost:3010 に設定されています。認証ライブラリ(Better-Auth、NextAuth など)は、この URL をコールバックのリダイレクトに使用します。APP_URL を Tailscale の IP に変更すると、Shadowrocket のプロキシとの競合が発生し、リモートマシンでのローカル開発に支障が出ます。

修正 — SSH のローカルポートフォワーディングを使用します。これにより、3 つの競合レイヤーをすべて回避できます。

# Forward local port 3010 to remote machine's localhost:3010
ssh -NL 3010:localhost:3010 <tailscale-ip>

# Or with autossh for auto-reconnect (recommended for long sessions)
autossh -M 0 -f -N -L 3010:localhost:3010 \
    -o "ServerAliveInterval=30" \
    -o "ServerAliveCountMax=3" \
    -o "ExitOnForwardFailure=yes" \
    <tailscale-ip>

ブラウザーで http://localhost:3010 にアクセスします。認証後のリダイレクト先 localhost:3010 → トンネル → リモートの開発サーバーとなり、正しく動作します。

この方法が最善である理由:

  • .env の変更が不要です。APP_URL=http://localhost:3010 はどこでも機能します。
  • Shadowrocket との競合がありません。localhost は常に skip-proxy に含まれます。
  • コードの変更が不要です。ローカル開発と同じ動作になります。
  • 業界標準の方法です。VS Code Remote SSH と GitHub Codespaces でも同じパターンが使用されています。

autossh をインストールする:brew install autossh(macOS)または apt install autossh(Linux)

バックグラウンドのトンネルを終了する:pkill -f 'autossh.*<tailscale-ip>'

ステップ 2E:スクリプトでの localhost プロキシ横取りを修正する

症状:シェルで http_proxy がグローバルに設定されていると、Makefile のターゲットや localhost に curl するスクリプト(ヘルスチェック、ウォームアップ用ルートなど)が失敗またはタイムアウトします。

根本原因:~/.zshrc に http_proxy=http://127.0.0.1:1082 が設定されていますが、no_proxy に localhost が含まれていません。そのため、すべての curl コマンドで localhost へのリクエストがプロキシ経由で送信されます。

修正 — スクリプト内の localhost へのすべての curl コマンドに --noproxy localhost を追加します。

# WRONG — fails when http_proxy is set
@curl -sf http://localhost:9000/minio/health/live && echo "OK"

# CORRECT — always bypasses proxy for localhost
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live && echo "OK"

または、~/.zshrc で no_proxy をグローバルに設定します。

export no_proxy=localhost,127.0.0.1
ステップ 2F:SSH ProxyCommand の二重トンネルを修正する(git push/pull の失敗)

症状:ssh -T [email protected] は常に成功しますが、git push または git pull が断続的に次のエラーで失敗します。

FATAL: failed to begin relaying via HTTP.
Connection closed by UNKNOWN port 65535

小規模な操作(認証、フェッチメタデータ)は成功しますが、大容量データの転送は失敗します。

根本原因:Shadowrocket TUN が有効な場合、すべての TCP トラフィックはすでに VPN トンネル経由でルーティングされています。SSH 設定でも ProxyCommand connect -H を使用していると、データは 2 つのプロキシ層を通過することになり、経由先のプロキシが大容量または長時間継続する HTTP CONNECT 接続を切断します。

診断:

# 1. Confirm Shadowrocket TUN is active
ifconfig | grep '^utun'

# 2. Check SSH config for ProxyCommand
grep -A5 'Host github.com' ~/.ssh/config

# 3. Confirm: removing ProxyCommand fixes push
GIT_SSH_COMMAND="ssh -o ProxyCommand=none" git push origin main

修正 — ProxyCommand を削除し、ssh.github.com:443 に切り替えます。完全な SSH 設定、ポート 443 が有効な理由、VPN がオフの場合の代替手段については、references/proxy_conflict_reference.md § SSH ProxyCommand and Git Operations を参照してください。

ステップ 2G:VM/コンテナーランタイムへのプロキシ伝播を修正する(Docker pull/build の失敗)

症状:ホストの curl では同じ URL に正常に接続できるのに、docker pull または docker build が net/http: TLS handshake timeout、Alpine/Debian リポジトリからの Connection refused、または auth.docker.io からの Internal Server Error で失敗します。

対象:Shadowrocket/Clash TUN が有効な macOS 上の OrbStack、Docker Desktop、または VM ベースの Docker ランタイム。

根本原因:VM ベースの Docker ランタイム(OrbStack、Docker Desktop)は、軽量 VM 内で Docker デーモンを実行します。VM の外向きトラフィックは、ホストプロセスとは異なるネットワーク経路を通ります。

Host process (curl):   Process → TUN (Shadowrocket) → landing proxy → internet ✅
VM process (Docker):   Docker daemon → VM bridge → host network → TUN → ??? ❌

TUN はホストから発生するトラフィックを正しく処理しますが、VM を経由するトラフィックはドロップまたは遅延させる場合があります(TCP スタック、MTU、キープアライブの動作が異なるためです)。

重要な違い:docker pull と docker build では異なるプロキシ経路を使用します:

操作プロキシの参照元制御方法
docker pullDocker デーモンの設定~/.orbstack/config/docker.json または docker info
docker build(RUN apt/apk)ビルドコンテナーの環境変数--build-arg http_proxy=... または --network host
docker runコンテナーの環境変数-e http_proxy=... またはデーモンから継承

docker.json だけを修正しても、docker build は修正されません。ビルドコンテナー内の RUN コマンドは、デーモンのプロキシ設定を継承しないためです。

診断 — どのサブ問題に該当するかを特定します。

# 1. Can the Docker daemon pull images?
docker pull --quiet alpine:latest 2>&1

# 2. Can a RUN command inside a build reach the internet?
docker build --no-cache - <<'EOF' 2>&1
FROM alpine:latest
RUN apk update && echo "APK OK"
EOF

# 3. Can a running container reach the internet?
docker run --rm alpine:latest sh -c "apk update 2>&1 | head -3"

4 つのサブ問題とその修正方法:

2G-1:ホストでは正常に動作するが、docker build が失敗する(OrbStack と Shadowrocket の組み合わせで最も一般的)

症状:ホストの curl では同じ URL に接続できるにもかかわらず、docker build 内の RUN apk add または RUN apt-get install が、すぐに(0.2 秒未満で)Connection refused で失敗します。

根本原因:OrbStack の network_proxy: auto は、すべての HTTPS トラフィックを傍受する透過プロキシを VM 内に作成します。Shadowrocket TUN も有効な場合、透過プロキシの上流接続が壊れます。HTTPS トラフィックが VM 内の 127.0.0.1 にリダイレクトされますが、そこでは何もリッスンしていません。

診断:

# Verify: inside the container, HTTPS goes to 127.0.0.1 (broken transparent proxy)
docker run --rm alpine:latest sh -c "wget -q --timeout=5 -O /dev/null https://dl-cdn.alpinelinux.org/ 2>&1"
# → "wget: can't connect to remote host (127.0.0.1): Connection refused"
#                                        ^^^^^^^^^^^^ This is the smoking gun

# Verify: --network host bypasses the VM bridge and works
docker run --rm --network host alpine:latest sh -c "apk update 2>&1 | head -3"
# → "v3.23.x ... OK: 27431 distinct packages available"  ← Works!

修正 — docker build に --network host を使用します。

docker build --network host -f Dockerfile -t myimage .

これにより OrbStack の VM ネットワークブリッジを完全に迂回します。ビルドコンテナーはホストのネットワークスタックを直接使用し、Shadowrocket TUN がトラフィックを正しく処理します。

トレードオフ:--network host を使用すると、ビルド時のネットワーク分離が無効になります。CI/CD では、プロキシ設定の修正(2G-2)を優先してください。ローカル開発では、--network host が実用的な修正方法です。

恒久的な修正 — すべてのビルドでこの設定が必要な場合は、~/.docker/daemon.json に追加するか、シェルエイリアスを使用します。

# Shell alias (add to ~/.zshrc)
alias docker-build='docker build --network host'
2G-2: OrbStack がプロキシ設定を自動検出してキャッシュする

OrbStack の network_proxy: auto は、シェル環境から http_proxy を読み取り、Docker デーモンを設定します。設定は ~/.orbstack/config/docker.json に保存されます。

主な動作:

  • network_proxy: auto — OrbStack がホストの環境変数を読み取り、VM 内に透過プロキシを作成します
  • network_proxy: none — 透過プロキシを無効にしますが、VM ブリッジのトラフィックは引き続き TUN 経由でルーティングされるため、タイムアウトする場合があります
  • docker.json — docker pull のプロキシを制御します。docker build の RUN コマンドは対象ではありません

診断:

# Check all three layers
echo "=== OrbStack config ==="
orbctl config get network_proxy

echo "=== docker.json (daemon proxy) ==="
cat ~/.orbstack/config/docker.json

echo "=== Docker info (effective proxy) ==="
docker info | grep -iE "proxy|No Proxy"

修正 — host.internal を使って docker.json を設定します(OrbStack がこれをホスト IP に解決します)。

python3 -c "
import json, os
config = {
    'proxies': {
        'http-proxy': 'http://host.internal:1082',
        'https-proxy': 'http://host.internal:1082',
        'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0/24,100.64.0.0/10,host.internal,*.local'
    }
}
path = os.path.expanduser('~/.orbstack/config/docker.json')
json.dump(config, open(path, 'w'), indent=2)
print('Written:', path)
"

# Full restart required
orbctl stop && sleep 3 && orbctl start

重要:host.internal(OrbStack 固有)を使用してください。127.0.0.1(VM のループバックを指します)や host.docker.internal(すべてのコンテキストで解決されるとは限りません)は使用しないでください。

プロキシを削除しない理由:TUN が有効な状態で Docker プロキシを削除すると、VM のトラフィックはブリッジ → TUN 経由で直接送信され、TLS ハンドシェイクがタイムアウトします。プロキシは機能する外向き通信経路を提供します。

2G-3: プロキシを削除すると Docker の状態が悪化する(直感に反する動作)
Docker の設定トラフィック経路結果
プロキシ ON(127.0.0.1)、no-proxy なしDocker → VM プロキシ → ???docker pull は動作する場合がありますが、localhost のプローブは失敗します ❌
プロキシ ON(host.internal)、no-proxy あり外部:Docker → ホストプロキシ → インターネット;ローカル:直接どちらも動作します ✅
プロキシ OFF(network_proxy: none)Docker → VM ブリッジ → ホスト → TUN → インターネットTLS タイムアウト ❌
--network host(ビルドのみ)ビルドコンテナー → ホストネットワーク → TUN → インターネットビルドが動作します ✅

判断フロー:

  • docker pull が失敗する → host.internal プロキシを使って docker.json を修正します(2G-2)
  • docker build が失敗する → --network host を使用する(2G-1)か、--build-arg http_proxy=http://host.internal:1082 を渡します
  • 両方が失敗する → docker.json と --network host の両方を修正します
2G-4: デプロイスクリプトやコンテナーのヘルスチェックがプロキシ経由で localhost に接続する

コンテナー内で curl localhost を実行するデプロイスクリプトや、wget http://localhost を使う Docker ヘルスチェックは、環境変数がコンテナーに引き継がれるとプロキシ経由でルーティングされます。

よくある症状:

  • コンテナー内のアプリは正常に動作しているのに、ヘルスチェックが (unhealthy) と表示される
  • ヘルスチェックのログに wget: can't connect to remote host (127.0.0.1): Connection refused と表示される(アプリのポートではなく、プロキシのポート)

根本原因:Docker はホストの大文字と小文字両方のプロキシ環境変数を引き継ぎます。多くのツールは大文字の環境変数(HTTP_PROXY=)だけをクリアし、小文字の環境変数(http_proxy=http://127.0.0.1:1082)をクリアし忘れます。ヘルスチェックの wget は小文字の環境変数を使います。

docker-compose.yml での修正 — 大文字と小文字の両方をクリアします。

environment:
  # Must clear both uppercase and lowercase — wget/curl check different vars
  - HTTP_PROXY=
  - HTTPS_PROXY=
  - http_proxy=
  - https_proxy=
  - NO_PROXY=*
  - no_proxy=*

デプロイスクリプトでの修正:

_local_bypass="localhost,127.0.0.1,::1"
export NO_PROXY="${_local_bypass}${NO_PROXY:+,${NO_PROXY}}"
export no_proxy="$NO_PROXY"

# Use 127.0.0.1 instead of localhost in probe URLs (some proxy implementations
# only match exact string "localhost" in no-proxy, not the resolved IP)
curl http://127.0.0.1:3001/health   # ✅ bypasses proxy
curl http://localhost:3001/health    # ❌ may still go through proxy

修正の確認:

# Docker proxy check (should show proxy + no-proxy)
docker info | grep -iE "proxy|No Proxy"

# Pull test
docker pull --quiet hello-world

# Build test (the real verification)
docker build --network host --no-cache - <<'EOF'
FROM alpine:latest
RUN apk update && echo "BUILD OK"
EOF

# Container env check (no proxy leak)
docker exec <container> env | grep -i proxy
# Expected: all empty or not set
Step 2H: TUN 経由で SSH/Git が失敗する(Connection closed by 198.18.x.x)

症状:git clone/fetch/push が Connection closed by 198.18.0.x port 443 で失敗します。ssh -T [email protected] も失敗する場合があります。DNS の名前解決で実際の IP ではなく 198.18.x.x アドレスが返されます。

まず、この症状についての注意:エラーメッセージ内の 198.18.x.x は、Fake-IP が失敗の原因である証拠ではありません。TUN 経由では、すべてのホスト名が Fake-IP に解決されるため、そのアドレスは、この理由でも別の理由でも接続に失敗した場合に表示されます。このアドレスから分かるのは TUN が有効だということだけで、それ以上のことは分かりません。まったく異なる 2 つの仕組みが同じエラー行を発生させ、必要な対処も逆になります。

(A) プロトコルを認識した処理の誤り(B) 転送経路の断続的な不安定性
主張TUN がポート 443 を検出し、HTTPS だと判断して SSH のハンドシェイクを破棄するトンネルの転送経路が、プロトコル、ポート、宛先を問わず、接続を断続的に破棄する
予測SSH-over-443 は確実に失敗します。同じプロキシ経由の HTTPS は問題ありませんSSH と HTTPS の両方が、時間的に集中する期間に同程度の割合で失敗します
正しい対処TUN を迂回するよう SSH をルーティングします再試行します。通常、次の接続は成功します
何かを実行する前に、原因を切り分けてください — 同じプロキシ経路で、無関係なホスト宛てに通常の HTTPS を十分な回数実行し、単なる偶然ではなく失敗率を把握します。
# Same proxy, plain HTTPS, unrelated destination — does it also fail?
for i in $(seq 1 40); do
  curl -s -o /dev/null -x http://127.0.0.1:<proxy-port> \
       -w '%{http_code}\n' -m 10 https://www.google.com/generate_204
done | sort | uniq -c
  • HTTPS リクエストも、SSH の失敗と同程度の割合で失敗する(000)場合 → (B)。失敗はプロトコルやポート固有ではありません。SSH の経路を変更しても解決しません。接続を切断しているものは、プロトコルより上流にあるためです。
  • 多数回試しても HTTPS が 100%成功する一方で、SSH は確実に失敗する場合 → (A)。以下の DIRECT ルールによる修正が適切です。

(B)の実測例を 1 つ挙げます。率を曖昧にせず具体的に示すためのものです。TUN モードで Shadowrocket を実行する macOS ホストでは、状態のよい時間帯に ssh -T [email protected] が 20 回中 20 回成功し、状態の悪い時間帯には試行の約 15~20%が失敗しました。同じ時間帯に、明示的なプロキシ経由で無関係なホスト宛てに送った通常の HTTPS も、同程度の割合で失敗しました。同じ日に git push は 2 回連続で失敗した後、設定を変更していないのに 3 回目で成功しました。このマシンは(B)でした。そのため、443 番ポートの話は実測すると成り立ちませんでした。これは 1 台のホストの例であり、普遍的に否定するものではありません — 上記のテストを実行して、ご自身の環境がどちらかを確認してください。

(B)の修正 — 再試行し、それを自動化します。 失敗は断続的に発生するため、再試行が最も簡単で適切な対処です。コマンドを毎回再実行するのではなく、git のトランスポート層で透過的に処理します。

git config --global core.sshCommand /path/to/ssh-retry-wrapper.sh

このラッパーでは、次の 2 点を正しく処理する必要があります。

  • 鍵交換の完了前に発生した失敗だけを再試行してください。 kex_exchange_identification、Connection closed|reset by <host> port <n>、Connection timed out during banner exchange などの stderr のシグネチャを照合します。鍵交換が完了していなければ、ssh はリモートコマンドを実行していないため、git はプロトコルのバイトを一切受け取っていません。接続を開き直しても git には見えません。転送の途中で発生した切断にはこれらのシグネチャが含まれないため、そのまま処理を通す必要があります — そのような場合に再試行すると、データが破損します。
  • 代わりに ConnectionAttempts を使わないでください。この状況では機能しません。 これは TCP 接続フェーズだけを再試行します。TUN 環境では、接続先で何が起きてもハンドシェイクは ローカルで 完了するため、接続は常に「成功」し、その後のバナー交換で失敗します。接続を受け付けてすぐに閉じるリスナーで実測したところ、ConnectionAttempts=1、3、5 のいずれも、接続の受け付けは 1 回だけでした。

⚠️ 再試行の重複には注意してください。ラッパースクリプトや cron ジョブがすでに push を再試行している場合、その下層にトランスポート層の再試行を追加すると、再試行回数が掛け合わされます(5 × 3 = 15 接続)。外側の層のログには、実際の接続試行回数が少なく記録されます。

(C)「SSH チャネルが完全にダウンしている」 — 仕組みではなく、避けるべき断定です。 SSH が連続して失敗する時間帯(ssh.github.com:443 のバナータイムアウト、22 番ポートでも Connection closed)に HTTPS が 100%成功すると、新しい仕組みが原因のように見えます。しかし、そうではありません。同じホストを時間帯を変えて再測定したところ、25 回の試行で ssh -T [email protected] の成功率は 75~84%でした。つまり、ずっと仕組み(B)の悪い時間帯のピークだったのです。1 つの時間帯に N 回連続で失敗しても、独立したサンプルが N 件あるわけではありません。転送の不安定さは時間とともに変動するため、悪い時間帯には 5 回連続の失敗も日常的に発生します。「チャネルが死んでいる」と結論づける前に、数分後に再測定してください。

どの程度サンプリングすればよいかは、失敗の件数ではなく、拒否の形態によって異なります。名前付きのポリシーコード(たとえば、API が KEYLESS_ACCESS_NOT_AVAILABLE を返す場合)は、エンドポイントが ルールを明示している ため、同一セッションで数回サンプリングすれば判断できます。チャレンジページ、レート制限、接続リセットは 状態 であり、時間や IP レピュテーションに応じて変動します。そのため、同じ時間帯に連続して失敗してもサンプルとしては 1 件とみなし、「ダウン」と「悪い時間帯」を区別するには、時間帯を変えて再測定する必要があります。

いずれの場合も、次の 2 点は覚えておいてください。

  • nc -vz github.com 22 が succeeded と報告しても、何の証明にもなりません。TUN 経由では、ローカルスタックが任意のポートで宛先に応答します。実際のハンドシェイク(ssh -T)だけを信頼してください。
  • リトライラッパーの試行回数を本当に使い切った場合、1 回限りの HTTPS バイパスなら共有リモート設定を変更せずに済みます。git push https://github.com/<owner>/<repo>.git HEAD:refs/heads/main(直接接続の試行が SSL_ERROR_SYSCALL で失敗した場合は、HTTPS_PROXY=http://127.0.0.1:<proxy-port> を前置してください)。

(A) の修正 — DIRECT ルール(プロキシツールの設定にアクセスできる場合)、TUN がプロトコル検査を行わずにこのトラフィックを通過させます。

IP-CIDR,140.82.112.0/24,DIRECT
IP-CIDR,192.30.252.0/22,DIRECT

これは影響を最小限に抑えて試せます。効果がなければ、元の状態に戻るだけです。

推奨しません:~/.ssh/config に GitHub の IP をハードコードすること。 このスキルの以前のバージョンでは、HostName 140.82.112.35 を指定していました。避けるべき理由は 3 つあります。

  1. 古くなり、そうなると確実に失敗します。 GitHub はこれらのアドレスを変更します。ハードコードした IP が無効になると connection refused が表示され、障害のように見えます。IP を管理するための週次 cron ジョブを推奨するのは、修正すべき場所を間違えている証拠です。
  2. 主張どおりにバイパスできるとは限りません。 プロキシツールは、宛先が偽の IP かどうかではなく、ルールに基づいてルーティングします。そのため、一致する DIRECT ルールがなければ、リテラル IP もホスト名と同じ転送経路を通り、同じ不安定さを引き継ぎます。
  3. 新しい known_hosts の検索キーが追加されます。 known_hosts はホストキーではなく接続文字列をキーにしているため、[140.82.112.35]:443 は未登録で、TOFU プロンプトが表示されます。これにより、無人の自動化が停止するか、StrictHostKeyChecking を緩めるよう促されます。これは不確かな効果のために支払う実質的な代償です。

すでに IP のハードコードを適用して改善した場合も、IP が原因だと結論づける前に、上記の HTTPS テストを実行してください。問題のある時間帯が過ぎれば、断続的な障害も自然に「改善」することがあります。

ステップ 2I:getaddrinfo チェーンで停止する DNS リゾルバーを修正する

症状:ssh、curl(-x なし)、git、およびシステム DNS を使用するその他のツールが、名前解決の前に約 60 秒間停止します。ssh -vvv は次の出力の直後にフリーズします。

debug2: resolving "<host>" port <port>
debug3: resolve_host: lookup <host>:<port>

その後も debug1: connect to address に到達しません。待つと最終的には成功しますが、新しい接続のたびに同じ遅延が発生します。nslookup <host> はすぐに(約 10ms で)応答しますが、dscacheutil -q host -a name <host> は 60 秒以上かかります。

根本原因:macOS の getaddrinfo は、scutil --dns にある domain フィルターが一致する(またはフィルターが設定されていない)すべてのエントリーを参照します。ネームサーバーに到達できないリゾルバーが 1 つでもあり、そのインターフェースがルーティングテーブルに残っていると、getaddrinfo は次のリゾルバーに進む前に UDP の再試行タイムアウト(通常 30〜60 秒)いっぱいまで待機します。実環境で最もよく見られる原因は、トンネリングデーモン(Tailscale、Cisco AnyConnect、Pulse Secure)が、utun と DNS の注入を解除せずにクラッシュすることです。

nslookup が誤解を招く理由:nslookup は /etc/resolv.conf(ネームサーバー 1 つ)のみを読み取ります。dscacheutil と getaddrinfo は DirectoryService を経由し、scutil --dns のリゾルバーチェーン全体に問い合わせます。この 2 つの結果が異なることが決定的な手掛かりです。

「ping は通るのに DNS は応答しない」という落とし穴:utun インターフェースが引き続き IP を受け持ち、ICMP にローカルで応答するため、ポート 53 が機能していなくても ping <resolver-ip> が 1ms 未満で応答することがあります。ping の結果からリゾルバーの健全性を判断しないでください。実際のサービスをテストしてください:dig @<ip> +tries=1 +timeout=3 example.com。

診断:ネームサーバーを二分探索する

15 秒以内に応答しないリゾルバーを特定します。

# 1. Read every resolver's nameserver, interface, and matching scope
scutil --dns | grep -E "^resolver|nameserver|domain :|search domain|if_index"

# 2. Time each nameserver in isolation (3-second cap)
for ns in <each_unique_nameserver_from_step_1>; do
  printf "  %s: " "$ns"
  /usr/bin/time -p dig @$ns +tries=1 +timeout=3 +short example.com 2>&1 | tr '\n' ' '
  echo
done

正常なネームサーバーは 0.1 秒未満で応答します。応答しないネームサーバーは、ちょうど 3.01 秒後に connection timed out; no servers could be reached を返します。

IPv6 リゾルバーについては、同じ dig @<ipv6> テストを実行してください。Tailscale や複数の VPN は IPv4 と IPv6 のアドレスを両方注入するため、どちらか一方が応答しなくても同じ症状が発生します。

リゾルバー属性を確認する — 影響範囲を特定する

各 scutil --dns リゾルバーには、どのクエリに対して動作するかを決める属性があります。

属性一致するものこのリゾルバーが停止した場合の影響範囲
domain : foo.com*.foo.com のクエリのみ限定的 — foo.com の検索のみ停止します
search domain : fooすべてのクエリ(検索サフィックスが追加されます)無制限 — すべての検索が停止します
domain フィールドなしすべてのクエリ(デフォルトで対象になります)無制限 — すべての検索が停止します

domain フィルターがあるリゾルバーの停止は厄介ですが、影響は局所的です。domain フィルターがないリゾルバーの停止(Tailscale の 100.100.100.100 のように、VPN によって挿入される DNS でよく見られます)は、修正するまでシステム上のすべての検索を停止させます。

原因のコンポーネントを特定する

二分探索で停止しているネームサーバーを特定したら、どのアプリがそれを挿入したのかを確認します(if_index のインターフェイス名が最も有力な手掛かりです。utun* インターフェイスは通常、VPN デーモンに由来します)。

特に Tailscale の場合:

tailscale status
# Healthy: lists peers
# Dead:    failed to connect to local Tailscale service; is Tailscale running?

「failed to connect」エラーは、デーモンプロセスは終了しているものの、挿入したネットワーク設定(utun インターフェイスと DNS リゾルバーエントリー)がクリーンアップされずに残っていることを意味します。同じパターンは、あらゆる VPN/トンネリングツールに当てはまります。

修正

担当するアプリをアプリケーションレベルで再起動し、クリーンアップフックを実行して古いインターフェイスを削除します。

Tailscale(App Store 版および macOS スタンドアロンビルド):

osascript -e 'quit app "Tailscale"' && sleep 3 && open -a Tailscale

ほかの VPN/トンネリングツールでは、kill -9 よりも、アプリを正常に終了する方法(メニューバー → Quit、または osascript -e 'quit app "<name>"')を優先してください。強制終了するとクリーンアップが省略され、同じようにインターフェイスが停止した状態が残ることがあります。アプリが通常の方法で終了しない場合に限り、pkill -9 <name> に進んでください。

「DNS キャッシュをフラッシュする」より「アプリを再起動する」ほうが効果的な理由:sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder はキャッシュされた結果をフラッシュしますが、scutil --dns のリゾルバーチェーンはキャッシュではなくネットワーク設定から再構築されます。フラッシュ後も停止したリゾルバーは残ります。最初にリゾルバーを登録したアプリから修正する必要があります。

エンドツーエンドで確認する

DNS リゾルバーの修正は、確認が不十分になりがちです。システムの経路が復旧したと判断する前に、以下の確認をすべて通過する必要があります。

# 1. The owning daemon is back (not just its UI)
tailscale status | head -3

# 2. The previously-dead nameserver responds fast
dig @<previously-dead-ns> +tries=1 +timeout=3 +short example.com
# Expected: <0.1s, returns IP

# 3. macOS system path is unblocked (proves getaddrinfo recovered)
/usr/bin/time -p dscacheutil -q host -a name example.com
# Expected: <0.1s, returns IP

# 4. The original failing command works WITHOUT any workaround
ssh -o "ProxyCommand=none" -T [email protected]
# Expected: "Hi <user>! You've successfully authenticated..."

最初に失敗した操作が、決定的な確認方法です。診断中に回避策(DNS を SOCKS5 プロキシに委譲する ProxyCommand、/etc/hosts のエントリー、ハードコードした IP アドレスなど)を適用した場合、回避策を無効にして(ProxyCommand=none)元のコマンドを実行することが、システムの DNS 経路を実際に復旧できたのか、単に迂回しただけなのかを確認する唯一の方法です。

macOS のリゾルバーの順序、IPv4 と IPv6 の分離、および各診断コマンドと実際の出力を順に確認する例を含む詳しい概念モデルについては、references/dns_resolver_chain_stall.md を参照してください。

ステップ 2J:TUN の DIRECT スプリットブレインを修正する(ドメイン接続は失敗し、実 IP 接続は動作する)

症状:TUN ツールが DIRECT にルーティングするすべてのサイト(国内サイト、クラウドプロバイダーの API、自分の API ドメイン)が同時に失敗する一方、プロキシ経由の海外サイトは引き続き動作します。クライアントによって、障害はいくつかの異なる形で現れます。

  • curl の直接接続:ハンドシェイク中に SSL routines::unexpected eof while reading
  • curl -x http://127.0.0.1:<proxy-port>:CONNECT tunnel failed, response 503
  • Node ベースの CLI(Claude Code、npm など):UNKNOWN_CERTIFICATE_VERIFICATION_ERROR が無限に再試行される — 偽 IP とドメインの対応テーブルが誤っているため、TUN が SNI を別のバックエンドに転送し、そのバックエンドが別サイトの証明書を提示します
  • プロキシのヘルスウォッチドッグを使っている場合、健全と報告され続けます — 以下の警告を参照してください

根本原因:TUN ツール(偽 IP モードの Shadowrocket/Clash/Surge)には、独立した転送プレーンが 2 つあります。プロキシ経由のトラフィック(ノード経由の海外向け)と、DIRECT トラフィック(ドメインルールに一致し、ツール独自のネットワークスタックから転送されるもの)です。DIRECT プレーンの状態だけが壊れることがあります。原因には、トンネル経由の DNS 失敗、ネットワーク変更後に古くなった偽 IP テーブル、内部状態の破損などがあります。その間、プロキシ経由のプレーンは完全に正常なままです。ドメインが DIRECT ルールに一致する偽 IP に名前解決された接続はすべて、ツール内で失敗します。

診断:

# 1. Confirm fake-IP DNS is in effect (domain resolves into 198.18.0.0/15)
dig +short <any-direct-rule-domain>
# → 198.18.x.x

# 2. Get the real IP from a public resolver (UDP/53 usually bypasses the broken path)
dig +short @223.5.5.5 <domain>   # or @1.1.1.1 / @8.8.8.8
# → real address

# 3. Connect by real IP with correct SNI — bypasses the fake-IP table
curl -sS -o /dev/null -w '%{http_code}\n' --resolve <domain>:443:<real-ip> https://<domain>/
# → normal HTTP status: this fresh-IP path works; establish failure scope before attribution

ステップ 3 も失敗する場合、これはスプリットブレインではありません。ただし、障害が確定したわけでもありません。ステップ 3 も TUN を経由するためです(UDP/53 も傍受されます)。「TUN の全面停止と実際の障害」(ステップ 2A の上)にある、インターフェースにバインドした物理接続プローブを実行してください。そこで両方とも 000 となった場合に限り、「実際のローカルネットワーク障害」と判断できます。

修正 — ネットワーク保守と、それに伴う通信中断が許可されている場合は、復旧契約に従って対象のトンネルを再起動し、古い偽 IP エントリが残っている場合は OS の DNS キャッシュをフラッシュしてください。現在の設定からサービスを特定し、代わりに Tailscale の制御トンネルを使ったり、プロキシアプリを終了したりしないでください。Shadowrocket の場合、shadowrocket://disconnect と shadowrocket://connect は状態遷移を要求します。それぞれの後で、実際の VPN 状態を確認してください。&& で連結しないでください。途中で失敗しても、準備済みの復旧アクションが実行される必要があります。他のクライアントでは、対応する API または GUI を使用してください。

トンネルが接続済みであることを確認した後、許可された保守範囲内でのみ sudo killall -HUP mDNSResponder を実行してください。DNS キャッシュのフラッシュでは、切断された VPN は復旧しません。

必要な各プレーンを確認する — あるプレーンを復旧させても、別のプレーンが停止したまま、または停止する場合があります。

curl -sS -o /dev/null -w 'domestic direct: %{http_code}\n' --max-time 10 https://<domestic-site>/
curl -sS -o /dev/null -w 'own DIRECT-rule domain: %{http_code}\n' --max-time 12 https://<your-api-domain>/health
curl -sS -o /dev/null -w 'cloud API: %{http_code}\n' --max-time 10 https://<cloud-provider-endpoint>/
curl -x http://127.0.0.1:<proxy-port> -sS -o /dev/null -w 'overseas via proxy: %{http_code}\n' --max-time 10 https://www.google.com/generate_204

ウォッチドッグの死角:自動プロキシヘルスチェッカーを使用している場合は、実際に何をプローブしているか確認してください。プロキシ経由で海外のエンドポイントだけをテストするウォッチドッグが確認するのは、プロキシ経由のプレーンだけです。このセクションの元となったインシデントでは、DIRECT プレーンが完全に停止していた 2 時間以上にわたり、ウォッチドッグは 5 分ごとに「健全」と報告していました。緑色のヘルスチェックが示すのは、プローブ対象の経路だけです。ツールが転送する各プレーンをプローブしてください。

ステップ 2K:問題の原因が自分のトンネルか、接続先のファイアウォールかを判断する

症状:公開ホストへの SSH が kex_exchange_identification: Connection closed by <ip> — または Connection closed by UNKNOWN port 65535 — で失敗する一方、同じホストへの HTTPS は引き続き動作します。nc -z <ip> 22 ではポートが開いていると報告されます。以下の方法は SSH 向けです。別のサービスがブロックされている場合は、全体を通してポート、ログパス、サービスユニットをそのサービスのものに置き換えてください。 これを独立した手順にする理由:Step 2H とまったく同じ症状に見えますが、Step 2H の対処(TUN の再起動、DNS キャッシュのフラッシュ)では解決しません。原因はそもそもローカルにない可能性があるためです。また、Step 5A ともまったく同じ症状に見えますが、5A は Tailscale が WSL 上にある場合に限られます。この手順は、経路上に Tailscale がない TUN を対象とします。ほかのどちらでも扱われていない 3 つ目の原因は、接続先自身のファイアウォールによって、接続が気付かれないまま破棄されていることです。これは、クラウドホストが適用する 2 つのレイヤー(マシンの外側にあるプロバイダーのセキュリティグループと、マシン内部の ufw/firewalld/nftables)のどちらでも起こり得ます。クライアントからは、許可リストによって破棄された接続と、TUN の転送障害を区別できません。どちらも「接続は確立するが、プロトコルのバナーが表示される前に切断される」ように見えます。TUN の使用中は、ローカルで調べるほど誤解を招く証拠が増えるだけです。

まず、nc -z を信用するのをやめてください。 TUN の使用中は、ローカルスタックが接続先に代わって TCP ハンドシェイクを完了するため、接続先で閉じられているポートも含め、すべてのポートで nc -z が成功します。ポートの調査結果を読む前に、次の手順で確認してください。

# Probe a port the destination certainly does NOT serve
nc -z -G 5 -w 5 <destination-ip> 65001 && echo "nc is lying: the TUN answered for a closed port"
# Control: an address that cannot answer at all (TEST-NET-3, RFC 5737)
nc -z -G 5 -w 5 203.0.113.199 443 || echo "control OK: this probe can still report failure"
  • 最初の行が表示される → このマシンでは nc -z <that host> <any port> の結果から、接続先について何も分かりません。この手順を続け、以降のポート調査の結果はすべて無視してください。
  • 最初の行が表示されない(対照行は表示される) → この経路では nc -z を信用できるため、この手順の前提は当てはまりません。続行する前に、実際に TUN が使われているか再確認してください。使われていない場合、ポートが開いている/閉じているという結果は有効な証拠となるため、Step 2B/5A のほうが適切な可能性があります。

ホスト名にも同じ落とし穴があります。存在しない名前に対してフェイク IP の DNS 応答が返るため、HTTP 200 が返っても、どのマシンが応答したかは特定できません。これも確認してください。dig +short @1.1.1.1 <a-name-you-just-invented> の結果は空になるはずです。アドレス(通常は 198.18.x.x)が返る場合、DNS 応答は合成されており、このマシン上では名前に基づく結果からホストを特定できません。

次に、接続先へ別経路で問い合わせてください。 「パケットは届いたか」を確定できるのは、障害が起きているデータ経路を通らない通信手段だけです。クラウド VM の場合、それはプロバイダーのゲストエージェント制御プレーンです。プロバイダー独自のネットワーク経由でインスタンスに到達するため、接続可能なすべてのポートがブロックされていても機能します。

  • Alibaba Cloud — aliyun ecs DescribeCloudAssistantStatus --RegionId <region> --InstanceId.1 <instance-id>(直近 1 分以内の LastHeartbeatTime は、マシンが稼働中でエージェントも正常であることを示します)。続いて、aliyun ecs RunCommand --RegionId <region> --InstanceId.1 <instance-id> --Type RunShellScript --ContentEncoding Base64 --CommandContent <base64> --Timeout 60 を実行し、aliyun ecs DescribeInvocationResults --RegionId <region> --InvokeId <id> で出力を取得します(Output フィールドは base64 です)。コマンドは 2026-08-30 に実行して検証済みです。
  • AWS/GCP/Azure にも同等の機能があります(SSM Run Command、ゲストエージェントベースのアクセス、Run Command 拡張機能)。ここでは構文を検証していないため、プロバイダーのドキュメントを確認してください。
  • ゲストエージェントがない、またはエージェントを提供するプロバイダー上にホストがない → この手順では問題を確定できません。ホストのログを確認するには、物理アクセスまたはコンソールアクセスができる人に依頼してください。代わりに別のクライアント側調査を行わないでください。
  • ハートビートが古い、または存在しない → エージェントかホストが停止しています。これはホストについての事実であり、トンネルとファイアウォールのどちらが原因かを示す証拠ではありません。まず、これを修正または調査してください。

原因の所在を確定する、次の 1 つの質問をしてください。

# run these INSIDE the destination, through the out-of-band channel
journalctl -u ssh -n 20 --no-pager || journalctl -u sshd -n 20 --no-pager   # unit is `ssh` on Debian/Ubuntu, `sshd` on RHEL
grep -a sshd /var/log/auth.log 2>/dev/null | tail -20    # Debian/Ubuntu, only if rsyslog is installed
grep -a sshd /var/log/secure   2>/dev/null | tail -20    # RHEL family

⚠️ まずジャーナルを確認し、空の auth.log を回答として扱わないでください。 Ubuntu 24.04 でもファイルは存在し、CRON と sudo の行は記録されますが、rsyslog はデフォルトでインストールされておらず、sshd はこのファイルに何も書き込みません。稼働中の 24.04 ホストでの測定結果では、auth.log は 90 行あり、そのうち sshd によるものは 0 行でした。一方、journalctl -u ssh には記録全体が含まれていました。ここで auth.log だけを読むと、この手順で防ごうとしている「接続試行は届いていない」という誤った結論をまさに生み出してしまいます。 サーバーが代わりに correct key but not from a permitted host を記録している場合、接続試行は sshd に到達しています。SSH トンネルの接続元制限を参照し、専用キーの from= 認証制限を確認してください。以下の動的アドレスに関する警告は、ファイアウォールの許可リストに関するものであり、同じ適用レイヤーの話ではありません。パケットのドロップ診断に戻る前に、その参照先に記載されている、管理者が制御する修正と公開利用者による検証を行ってください。

接続試行がリアルタイムで到達する様子を見るには、クライアントから接続を再試行している間にホスト上でこれを実行してください。これはその瞬間に開いているソケットだけを表示するため、すでに失敗して切断された接続試行については何も分かりません。

ss -tn | grep ':22 '
  • エントリーがある → パケットは到達しており、sshd が接続を閉じています。サーバー側の問題(MaxStartups の枯渇、AllowUsers/DenyUsers、hosts.deny、ディスク容量不足、フォークの失敗)です。トンネルの問題ではなく、このスキルの対象外です。
  • 試行に対応するエントリーがない → 「パケットがまったく到達しなかった」と結論づけないでください。 ジャーナルエントリーがゼロであることから分かるのは、sshd に到達したものが何もなかったということだけです。ホストローカルのパケットフィルターでも、ネットワークが何も配送しなかった場合と同じ結果になります。両者の対処法は正反対で、違いはコマンド1つで判別できます。
# on the host, while the client retries; needs root
timeout 20 tcpdump -nni any "tcp port 22 and host <your-egress-ip>" -c 10
ss -tan '( sport = :22 )'          # look for SYN-RECV, not just LISTEN
  • SYN が tcpdump に表示されるのに、ss には LISTEN だけが表示され、SYN-RECV がない → パケットは到達しており、ホストローカルのファイアウォールがソケット層に届く前にドロップしています。ネットワーク経路には問題ありません。トンネルを調べるのはやめて、以下のホストファイアウォールの確認に進んでください。
  • tcpdump に SYN がまったく表示されない → これでようやく「到達しなかった」と言えます。障害箇所はホストの手前にあります。クラウドのファイアウォール/ネットワーク ACL、または自身の TUN です。以下を続けてください。

⚠️ SYN の再送(同じシーケンス番号が毎秒表示される)は、応答のないドロップを示す決め手です。拒否された接続では代わりに RST が返り、閉じているポートでは即座に1つ返ります。

何も到達しなかった場合は、許可リストと実際の送信元 IP を照合してください。 TUN の下では、curl ifconfig.me が返すのはプロキシの出口 IP であり、接続先のファイアウォールから見えるアドレスではありません。信頼できる値は、自身の API 呼び出しに対してプロバイダーが記録した送信元 IP です。

# Alibaba Cloud example. Do NOT grep for '"sourceIpAddress":"' — the CLI pretty-prints with a
# space after the colon, so that pattern silently matches nothing. Parse the JSON instead.
aliyun actiontrail LookupEvents --RegionId <region> --MaxResults 20   | python3 -c 'import json,sys,re; d=json.load(sys.stdin);       print("\n".join(sorted({e.get("sourceIpAddress","") for e in d.get("Events",[])
            if re.fullmatch(r"[0-9.]+", e.get("sourceIpAddress",""))})))'

IP フィルターは重要です。このフィールドの内部サービス間イベントには、あなたのアドレスではなくホスト名(例:rmc.resourcemanager.aliyuncs.com)が含まれます。

⚠️ これは、プロバイダー API のトラフィックとブロックされている接続が同じ転送経路を通る場合に限り、信頼できる情報です(どちらも DIRECT、またはどちらもプロキシ経由)。ステップ 2J が重要なのはこのためです。TUN はルールに基づいてルーティングするため、プロバイダーの API ドメインと接続先ホストが異なる経路を通り、出口も異なる場合があります。その場合、記録された IP は接続先のファイアウォールから見える IP ではありません。照合結果を信頼する前に、両方が共通のルールに一致していることを確認してください。

通常、許可リストは2つあり、片方を修正しただけでは問題は解決しません。 クラウドホストでは、一般にプロバイダーのセキュリティグループ(マシンの外側)と、ホスト自身のファイアウォール(ufw、firewalld、生の iptables/nftables)の2か所でポートを制御しています。これらは別々のツールで編集され、個別に状態が変化するため、一方に追加したルールはもう一方には反映されません。必ず両方を確認してください。

# outside the machine — provider security group (Alibaba Cloud shown)
aliyun ecs DescribeSecurityGroupAttribute --RegionId <region> --SecurityGroupId <sg-id>

# inside the machine, via the out-of-band channel — host firewall
ufw status verbose 2>/dev/null            # Debian/Ubuntu, if ufw is in use
firewall-cmd --list-all 2>/dev/null       # RHEL family
iptables -S 2>/dev/null | head -30        # raw rules, and the INPUT policy
nft list ruleset 2>/dev/null | head -60
  • 現在の送信元 IP がどちらのリストにもありません → それが修正すべき箇所です。そのレイヤーに追加してください(セキュリティグループ:aliyun ecs AuthorizeSecurityGroup、取り消しは RevokeSecurityGroup。ufw allow from <ip> to any port <n> proto tcp、取り消しは ufw delete allow ...)。動的な家庭用またはモバイルのアドレスに固定された許可リストは、気づかないうちに古くなります。ルールは正しく見えたままで、アドレスが変わった日にもログは記録されません。
  • セキュリティグループにはありますが、パケットが依然として sshd に届きません → これは、前の箇条書きの以前の表現が誤っていたケースです。ブロッカーがローカルにあるという意味ではありません。まず、上記の tcpdump と ss の組み合わせを実行してください。SYN が到着しているのに SYN-RECV がなければ、ホストのファイアウォールが通信を破棄しています。自分のマシンでどれだけ作業しても状況は変わりません。tcpdump で何も到着していないことを確認できた場合に限り、ブロッカーはローカルにあります。以下に進んでください。

⚠️ サービスレベルのツールだけでなく、まず iptables -S を確認してください。 デフォルトの -P INPUT DROP と 80/443 への明示的な許可ルールがあると、このスキルが他の箇所で確認するあらゆる点では正常なマシンに見えます。sshd はアクティブで 0.0.0.0:22 をリッスンし、設定も有効で、fail2ban はなく、hosts.deny は空で、ディスクとメモリにも問題ありません。それでも SSH パケットはすべて破棄されます。パケットフィルターを確認せずに fail2ban と hosts.deny を調べることは、ブロックされているホストを「完全に正常」と判定してしまう最も簡単な方法です。

自分のマシンを疑う前に、別の接続元からも確認してください。 同じ送信元を共有していて(同じ LAN 上の別のマシン、スマートフォンのテザリング、同僚のノートパソコンなど)、ネットワークスタックは共有していないホストがあれば、そこから同じプローブを実行してください。「自分の問題か、相手側の問題か」を切り分けるための最も安価な対照試験であり、TUN を経由しないため、TUN に結果を歪められることもありません。結果は 2 通りで、それぞれ意味が異なります。

  • そのクリーンなホストでも失敗し、送信元 IP があなたのものと一致します → 宛先があなたを拒否しています。トンネルは問題ではありませんでした。
  • クリーンなホストでは成功します → ブロッカーは本当にローカルにあり、さらに利用可能な経路も確保できました(ssh -J <clean-host> <destination> で TCP を中継できます。鍵は自分のマシンに置いたままです)。

2 つ目の接続元は、別のホストでなくてもかまいません。別のネットワークでも構いません(スマートフォンのテザリング、別の WiFi など)。2026-09-24:カフェの WiFi が JMS のサブスクリプションドメインとメインサイト、さらにすべてのノードをブラックホール化しました(2 時間にわたり、TCP 接続はすべてタイムアウト)。「プロバイダーが GFW にブロックされている」状態と見分けがつかず、すべての自己修復レベルが失敗しました。どの修復アクションも同じ会場の送信元を経由していたためです。WiFi を切り替えると、設定を一切変えずにすべて復旧しました。会場を変える実験は 2 分でできます。1 か所からのプローブだけで「プロバイダーが停止している/ブロックされている」と結論付ける前に試してください。

TUN に横取りさせず、プロキシに明示的に中継させて、ローカルのブロッカーを確認します。

ssh -o ProxyCommand="nc -X connect -x 127.0.0.1:<proxy-port> %h %p" <host>
  • HTTP/1.1 503 Service Unavailable(または SOCKS error 1)→ プロキシはそのポートの中継を拒否しています。22 番ポートでよくあるケースです。多くのルールセットでは、HTTP/HTTPS 用のポートだけが許可されています。
  • 接続できます → プロキシはこの通信を運べるため、ブロッカーは TUN による自動的な横取りに限定されます。以下の修正方法 1 が、この問題に対する対処法です。

修正方法は 2 つあります。

  1. TUN ツールで、そのホスト/ポートに DIRECT ルールを追加してから再テストしてください。これは他人のプロキシ設定を変更することになります。自分のものでない場合は、先にマシンの所有者の同意を得てください。
  2. 無人で実行するものには、SSH から切り替えて、たった今使った帯域外チャネルに自動化を移すほうが適切です。これなら、許可リストの陳腐化とプロキシによるポート拒否の両方の障害モードを乗り越えられ、ローカルのネットワーク状態にも依存しません。
Step 2L:プロバイダーレベルの障害(同じプロバイダーのすべてのノードが同じように停止する)

症状:あるプロバイダーのすべてのノードで同じ兆候が見られます。TCP 接続は成功しますが、TLS ハンドシェイクが完了しません(time_appconnect=0.000)。ローカルのプロキシアプリとパケットトンネル拡張機能の両方を再起動しても、状況は変わりません。プロキシ経由では、海外の接続先がすべて同時に失敗しますが、DIRECT ルールのトラフィックは引き続き動作します。

この問題に独立した手順が必要な理由:この症状は Step 2H/2J(ローカル TUN の異常動作)や Step 2K(特定の接続先のファイアウォール問題)に見えますが、障害が発生しているのはプロバイダーのデータプレーンです。ローカルで修復しても効果はなく、Step 2K の接続場所のテストでも問題はすでに切り分けられています。接続場所の問題なら通常の TCP 接続も失敗しますが、ここでは成功します。ローカルでできる唯一の解決策は、別のプロバイダーのノードを使うことです。

約 10 秒で TUN を迂回するプローブを使って確認します(--interface en0 と --resolve を指定し、dig @223.5.5.5 で実 IP を取得します)。

  1. ベースラインの確認:同じ物理経路で、関係のない TLS ホストから応答がある(例:api.github.com が 200 を返す)→ 回線と汎用 TLS は正常です。
  2. 同一プロバイダー内での共通性:同じサブスクリプションのノードが 2 つ以上、同一の TLS 停止を示す → 特定のノードではなく、プロバイダーレベルの問題です。
  3. コントロールプレーン:サブスクリプションのドメインに直接接続します。521(Cloudflare のエッジは稼働中、オリジンは停止中)は、プロバイダーが自社の障害を示しています。200/302/404 の場合はコントロールプレーンが稼働しており、データプレーンのみの障害です。

修正方法:ロールバックできるよう、現在選択中のノードを記録します。選択を切り替える前に、別のサブスクリプションの候補ノードを同じ方法でプローブします(time_appconnect が 0 以外である必要があります)。その後、切り替えます。2026-10-22 に確認した事例では、あるプロバイダーのサブスクリプションのオリジンが 521 を返し、そのプロバイダーの VLESS ノード 5 つすべてで TLS が停止していました。一方、2 つ目のサブスクリプションのレジデンシャル IP 回線は正常で、選択後数秒で通信が復旧しました。2 つ目のサブスクリプションがない場合、正直な状況は「プロバイダーが停止中 — 待機」です。ローカルアプリの再起動を繰り返さないでください。また、この兆候だけで「GFW がプロバイダーをブロックした」と結論づけないでください(プロバイダーのコア障害とプロトコルレベルのブロックは、クライアント側からは区別できません。ただし、どちらも同じ切り替えで対処できます)。 ノードの復旧・停止を判断するには、中間層ではなくデータプレーンで判定する必要があります:TLS/Reality から再び応答があっても、復旧したとは限りません。また、切り替え直後にブラウザーで一度失敗しただけでは、停止したとは判断できません(再接続中、クライアントはすべての通信を 1~2 秒間ブラックホールに送ります。5~10 秒待ってください)。合否を判定するプローブは、そのノード経由の url-test です。ユーザーが現在使用中の通信経路に触れずに実行するには、delay API を備えた 2 つ目のローカルクライアントコアを使います。references/node-liveness-verification.md(Shadowrocket の認証情報抽出、mihomo テンプレート、コントロールグループの調整、実測で確認した落とし穴)を参照してください。

Step 3:プロキシツールの設定を修正する

プロキシツールを特定し、そのツールに適した修正を適用します。各ツールの詳しい手順は references/proxy_conflict_reference.md を参照してください。

重要な原則:100.64.0.0/10 を除外するために tun-excluded-routes を使用しないでください。これにより、プロキシが → en0 ルートを追加し、Tailscale のルートを上書きします。代わりに、トラフィックをプロキシ TUN に通し、DIRECT ルールで通過させてください。

共通の修正方法 — このルールを任意のプロキシツールに追加します。

IP-CIDR,100.64.0.0/10,DIRECT
IP-CIDR,fd7a:115c:a1e0::/48,DIRECT

修正を適用した後、次のように確認します。

route -n get <tailscale-ip>
# Should show Tailscale utun interface, NOT en0
Step 4:Tailscale SSH ACL を設定する

SSH 接続は成功するものの operation not permitted が返される場合、Tailscale ACL で接続のたびにブラウザー認証が必要になっている可能性があります。

Tailscale ACL admin で、SSH セクションに "action": "accept" が設定されていることを確認します。

"ssh": [
    {
        "action": "accept",
        "src": ["autogroup:member"],
        "dst": ["autogroup:self"],
        "users": ["autogroup:nonroot", "root"]
    }
]

注:"action": "check" では、接続のたびにブラウザー認証が必要です。非対話型の SSH アクセスを行うには、"accept" に変更してください。

ステップ 5:WSL の Tailscale インストールを修正する

SSH 接続と ACL の通過はできるものの、tailscaled のログで be-child ssh の終了コード 1 により失敗する場合、snap 版の Tailscale によるサンドボックス制限で SSH シェルを実行できません。

診断 — WSL の tailscaled ログを確認します。

# For snap installs:
sudo journalctl -u snap.tailscale.tailscaled -n 30 --no-pager

# For apt installs:
sudo journalctl -u tailscaled -n 30 --no-pager

次の内容を探します。

access granted to [email protected] as ssh-user "username"
starting non-pty command: [/snap/tailscale/.../tailscaled be-child ssh ...]
Wait: code=1

修正 — snap 版を削除し、apt 版をインストールします。

# Remove snap version
sudo snap remove tailscale

# Install apt version
curl -fsSL https://tailscale.com/install.sh | sh

# Start with SSH enabled
sudo tailscale up --ssh

重要:新しいインストールでは、Tailscale の IP アドレスが変わる場合があります。tailscale status --self で確認してください。

ステップ 5A:WSL での Tailscale SSH プロキシの無応答を修正する

症状:TCP ポート 22 に到達できます(nc -z -w 5 <ip> 22 が成功します)が、SSH は直ちに次のエラーで失敗します。

kex_exchange_identification: Connection closed by remote host

SSH バナーは一切受信されません。apt 版の Tailscale(snap 版ではありません)を使用している場合にも発生します。

根本原因:WSL で tailscale up --ssh を有効にすると、Tailscale はカーネルのネットワークスタックより上のアプリケーション層で、ポート 22 への接続をインターセプトします。Tailscale 組み込みの SSH プロキシが正しく動作しない場合、TCP 接続を受け付けた直後に SSH バナーを送信する前に切断します。

主な診断方法 — WSL インスタンスで次を実行します。

# This will show 0 packets even during active SSH attempts
sudo tcpdump -i any port 22 -c 5 -w /dev/null 2>&1

パケットが 0 件の場合、カーネルのネットワークスタックに到達する前に Tailscale が接続をインターセプトしています。カーネルの sshd は接続を認識しません。

ステップ 5 との違い:ステップ 5 では、be-child ssh が失敗する snap のサンドボックスの問題を扱っています。これは別の問題で、インストール方法に関係なく、Tailscale の SSH プロキシ自体が無応答になります。

修正 — Tailscale の SSH プロキシを無効にし、通常の sshd を使用します。

# On the WSL instance:
sudo tailscale up --ssh=false

# Verify sshd is running
sudo service ssh status
# If not running:
sudo service ssh start

# Verify from the client machine:
ssh -o ConnectTimeout=10 <user>@<tailscale-ip> 'echo SSH_OK'

Tailscale SSH を無効にすると、接続は通常どおりカーネルのネットワークスタックを通って sshd に届きます。ステップ 4 の Tailscale ACL "action": "accept" は適用されなくなり、SSH キーまたはパスワードを使って sshd が認証を処理します。

--ssh を有効にしておく場合:ACL ベースのアクセス制御や SSH キー管理が不要になるなど、Tailscale の SSH 機能が特に必要な場合に限ります。標準の sshd が動作する場合は、信頼性を優先して --ssh=false をおすすめします。

ステップ 5B:macOS で App Store 版 Tailscale を修正する(tailscale ssh が見つからない場合)

症状:tailscale ssh を実行すると、次のエラーが返されます。

The 'tailscale ssh' subcommand is not available on macOS builds
distributed through the App Store or TestFlight.

根本原因:macOS 用の App Store 版 Tailscale はサンドボックス化されており、tailscale ssh サブコマンドが含まれていません。

修正 — スタンドアロン版をインストールします。

  1. App Store 版をアンインストールします(/Applications から削除します)
  2. https://pkgs.tailscale.com/stable/#macos からスタンドアロン版をダウンロードします
  3. /Applications にインストールします

インストール後の CLI 設定:スタンドアロン版の tailscale CLI バイナリはアプリバンドル内に含まれています。シェル設定にエイリアスを追加します。

# ~/.zshrc
alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"

次のコマンドで確認します。

source ~/.zshrc
tailscale version
tailscale ssh <user>@<hostname>   # Should work now
ステップ 5C:Windows ホストの TUN プロキシがマシン全体を停止させる(WSL とその Tailscale を含む)

症状:Windows + WSL2 のワークステーションで、国内・海外、ホスト・WSL のすべてが一度にオフラインになり、Tailscale も接続できなくなります(「Tailscale までダウンしています」)。復旧するのは NIC を切り替えたときではなく、ホストの TUN プロキシ(v2rayN sing-box TUN、Clash TUN)をオフにしたときです。

根本原因の概要:WSL2 NAT には独自のネットワーク経路がありません。ホストの TUN がデフォルトルートを所有しているため、プロキシノードが停止すると、ホストとその背後にあるすべてのものがブラックホール化し、WSL の tailscaled のコントロールプレーン接続も失われます。これは macOS のスプリットブレイン(ステップ 2J)とは異なり、一方だけではなく、両方のプレーンが停止します。

このシナリオには独自のリファレンスがあり、この SKILL.md では繰り返さない以下の 4 点を解説しています。複数の復旧操作のうち、どれが実際にネットワークを直したのかを証明するイベントログの時系列分析方法(Microsoft-Windows-NetworkProfile/Operational のイベント 10000/10001 に TUN アダプター名が記録されます。例:singbox_tun)、診断中に偽の「出力なし」をひそかに引き起こす WSL→Windows 相互運用の 3 つの落とし穴(PATH、GBK コードページ、TTY なしでのパイプ切断)、片方だけが実際にオンラインの場合に Windows 側と WSL 内の tailscaled を見分ける方法(Tailscale アダプター上の APIPA 169.254.x.x はエンジンが起動していないことを示します)、そして予防策(ホスト TUN の代わりに WSL からプロセス単位のプロキシを使う方法)です。

この状況を診断する前に、references/windows_host_tun_wsl_cascade.md をお読みください。

ステップ 6:エンドツーエンドで検証する

完全な接続テストを実行します。

# 1. Check route is correct (must show Tailscale's utun, not en0 or Shadowrocket's utun)
route -n get <tailscale-ip>
# Also confirm which utun is Tailscale's:
ifconfig | grep -A2 'inet 100\.'

# 2. Test TCP connectivity
nc -z -w 5 <tailscale-ip> 22

# 3. Test SSH
ssh -T -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes <approved-ssh-alias> 'echo SSH_OK && hostname && whoami'

選択したオンライン状態のピアに対して、承認済みのエイリアスを解決します。アドレスファミリーを変更する際は、同一ピアでの SSH 復旧の手順に従い、検証済みのホストキー ID を維持してください。ホストキーの確認を無効にしないでください。キーが不明、または変更されている場合は、続行する前に別の手段で ID を確認する必要があります。

3 つすべてに合格する必要があります。ステップ 1 が失敗した場合は、ステップ 3 を再確認してください。ステップ 1 で誤った utun が表示された場合(例:Tailscale の MTU 1280 のものではなく、MTU 4064 の Shadowrocket の utun)、これもルート競合です。ステップ 2 は成功するのにステップ 3 が kex_exchange_identification で失敗する場合は、ステップ 5A(Tailscale SSH プロキシによるインターセプト)を再確認してください。ステップ 2 が失敗する場合は、WSL の sshd またはファイアウォールを確認してください。ステップ 3 がその他のエラーで失敗する場合は、ステップ 4〜5 を再確認してください。

DNS 関連の修正(ステップ 2I)では、上記の 3 つのステップだけでは不十分です。システム DNS の復旧を確認できないためです。代わりにステップ 2I の最後にある 4 つの観点で検証してください。デーモンの状態、リゾルバーごとの dig、dscacheutil、そして回避策を一切使わずに実行した、元の失敗コマンドです。

SOP:Tailscale 経由のリモート開発

プロキシツールを使って Tailscale 経由でリモート開発するための事前設定ガイドです。問題が発生する前に、以下の手順に従ってください。

前提条件
  • 両方のマシンに Tailscale がインストールされ、実行されていること
  • プロキシツール(Shadowrocket/Clash/Surge)が Tailscale と互換性のある設定になっていること(上記のステップ 3 を参照)
  • SSH 接続が機能していること:ssh <tailscale-ip> 'echo ok'
1. プロキシの影響を受けない Makefile パターン

localhost に対して curl を実行する Makefile ターゲットでは、必ず --noproxy localhost を使ってください。http_proxy は ~/.zshrc でグローバルに設定されていることが多く(中国ではよくある設定です)、Make がシェルの環境変数を引き継ぐため、これが必要です。

## ── Health Checks ─────────────────────────────────────

status:                ## Health check dashboard
	@echo "=== Dev Infrastructure ==="
	@docker exec my-postgres pg_isready -U postgres 2>/dev/null && echo "PostgreSQL: OK" || echo "PostgreSQL: FAIL"
	@curl --noproxy localhost -sf http://localhost:9000/minio/health/live >/dev/null 2>&1 && echo "MinIO: OK" || echo "MinIO: FAIL"
	@curl --noproxy localhost -sf http://localhost:3001/api/status >/dev/null 2>&1 && echo "API: OK" || echo "API: FAIL"

## ── Route Warmup ──────────────────────────────────────

warmup:                ## Pre-compile key routes (run after dev server is ready)
	@echo "Warming up dev server routes..."
	@echo -n "  /api/health → " && curl --noproxy localhost -s -o /dev/null -w '%{http_code} (%{time_total}s)\n' http://localhost:3010/api/health
	@echo -n "  /            → " && curl --noproxy localhost -s -o /dev/null -w '%{http_code} (%{time_total}s)\n' http://localhost:3010/
	@echo "Warmup complete."

ルール:

  • curl http://localhost を呼び出す場合は、必ず --noproxy localhost を指定してください
  • Docker コマンド(docker exec)は http_proxy の影響を受けないため、修正は不要です
  • redis-cli と pg_isready は TCP で直接接続するため、修正は不要です
2. SSH トンネル用 Makefile ターゲット

Tailscale の SSH トンネル経由でリモート開発を行うため、次のターゲットを追加します。

## ── Remote Development ────────────────────────────────

REMOTE_HOST    ?= <tailscale-ip>
TUNNEL_FORWARD ?= -L 3010:localhost:3010

tunnel:                ## SSH tunnel to remote machine (foreground)
	ssh -N $(TUNNEL_FORWARD) $(REMOTE_HOST)

tunnel-bg:             ## SSH tunnel to remote machine (background, auto-reconnect)
	autossh -M 0 -f -N $(TUNNEL_FORWARD) \
		-o "ServerAliveInterval=30" \
		-o "ServerAliveCountMax=3" \
		-o "ExitOnForwardFailure=yes" \
		$(REMOTE_HOST)
	@echo "Tunnel running in background. Kill with: pkill -f 'autossh.*$(REMOTE_HOST)'"

設計上の判断:

選択肢理由
?=(条件付き代入)上書きできます:make tunnel REMOTE_HOST=100.x.x.x
変数としての TUNNEL_FORWARD複数ポートに対応します:make tunnel TUNNEL_FORWARD="-L 3010:localhost:3010 -L 9000:localhost:9000"
autossh -M 0autossh 独自の監視ポートを無効にし、代わりに ServerAliveInterval を使用します(NAT 経由ではこちらのほうが信頼性が高いためです)
ExitOnForwardFailure=yesポートがすでにバインドされている場合、トンネルを確立せずに黙って実行を続けるのではなく、すぐに失敗します
終了方法のヒントに autossh.*$(REMOTE_HOST) を使用パターンを絞り込むため、ほかの SSH セッションを誤って終了することはありません

autossh のインストール:brew install autossh(macOS)または apt install autossh(Linux/WSL)

3. 複数ポートのトンネル

プロジェクトで複数のサービス(開発サーバー+オブジェクトストレージ+API ゲートウェイ)が必要な場合:

# Forward multiple ports in one tunnel
make tunnel TUNNEL_FORWARD="-L 3010:localhost:3010 -L 9000:localhost:9000 -L 3001:localhost:3001"

# Or define a project-specific default in Makefile
TUNNEL_FORWARD ?= -L 3010:localhost:3010 -L 9000:localhost:9000

各 -L フラグは独立しています。ローカルでいずれかのポートがすでにバインドされていると、ExitOnForwardFailure=yes によってトンネル全体が中断されます。まずポートの競合を解消してください。

4. SSH の非ログインシェルの設定

「対話型では動くのに、スクリプトでは失敗する」問題が頻繁に発生する原因です。 SSH の非ログインシェルでは ~/.zshrc(Linux の場合は ~/.bashrc)が読み込まれないため、nvm、Homebrew、uv、cargo、その他シェルレベルのマネージャーを使ってインストールしたツールは $PATH に含まれません。~/.zshrc に設定したプロキシ環境変数も読み込まれません。

これは、CI/CD パイプライン、cron で起動する SSH、Makefile のリモートターゲットなど、ssh user@host "command" を使って実行するすべてのリモートコマンドに影響します。すべてのリモートコマンドの前に、macOS では source ~/.zshrc 2>/dev/null;、Linux/WSL では source ~/.bashrc 2>/dev/null; を付けてください。

よくある失敗例:対話型 SSH セッションではコマンドが動くのに、ssh user@host "uv run ..." や ssh user@host "node ..." を実行すると command not found が返されます。

詳細と例については、references/proxy_conflict_reference.md § SSH の非ログインシェルの落とし穴を参照してください。

リモートコマンドを実行する Makefile ターゲットでは:

REMOTE_CMD = ssh $(REMOTE_HOST) 'source ~/.zshrc 2>/dev/null; $(1)'

remote-status:         ## Check remote dev server status
	$(call REMOTE_CMD,curl --noproxy localhost -sf http://localhost:3010/api/health && echo "OK" || echo "FAIL")
5. エンドツーエンドのワークフロー
初回のセットアップ(リモートマシン)
# 1. Clone repo and install dependencies
ssh <tailscale-ip>
cd /path/to/project
git clone [email protected]:user/repo.git && cd repo
pnpm install  # Add --registry https://registry.npmmirror.com if in China

# 2. Copy .env from local machine (run on local)
scp .env <tailscale-ip>:/path/to/project/repo/.env

# 3. Start Docker infrastructure
make up && make status

# 4. Run database migrations
bun run db:migrate

# 5. Start dev server
bun run dev
日常のワークフロー(ローカルマシン)
# 1. Start tunnel
make tunnel-bg

# 2. Open browser
open http://localhost:3010

# 3. Auth, coding, testing — everything works as if local

# 4. When done, kill tunnel
pkill -f 'autossh.*<tailscale-ip>'
これで動作する理由
Browser → localhost:3010 → SSH tunnel → Remote localhost:3010 → Dev server
                                     ↓
                              Auth redirects to localhost:3010
                                     ↓
                              Browser follows redirect → same tunnel → works

重要なポイントは、.env の APP_URL=http://localhost:3010 が、ローカル開発とリモート開発の両方で正しいことです。SSH トンネルによって、リモートサーバーの localhost にローカルマシンの localhost からアクセスできるようになります。そのため、認証コールバックは常に localhost:3010 へ正しくリダイレクトされます。

6. チェックリスト

リモート開発を始める前に、次の項目を確認してください。

  • Tailscale に接続済み:tailscale status
  • SSH が動作する:ssh <tailscale-ip> 'echo ok'
  • プロキシツールを設定済み:[Rule] に IP-CIDR,100.64.0.0/10,DIRECT がある
  • skip-proxy に 100.64.0.0/10 が含まれている
  • tun-excluded-routes に 100.64.0.0/10 が含まれていない
  • NO_PROXY に .ts.net,100.64.0.0/10 が含まれている
  • autossh をインストール済み:which autossh
  • Makefile の curl コマンドに --noproxy localhost がある
  • リモート開発サーバーが起動している:ssh <ip> 'source ~/.zshrc 2>/dev/null; curl --noproxy localhost -sf http://localhost:3010/'
  • トンネルが動作する:make tunnel-bg && curl -sf http://localhost:3010/

参考資料

  • references/proxy_node_chain_throughput.md — 単一ホップおよびプロキシチェーンの処理能力の診断、ノードを直列に接続した場合の比較、実際のクライアントによる検証、実際のワークロード全体の再生
  • references/node-liveness-verification.md — データプレーンの url-test のみを稼働状況の判定基準とする方法、ユーザーが現在使用中のチャネルに触れずに任意のノードの復旧を検証する方法(mihomo delay API、コントロールグループの調整、実測で判明した落とし穴)
  • references/proxy_conflict_reference.md — ツールごとの設定(Shadowrocket、Clash、Surge)、NO_PROXY の構文、SSH ProxyCommand、競合のアーキテクチャ
  • references/windows_host_tun_wsl_cascade.md — Windows ホストの TUN 障害が WSL とその Tailscale に波及する仕組み:イベントログの時系列分析、WSL→Windows の相互運用における落とし穴、2 つの tailscaled の判別、予防策

コメント