ヘアピンを再現し、二つの方法で解く
目標
家の中のノートPC・ルーター・サーバーの3つの役割を、1つのPodの中に作り、宛先だけを変えたときに応答が戻れないことを実際に再現します。次に、送信元まで変えて通るようにし、同じ問題を名前解決の側で解く方法と比べます。
なぜ重要なのか
「外からは通るのに、家の中でだけ通らない」という報告は、原因を推測するのが難しいものです。接続できないのでも、名前が解決できないのでもなく、応答は来るのに、受け取る側が捨ててしまうからです。捨てられることは、ログに何も残しません。
このラボは、その捨てられる瞬間を目で見えるようにします。connect()したUDPソケットは、接続した相手から来たものだけを受け取り、残りはカーネルが黙って捨てますが、それは、ノートPCがルーターのアドレスから来る応答を待っていて、サーバーのプライベートアドレスから来た応答を捨てる状況と、まったく同じです。
このPodでできることとできないこと
ラボのPodにはカーネル権限がないので、ip netnsもnftもtcpdumpも動作しません。その代わり、127.0.0.0/8は範囲全体が自分自身なので、権限なしでも、127.0.0.10・127.0.0.20・127.0.0.99を1台で一緒に使えます。3つの役割を、その3つのアドレスに分けて使います。ステップ6のnftablesのルールは、文章として書くだけで、適用はしません。
ステップ
- 3つの役割のアドレスとやることを、
/root/hairpin/01-map.txtファイルに書いてください。laptop 127.0.0.10、router 127.0.0.99、server 127.0.0.20で、3つのアドレスを1台で使える理由も1行書きます。 /root/hairpin/site/index.htmlファイルにhairpin-lab-serverの1行を入れ、そのディレクトリを127.0.0.20:9000で公開してください。プライベートアドレスでつないだ結果と、127.0.0.99:9000でつないだ結果を、/root/hairpin/02-direct.txtファイルに一緒に書き込みます。- 宛先だけを変えたとき(DNATだけ)に、パケットの送信元と宛先がどう変わるかを、
/root/hairpin/03-tuple.txtファイルに5行で書いてください。行頭は、それぞれrequest-out、after-dnat、reply、expected、mismatch=です。 /root/hairpin/nat.pyファイルを作成してください。python3 nat.py dnat-onlyが、3つのソケットでその状況を再現し、最後の行にresult=droppedを出力する必要があります。実行した結果を、/root/hairpin/04-dnat-only.txtファイルに保存してください。- 同じツールの
python3 nat.py dnat-snatが、送信元まで変えてresult=deliveredを出すようにして、結果を、/root/hairpin/05-dnat-snat.txtファイルに保存してください。 - 本物のルーターにかけるルールを、
/root/hairpin/06-rules.nftファイルに書いてください。サーバーは192.168.0.20、家の中の範囲は192.168.0.0/24、ポートは80です。preroutingとpostroutingの2つのチェーンが必要で、送信元を変えるルールは家の中から来たものにだけかかる必要があります。 - 同じ問題を、名前の側で解いてください。
/root/hairpin/hosts.internalファイルにhome.example.testの内部での答えを書き、curl --resolveで内部の答えと外側の答えをそれぞれ真似て、/root/hairpin/07-split.txtファイルに書き込みます。 - 2つの解法を、
/root/hairpin/08-compare.mdファイルで比較してください。## 헤어핀、## 스플릿、## 무엇을 고르나(それぞれ韓国語で「ヘアピン」「スプリット」「何を選ぶか」を意味する見出しです)の3つの節が必要です。
参考
- サーバーは
python3 -m http.server 9000 --bind 127.0.0.20 --directory /root/hairpin/siteで起動します。バックグラウンドで動かすときは、setsid nohup … >/tmp/서버로그 2>&1 </dev/null &(プレースホルダーはサーバーログです)で標準出力を切り離してください。引き継がせると、採点ツールがそのプロセスが終わるまで待ちます。 - 接続が失敗したことも証拠です。
curl -sS -m 3 … 2>&1で、エラーメッセージまでファイルに書き込んでください。 - ステップ4でソケットを3つ使う理由は次のとおりです。ノートPCのソケットはルーターのアドレスに
connect()し、ルーターのソケットは127.0.0.99:9500に、サーバーのソケットは127.0.0.20:9500に結びます。ソケットでは送信元を偽装できないので、DNATだけがかかった状況では、元の送信元を本文に載せてサーバーに知らせ、サーバーがそのアドレスに直接答えるようにします。 socket.timeoutを捕捉して、result=droppedを出力してください。時間が経って失敗したのであって、エラーが出たのではありません。- ステップ7の
curl --resolve 이름:포트:주소(プレースホルダーは名前、ポート、アドレスです)は、その名前がそのアドレスに解決されたかのように扱います。DNSを立てなくても、スプリットホライズンを真似できます。 - 採点ツールは、作成した
nat.pyを2つのモードで直接実行してみます。結果を埋め込んでおくと、片方のモードで必ず不合格になります。
3つの役割の地図を描く
127.0.0.0/8は、第1オクテットが127ならすべて自分自身です。そのため、127.0.0.20にサーバーを結びつけてもよく、127.0.0.99をルーターの役割に使ってもかまいません。カーネル権限が必要ないことが、このラボがPodで動く理由です。
1行に名前・アドレス・やることを書き、最後の行に、3つのアドレスを1台で一緒に使える理由を書いてください。
プライベートアドレスでは通るのに、外側のアドレスでは通らない
まず、サーバーが公開するファイルを作ります。採点ツールがレスポンスを見分けられるように、hairpin-lab-serverという文字が入っている必要があります。
python3 -m http.server 9000 --bind 127.0.0.20 --directory /root/hairpin/site
そのあと2回つないでみます。127.0.0.20:9000は通り、127.0.0.99:9000は通りません。今はルーターの役割に何もないので、すぐに拒否されますが、これが「ポートフォワーディングをかけていないルーター」の役割です。
失敗した側のメッセージもファイルに書き込む必要があります。curl -sS -m 3 http://127.0.0.99:9000/ 2>&1のように、標準エラーも一緒に受け取ってください。
4つのタプルを追跡する
DNATは宛先だけを変えます。そのため、ルーターを通ったあとも、送信元はノートPCのままで、サーバーはノートPCに直接答えます。
4行を順に書いてください。
request-out: ノートPCが最初に出すとき。宛先はルーターのアドレスです。after-dnat: ルーターが宛先をサーバーに変えたあと。送信元はそのままです。reply: サーバーが答えるとき。送信元がサーバーのプライベートアドレスです。expected: ノートPCが待つ応答。送信元がルーターのアドレスである必要があります。
最後のmismatch=の行には、replyとexpectedがどこでずれているかを書きます。ポート番号は、適当な値を選んで一貫して使えば十分です。
宛先だけを変えると、応答が捨てられる
核心はconnect()です。UDPソケットでも、connect()すると、カーネルに「誰から来る応答を待っているか」が登録され、別のアドレスから来たデータグラムは、ソケットに届かないまま捨てられます。
ソケット3つを次のように置きます。
laptop bind(('127.0.0.10', 0)) connect(('127.0.0.99', 9500))
router bind(('127.0.0.99', 9500))
server bind(('127.0.0.20', 9500))
ノートPCが送ると、ルーターが受け取ります。DNATだけがかかった状況なので、サーバーが見る送信元はノートPCでなければなりませんが、ソケットでは送信元を偽装できません。そのため、元の送信元を本文に載せて送り、サーバーがそのアドレスに直接答えるようにしてください。
最後にlaptop.recv()を2秒の制限で試し、socket.timeoutが出たら、result=droppedを出力します。
送信元まで変えると通る
直すのは1つだけです。ルーターがリクエストをサーバーへ渡すとき、自分のソケットで送れば、サーバーが見る送信元がルーターになり、応答もルーターに来ます。ルーターは、その応答をノートPCに返せばよいのです。
このとき、ノートPCが受け取る応答の送信元は127.0.0.99:9500で、それがノートPCがconnect()した相手と同じなので、カーネルが通します。
同じツールをモードだけ変えて実行するということが重要です。採点ツールが2つのモードを両方実行して、結果が実際に分かれるかを見ます。
ルーターにかけるルールを書く
このPodにはカーネル権限がないので、nftで適用できません。ルールを正確に書くことが目的です。
チェーンが2つ必要です。
chain prerouting { type nat hook prerouting priority dstnat; … dnat to … }
chain postrouting { type nat hook postrouting priority srcnat; … masquerade }
送信元を変えるルールに、条件を書き漏らさないでください。条件なしでかけると、外から来たリクエストまで送信元がルーターに変わり、サーバーログのアクセス記録がすべて1つのアドレスにまとまってしまいます。ip saddrで、家の中の範囲から来たものだけを選んでください。
ルールごとに、何をする行かを#のコメントで付けます。
同じ問題を名前の側で解く
スプリットホライズンDNSは、同じ名前を、内側から尋ねればプライベートアドレスを、外側から尋ねればグローバルアドレスを答えるようにするものです。そうすれば、家の中の機器は、そもそもルーターまで行きません。
DNSサーバーを立てずに、curl --resolveで2つのケースを真似ます。
curl -sS -m 3 --resolve home.example.test:9000:127.0.0.20 http://home.example.test:9000/
curl -sS -m 3 --resolve home.example.test:9000:127.0.0.99 http://home.example.test:9000/ 2>&1
アドレスではなく名前でアクセスするということが核心です。プライベートアドレスで直接つなぐようにすると、証明書に書かれた名前とアクセスしたアドレスが違うので、警告が出ます。
hosts.internalには、内部で答える値だけを置きます。外側のアドレスも一緒に書くと、それはもうスプリットではありません。
2つの解法を比較する
2つの解法は、直す場所が違います。ヘアピンはルーター1台だけを触ればよいのですが、トラフィックがルーターを往復します。スプリットDNSはスイッチの中で完結しますが、管理する場所が2つになります。
比較に必ず入れるものは3つです。
- 証明書: なぜアドレスではなく名前でアクセスする必要があるのか
- 経路: トラフィックがどこまで行って戻ってくるか
- Kubernetes: Podが自分のServiceの外部アドレスで自分を呼び出すと同じ状況になるが、それをどう避けるか
節の見出し## 헤어핀、## 스플릿、## 무엇을 고르나は、採点ツールがそのまま探します(それぞれ韓国語で「ヘアピン」「スプリット」「何を選ぶか」を意味する見出しです)。