← シリーズに戻る

Tailscale越しのSSHとMTU 1280

Part 5 / 6 SSH接続の5方式を7軸比較(4方式実測)

Tailscale を挟んでも、SSH の設定はほとんど変わりません。Tailscale が解くのは到達性だけで、認証は従来どおり sshd が行います。

つまり公開鍵認証で作った鍵も ~/.ssh/config もそのまま使えて、変えるのは HostName に書く宛先だけです。ただし経路のMTU (1個のIPパケットで運べる最大の大きさ) が 1280 バイトに固定されるので、ある条件下だけ鍵交換がこの境界に当たります。実際のtailnet (Tailscale でつないだ端末の集まり) で測った値で示します。

変わるのは宛先だけ

Host pi
    HostName 100.x.y.z        # tailnet 内のIP、または MagicDNS 名
    User deploy
    IdentityFile ~/.ssh/lab

宛先は tailnet の中の IP か、MagicDNS (tailnet の中だけで引ける端末名) の名前です。踏み台の行が消えます。NATの内側に居る機械へ、踏み台を置かずに直接届くようになるからです。authorized_keys も PasswordAuthentication no も、そのまま効いたままです。

踏み台経由の config から Tailscale 越しの config への書き換えを左右に並べた図。左の C 踏み台経由 (4章の config) は Host target / HostName lab-target / User deploy / IdentityFile ~/.ssh/lab / ProxyJump bastion で、奥の機械へは踏み台を通って入る。右の D Tailscale 越し (この章の config) は Host pi / HostName 100.x.y.z (強調) / User deploy / IdentityFile ~/.ssh/lab で、ProxyJump bastion の行は取り消し線で消えている。HostName は tailnet 内の IP か MagicDNS 名、ProxyJump の行が消える (NAT の内側へ直接届く)。User と IdentityFile は両方で同じ。下の囲みは「サーバー側は何も変えない。そのまま効き続ける: authorized_keys、PasswordAuthentication no、認証するのは sshd」。最下部に「Tailscale が解くのは到達性だけ。鍵も config の残りの行もそのまま使う」

scp と rsync を実測で使い分けるの章で測った scp と rsync も、同じ ~/.ssh/config を読むので同じように乗るはずです。tailnet 越しの転送量は測っていません。

経路のMTUを測る

tailscale0 の MTU は 1280 です。

$ ip link show tailscale0
3: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 ...

インターフェースの表示ではなく、相手まで実際に何バイト通るかを測ります。ping -M do は分割を禁止するので、通らないサイズで落ちます。

payload 1200B (IP総長 1228B): 0% packet loss
payload 1232B (IP総長 1260B): 0% packet loss
payload 1252B (IP総長 1280B): 0% packet loss
payload 1280B (IP総長 1308B): 100% packet loss

IP パケット全長 1280 バイトちょうどまでが通り、それを超えると落ちます。 経路のMTUが 1280 であることが、インターフェースの設定値ではなく実測で確定しました。

ping -M do でサイズを振った結果の横棒グラフ。棒の長さは IP パケット全長で、1100 より左は省略、MTU 1280 に赤い点線。IP 総長 1228 (payload 1200) は 0% loss、IP 総長 1260 (payload 1232) は 0% loss、IP 総長 1280 (payload 1252) は 0% loss で線にちょうど届いて通る、IP 総長 1308 (payload 1280) は線を越えて 100% loss (赤)。下に「tailscale0 の表示 (mtu 1280) ではなく、相手まで実際に通る大きさで確定した。IP 総長 = payload + 28 (IPv4 ヘッダ 20 + ICMP ヘッダ 8)。-M do は分割を禁止するので、通らない大きさはそのまま落ちる」

1280 という値は Tailscale の都合ではなく、IPv6 が RFC 8200 で定めている最小MTUです。どんな経路でも通る値として選ばれています。

TCP から見える境界

上位の TCP が1セグメントに載せられるのは、ここからヘッダを引いた分です。

1280 (MTU) − 20 (IPv4) − 20 (TCP)                      = 1240 バイト
1280 (MTU) − 20 − 20 − 12 (TCPタイムスタンプ、Linuxは既定で有効) = 1228 バイト

1228 バイトが実効の境界です。これを超えるデータを1回で書くと、TCP はフルサイズのセグメントを作ります。

この経路でフルサイズのセグメントが通るかを確かめました。相手の22番へ実際に書き込んで、送信キューが空になるかを見ます。

1回の write サイズ -> 1.5秒後の Send-Q 残量
    1000B -> 0  全部ACK済み
    1208B -> 0  全部ACK済み
    1232B -> 0  全部ACK済み
    1400B -> 0  全部ACK済み
    4000B -> 0  全部ACK済み

この組み合わせは健全でした。ついでに言うと、この経路は直接接続が張れておらず Tokyo の DERP リレー (直接つながらない端末どうしの通信を Tailscale のサーバーが中継する仕組み) 経由です。

pong from autocrew-wsl (100.72.192.8) via DERP(tok) in 50ms
direct connection not established

