本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

作業の途中で、1Password CLI(op)だけが突然使えなくなりました。

[ERROR] could not read secret 'op://…': error initializing client:
Get "https://my.1password.com/api/v2/account/keysets?…":
write tcp [fe80::…%utun4]:62888->[2600:1f10:…]:443: write: socket is not connected

同じ端末から、同じタイミングで、別のサーバには curl で普通に繋がります。ネットワークが落ちているわけではありません。数分前までは op も動いていました。

結論は、VPN クライアントが IPv6 のデフォルト経路を張っているのに、その経路が実際には IPv6 を通していない、というものでした。以下は切り分けの記録です。

同じ症状は特定の製品に限りません。トンネル内でグローバルな IPv6 アドレスを配らないまま IPv6 のデフォルト経路だけを設定する VPN であれば、同様のことが起こり得ます。自分が該当するかは、VPN に接続した状態で次の 2 つを見ると判定できます。

ifconfig utun4 | grep inet6           # fe80:: しか出てこないか
route -n get -inet6 default | grep interface   # その utun を返すか

utun4 の番号は環境によって変わります。2 つ目のコマンドが返したインターフェース名に読み替えてください。

症状の切り分け

エラーメッセージの宛先が [2600:1f10:…] と IPv6 アドレスになっている点が手がかりでした。プロトコルを固定して、同じ宛先に両方から当ててみます。

curl -4 -sS -o /dev/null -m 8 -w '%{http_code}\n' https://my.1password.com/api/v2/health
curl -6 -sS -o /dev/null -m 8 -w '%{http_code}\n' https://my.1password.com/api/v2/health
宛先IPv4IPv6
my.1password.com404(サーバまで届いている)タイムアウト
別のサービス200タイムアウト

IPv4 では届き、IPv6 では宛先を問わずタイムアウトします。404 が返るのは「そのパスが無い」という応答なので、サーバまで到達している証拠として使えます。

経路表を見ると、IPv6 のデフォルト経路が VPN のインターフェース(utun)に向いていました。

$ netstat -rn -f inet6 | grep '^default'
default    link#30    UGCSg    utun4

つまり、IPv6 宛のパケットは VPN のトンネルに吸い込まれ、その先で行き止まりになっていました。経路自体は存在するので、OS は「IPv6 が使える」と判断して送り出します。

ただし、この grep だけで VPN を犯人と決めるのは早すぎます。VPN を切った状態でも utun 向きの default 行は並ぶからです(インターフェース限定の経路で、フラグに I が付きます)。グローバルな経路があるかどうかは、こちらのほうが確実でした。

route -n get -inet6 default

経路が返ればグローバルな IPv6 デフォルト経路が存在し、無ければ not in table と言われます。

なぜ curl は平気だったのか

同じ宛先なのに片方のツールだけ失敗する理由を示した図。左上に op(1Password CLI)の箱があり「接続後の write で socket is not connected」と書かれ、そこから「IPv6 経路へ」の矢印が中央上の「IPv6 の経路(VPN が張った default)」の箱へ伸びている。その右には大きなバツ印と「経路はあるが通らない」の注記がある。左下には curl の箱があり「IPv6 が沈黙したら 200ms 後に IPv4 も試す」と書かれ、そこから2本の矢印が出ている。1本は破線で中央上の IPv6 の経路へ向かい「① まず IPv6」、もう1本は実線で中央下の「IPv4 の経路(物理インターフェース)」へ向かい「② 繋がらないので IPv4」とラベルされている。IPv4 の経路からは右の「宛先サーバ(IPv4/IPv6 両対応)」へ矢印が届いている。図の下部に、VPN クライアントが IPv6 の default 経路を張るがその経路は実際には IPv6 を通していないこと、IPv6 の接続が沈黙すれば IPv4 に切り替わるが接続が成立したことになってから壊れると切り替わらないこと、ネットワークは生きているのに一部のツールだけ失敗するときはこの割れ方を疑うこと、が記されている。

名前解決自体は動いていて、どちらのツールも AAAA レコード(IPv6 アドレス)を受け取っています。冒頭のエラーに宛先として IPv6 アドレスが写っていることが、その証拠です。違いは、その IPv6 での接続がどう失敗したかにありました。

curl は Happy Eyeballs(RFC 6555。改訂版が RFC 8305)の挙動を持ちます。IPv6 の接続確立を待ち、間に合わなければ IPv4 も並行して試すもので、curl の既定の頭出しは 200 ミリ秒です。IPv6 が沈黙しても IPv4 に落ちて成功するので、利用者からは何事もなかったように見えます。

では op は IPv4 に切り替えない実装なのか、というと、そう単純でもありません。op は Go で書かれており(go version -m $(command -v op) で確認できます)、Go の標準ライブラリの dialer は 既定で Fast Fallback(RFC 6555)が有効です。「フォールバックを実装していないから失敗した」という説明は、この既定と噛み合いません。

手がかりはエラーメッセージのほうにありました。curl -6 の失敗はタイムアウト、つまり接続が確立しないまま沈黙する形です。一方 op の失敗は write: socket is not connected で、接続が成立したことになってから書き込みで壊れています。Happy Eyeballs は接続確立が沈黙したときに働く仕組みなので、この壊れ方は原理的に救えません。送信元が fe80::…%utun4 とリンクローカルアドレスになっている点も、グローバル宛先に出す送信元として異常です。

そこから先(なぜ経路によって沈黙と即時エラーに分かれたのか)は追い切れていません。確実に言えるのは、IPv4 への切り替えは万能ではなく、接続が成立したことになってから壊れる経路では働かない、というところまでです。

この非対称性が、切り分けを難しくします。手元の確認手段(curl でヘルスチェック、ブラウザで開いてみる)がことごとく成功するので、「ネットワークは正常」という誤った結論に落ち着きやすい状態でした。

稼働している VPN 製品を取り違えた

原因の見当をつける段階で、一度間違えました。

scutil --nc list

この一覧に VPN 製品 A の名前が出ていたので、そちらを疑いました。しかし行末は (Disconnected) で、繋がっていませんでした。実際に稼働していたのは別の製品 B で、こちらは scutil の一覧に現れません。自前でインターフェースを管理する実装だと、この一覧には出てこないようです。

確実なのは、アドレスが付いている utun インターフェースと、プロセスを見ることでした。

for i in $(ifconfig -l | tr ' ' '\n' | grep '^utun'); do a=$(ifconfig $i | grep -E '^\s+inet ' | awk '{print $2}'); [[ -n $a ]] && echo "$i: $a"; done
ps -axo comm | grep -i vpn | sort -u
route -n get default | grep interface

今回はプロセス名に vpn を含んでいたので拾えましたが、含まない製品もあります。最終的な判断はアドレスが付いた utun と、デフォルト経路がどのインターフェースに向いているかで行いました。VPN 製品が複数入っている端末では、一覧の表示ではなく、経路とインターフェースとプロセスの3点で見るのが確実です。

「IPv6 を切る」は効かないことがある

対処として、端末の IPv6 を無効化することを検討しました。ところが確認すると、Wi-Fi の IPv6 設定は既に「オフ」でした。

networksetup -getinfo "Wi-Fi" | grep '^IPv6:'
ifconfig en0 | grep -E '^\s+inet6? '

物理インターフェースには IPv6 アドレスが 1 つも付いていません。それでも IPv6 のデフォルト経路が存在したのは、VPN が張ったものだけだったからです。networksetup が触るのはネットワークサービスの設定で、VPN クライアントが動的に作る utun の経路には及びません(VPN 構成自体がサービス一覧に出ることはありますが、経路は別管理です)。

つまりこの状況では、networksetup -setv6off "Wi-Fi" を実行しても何も変わりません。結局、VPN を一時的に切って作業を進めました。

一般論として端末の IPv6 を切ることの影響も整理しておくと、次のようになります。

  • 国内のデュアルスタック環境では体感差はほぼありません。IPv4 over IPv6(IPoE 接続や MAP-E など、IPv6 網の上で IPv4 の通信を運ぶ方式)はルータ側の処理なので、端末側の設定とは別
  • 影響が出るのは IPv6 のみのネットワーク(IPv4 宛の通信を変換して中継する NAT64/DNS64 の仕組みを使う、一部のモバイル回線や施設内 Wi-Fi)。渡航先で繋がらなくなる可能性があります
  • AirDrop は別インターフェース(awdl0、Apple 端末同士が直接通信するためのもの)のリンクローカル IPv6 上で転送するので、Wi-Fi サービスの IPv6 設定を変えても止まりません(実際、Wi-Fi の IPv6 が Off でも awdl0 には fe80:: が付いたままです)。Handoff も Wi-Fi と Bluetooth の両方を使うので同様です

いずれにせよ、今回のように経路の出どころが VPN であれば、端末側の IPv6 設定をいじっても解決しません。