リレー経由でもフルサイズのセグメントは落ちていません。 遅延は増えますが、サイズの問題とは別の話です。

鍵交換が境界に当たる条件

問題が出うるのは、クライアントが公開鍵を載せて送る鍵交換パケット (SSH2_MSG_KEX_ECDH_INIT) が 1228 バイトを超えたときです。そしてこのサイズは、両端の OpenSSH の版で決まります。

交渉された鍵交換クライアントが送る鍵交換パケット (実測)1228 との関係
mlkem768x25519-sha2561232 バイト超える
sntrup761x25519-sha512@openssh.com1208 バイト収まる
curve25519-sha25648 バイト収まる

表のバイト数は計算値ではありません。偽の SSH サーバーを立て、OpenSSH 10.0p2 のクライアントが実際に送ってきたパケットの大きさを測った値です (測り方はWSL2 Tailscale SSHがKEXで停止に書きました)。

post-quantum な鍵交換 (量子計算機でも解けないとされる暗号を組み込んだ鍵交換) では、公開鍵そのものがパケットに載るので 25 倍に膨らみます。そして mlkem768x25519-sha256 と sntrup761x25519-sha512 の差はわずか 24 バイトですが、その 24 バイトが境界をまたいでいます。

MTU 1280 バイトの物差しに鍵交換パケットを重ねた図。物差しは IPv4 20 + TCP 20 + TCPタイムスタンプ 12 のヘッダと、TCP のデータ 1228 バイトからなり、右端に「MTU 1280 バイト = データ 1228 の端」の赤い縦線がある。その下にデータの先頭からパケットの棒を置いている。curve25519-sha256 は 48 バイトで収まる、sntrup761x25519-sha512@openssh.com は 1208 バイトで収まる、mlkem768x25519-sha256 は 1232 バイトで 4 バイト超える (赤)、KEXINIT (10.0p2 の既定) は 1568 バイトで線を大きく超えるが届いた。下段は境界付近の拡大で、1208 は境界まで 20 バイト残り、1232 は 4 バイト超える。mlkem768 と sntrup761 の差の 24 バイトが境界をまたいでいる

(MTU は高さ制限のあるトンネルに似ています。高すぎる荷物は通れませんが、TCP は荷を分けて積み直すので、1568 バイトの KEXINIT も届きました。止まりうるのは、その積み直しがどこかでうまくいかないときです)

実tailnet越しに OpenSSH 10.0p2 のクライアントから接続すると、こうなりました。

debug1: Remote protocol version 2.0, remote software version OpenSSH_9.6p1
debug1: kex: algorithm: sntrup761x25519-sha512@openssh.com
debug1: expecting SSH2_MSG_KEX_ECDH_REPLY
iris@100.72.192.8: Permission denied (publickey,password).

サーバーが 9.6p1 で mlkem768x25519-sha256 を持っていないため、sntrup761(1208 バイト)で交渉が成立し、境界の内側に収まって認証まで到達しています。両端が 10.x だと mlkem768(1232 バイト)が選ばれ、このパケットが境界を超えます。

ただし、この接続でもクライアントが最初に送るアルゴリズム一覧 (SSH2_MSG_KEXINIT) は、同じ 10.0p2 の既定設定で測ると 1568 バイトあり、1228 バイトを超えています。それでもサーバーに届き、交渉は成立しました。メッセージが 1228 バイトを超えても TCP が分けて送るので、それだけでは落ちない、ということです。

両端の OpenSSH の版の組み合わせで鍵交換パケットの大きさが決まることを示す2段の図。上段 (実tailnetで確認) は、クライアント OpenSSH 10.0p2、サーバー OpenSSH 9.6p1 (mlkem768 を持たない) で、sntrup761x25519-sha512@openssh.com が交渉され、パケットは 1208 バイトで境界の内側に収まる。認証まで到達し、最終行は Permission denied (publickey,password)。下段 (この経路では停止を再現していない) は、両端が OpenSSH 10.x で mlkem768x25519-sha256 が選ばれ、1232 バイトで境界を 4 バイト超える。超えたときにだけ落ちうる。停止を踏んだのは WSL2 をクライアント側にした別の組み合わせ

詰まったときに見る順番

  1. ssh -vv の最終行をssh -vv で読む接続の5段階を実測の5段階に当てる。4段階目なら鍵ではない
  2. ping -M do -s でサイズを振って、経路のMTUを実測する
  3. ssh -V を両端で見る。mlkem768 が選ばれる組み合わせかを確かめる
  4. -o KexAlgorithms=curve25519-sha256 -o Ciphers=aes128-ctr で繋ぎ直す。通ればサイズの問題

4 が通った場合の恒久的な書き方と、なぜ2つ指定しないと効かないのかはWSL2 Tailscale SSHがKEXで停止に書きました。

この章の結論

Tailscale は到達性を解くだけなので、鍵も authorized_keys も人が増えるたびに配り続けることになります。7軸の表で言えば、到達性と公開面は上がりましたが、鍵の配布と失効は公開鍵認証のときのままです。

そこを変える方法が1つ残っています。次の章で扱います。