経路を消しても戻る

その後、VPN を切らずに済ませる方法を探しました。まず思いつくのは、問題の経路を直接消すことです。

sudo route -n delete -inet6 default -interface utun4

一瞬は直ります。 curlop も通るようになります。ところが手元の環境では、しばらくすると経路表に同じ既定経路が戻っていました。この種のクライアントには経路表の変更を監視して自動的に元へ戻す仕組み(route monitoring)を持つものがあり、消しただけでは復元されるようです。再接続のたびに消す必要もあり、運用として続きません。

ネットワークサービス側の設定(networksetup -setv6off)も試しましたが、経路表は変わりませんでした。前述のとおり稼働している製品はネットワークサービスの一覧に現れないので、そもそも指定できる対象が無かったということになります。クライアントが接続時に自分でインターフェースと経路を用意していて、OS のネットワークサービス設定を経由していないようです。

効いた方法 — より具体的な経路で上書きする

経路の取り合いをやめて、経路選択の規則のほうを使います。

経路を消す代わりに、より具体的な経路を置く仕組みを示した図。左に「IPv6 宛の通信(AAAA を持つ相手へ)」の箱があり、そこから「経路選択」というラベルの付いた2本の矢印が中央の2つの候補経路へ伸びている。上の候補は破線の矢印で結ばれた「::/0(既定経路)VPN の utun へ/クライアントが入れ直す」で、右の「吸い込まれて行き止まり/待たされる」に繋がり、その下にバツ印と「こちらは選ばれない」の注記がある。下の候補は実線の矢印で結ばれた「2000::/3(追加した経路)lo0 へ -reject/プレフィックスが長い方が勝つ」で、右の「即座に到達不能(EHOSTUNREACH)」に繋がり、さらに下向きの矢印で「アプリが IPv4 へ切り替わる」に至る。図の下部に、既定経路を削除してもクライアントが監視していて元に戻ることがあること、より長いプレフィックスの経路を置けば入れ直されても選ばれ続けること、-blackhole は黙って捨てるので待たされたままだが -reject は即座にエラーを返すこと、が記されている。

IP の経路選択では、より具体的な(プレフィックス長が長い)経路が優先されます。IPv6 でも同じで、RFC 7608(BCP 198)が longest-match-first を規定しています。既定経路は ::/0 なので、それより長いものを置けば勝ちます。そこで IANA が現在グローバルユニキャストとして配布している 2000::/3 を到達不能として登録します。6to4(2002::/16)も Teredo(2001::/32)もこの内側なので、まとめて塞げます。

sudo route -n delete -inet6 2000::/3 2>/dev/null   # 既にあれば消す(無くてもよい)
sudo route -n add -inet6 2000::/3 ::1 -reject

2 行目だけを既存の経路がある状態で実行すると File exists で失敗するので、入れ直すときは先に削除します。

-blackhole ではなく -reject を使うのが要点です。 -blackhole はパケットを黙って捨てるだけなので、アプリは接続タイムアウトまで待たされ、「遅く壊れる」状態が変わりません。-reject は送信側に即座に EHOSTUNREACH を返すので、アプリが待たずに IPv4 へ移れます。なお ::1 というゲートウェイは形式上必要なだけで、-reject 経路では実際の転送先としては使われません。

これなら、VPN クライアントが既定経路を入れ直しても、そちらより具体的なので効き続けます。この方法はインターフェース名に依存しないので、utun の番号を調べ直す必要もありません。

結果はこうなりました。

$ netstat -rn -f inet6 | grep '^2000::/3'
2000::/3    ::1    UGRSc    lo0

$ curl -6 -s -o /dev/null -w '%{http_code} %{time_total}s\n' --max-time 8 https://my.1password.com/api/v2/health
000 0.004583s

$ op vault list
ID                            NAME
xxxxxxxxxxxxxxxxxxxxxxxxxx    Personal

000 は HTTP 応答が返らなかったことを示します。重要なのは失敗そのものではなく、タイムアウト(8 秒)を待たずに終わっていることです(所要時間は環境により数ミリ〜数百ミリ秒の幅があります。手元の再実行では 0.19 秒でした)。

IPv6 が「接続できたことになってから write で壊れる」から「connect の時点で到達不能を返す」に変わりました。速くなったこと以上に、失敗する層が変わったのが要点です。前半で見たとおり Happy Eyeballs は接続の確立が沈黙・失敗したときに働く仕組みなので、失敗が connect まで前倒しされれば正しく作動します。op が通るようになったことは、Go 側のフォールバックが壊れていたのではなく、与えられた失敗の形が悪かっただけだったことを裏返しに示しています。

2000::/3 はグローバルユニキャストに割り当てられている範囲なので、リンクローカル(fe80::/10)・ULA(fc00::/7)・マルチキャストには触れません。同一 LAN 内の IPv6 通信や AirDrop は影響を受けません。

ただしこの経路は再起動すると消えます(macOS には route の設定を永続化する仕組みが標準で無く、恒久化したいなら LaunchDaemon 等が必要です)。逆に言えば再起動するまでは VPN を切っても残るので、本物の IPv6 を使いたいときは明示的に削除してください。

とくに注意が要るのは、前述の IPv6 のみのネットワーク(NAT64/DNS64 を使うモバイル回線や施設内 Wi-Fi)です。そこに繋ぐと、IPv6 だけでなく通信全体が繋がらなくなります。持ち出す端末では、VPN を切ったら削除する習慣にしておくのが安全です。

もう一点、2000::/3 は「現在 IANA が配布中のグローバルユニキャスト空間」であって、到達可能な IPv6 アドレスの全部ではありません。NAT64 の Well-Known Prefix 64:ff9b::/96(RFC 6052)はこの外側にあり、IANA のレジストリでも Globally Reachable とされています。NAT64/DNS64 の回線ではこの経路をすり抜けるので、必要なら同様に登録してください。

sudo route -n delete -inet6 2000::/3

設定側で塞ぐこともできる

なお、こうした状況は接続先(head-end)側の設定で起きないようにできます。

Cisco の VPN(AnyConnect、現行の名称は Cisco Secure Client)では、head-end がクライアントに IPv4 のアドレスだけを割り当てた場合、既定では IPv6 宛の通信は破棄されます。グループポリシーで Client Bypass Protocol を有効にすると、アドレスが割り当てられなかったほうのプロトコル(この場合 IPv6)はトンネルに入れず、クライアントの通常の経路から平文で送出されます。ASA では次のように設定するもので、既定は無効です。

group-policy <name> attributes
 client-bypass-protocol enable

トリガになるのは「トンネルがそのプロトコルを扱えるか」ではなく、head-end がそのプロトコルのアドレスを割り当てたかどうかです。今回のように IPv4 のアドレスだけが割り当てられた状態が、これに当たります。

クライアントのプロファイル XML にも、IP プロトコルを指定する項目があります。

<IPProtocolSupport>IPv4</IPProtocolSupport>

ただしこちらは head-end に接続するときにどちらのプロトコルを使うか(と、失敗したときのフォールバック順序)を決める設定です。取り得る値は IPv4 / IPv6 / IPv4, IPv6 / IPv6, IPv4 で、既定は IPv4, IPv6 とされています。トンネルの中を流れる通信や、クライアントが張る経路を制御するものではないので、今回の症状に対応するのは Client Bypass Protocol のほうです。

いずれも利用者側では変更できないので、手元では上の経路の方法を使うことになります。

まとめると

「ネットワークは生きているのに、特定のツールだけが失敗する」という症状に出会ったら、次の順で見ると早いです。

  1. エラーメッセージの宛先アドレスが IPv4 か IPv6 かを確認する
  2. curl -4curl -6 で同じ宛先に当て、プロトコルで結果が割れるか見る。このとき -w '%{remote_ip}' も出す。IPv6 経路が無いと macOS は ::ffff: で始まる IPv4 射影アドレスを返すことがあり、curl -6 が成功してしまうため、成功しただけでは IPv6 が通っている証拠になりません
  3. 割れていたら route -n get -inet6 default で、グローバルな IPv6 デフォルト経路の有無と向き先を確認する
  4. utun 系に向いていたら、VPN が張った経路を疑う。稼働中の VPN は一覧表示ではなくプロセスとインターフェースで特定する
  5. 回避するなら、経路を消すのではなく 2000::/3 を reject で登録する。既定経路より具体的なので、入れ直されても効き続ける

考え方は OS を問いません。Linux なら sudo ip -6 route add unreachable 2000::/3、Windows なら netsh interface ipv6 add route 2000::/3 interface=<番号> が相当します。いずれも、既定経路より具体的な到達不能経路を置く、という点は同じです。

手元の確認手段が Happy Eyeballs で救われていると、「正常」という証拠ばかりが集まります。プロトコルを固定して当てるところまでやらないと、割れ方が見えませんでした。