1 ポイント 投稿者 GN⁺ 2025-03-05 | 1件のコメント | WhatsAppで共有
  • Linuxでユーザー空間TCP/IPスタックを自作しながらネットワーク層を学ぶシリーズの第一歩として、レイヤー2のイーサネットフレーム処理とARP応答から実装する
  • カーネルの低レベルなネットワークトラフィックはTAPデバイスで受け取り、返されたファイルディスクリプタを通じて仮想デバイスのイーサネットバッファをread/writeできる
  • イーサネットヘッダはdmacsmacethertypepayloadとして扱い、ethertypeの値が1536以上ならペイロード種別、それより小さければペイロード長を意味する
  • ARPはIPv4アドレスのようなプロトコルアドレスを48ビットMACアドレスへ動的にマッピングし、要求に応答しながら変換テーブルを更新する流れで実装される
  • arpingテストでは、カスタムスタックのARP応答をLinuxカーネルが認識し、tap0インターフェースのARPキャッシュ10.0.0.4エントリが追加される

ユーザー空間TCP/IPスタックの出発点

  • 目標は、Linuxで最小のユーザー空間TCP/IPスタックを実装しながら、ネットワークとシステムプログラミングをより深く理解すること
  • TCPは30年以上にわたって複数の仕様が積み重なり複雑だが、実装の中核要素はTCPヘッダの解析、状態機械、輻輳制御、再送タイムアウト計算に絞って考えられる
  • イーサネットとIPはTCPより複雑さが低いため、このシリーズはレイヤー2から実装を始める

TAPデバイスでイーサネットトラフィックを受け取る

  • Linuxカーネルの低レベルなネットワークトラフィックを横取りするために、Linux TAPデバイスを使う
  • TUN/TAPデバイスは、ユーザー空間のネットワーキングアプリケーションがそれぞれL3/L2トラフィックを操作する際によく使われる
    • トンネリングは、パケットを別のパケットのペイロードの中に包む方式である
    • OpenVPNのようなプログラムもTUN/TAPデバイスを使う
  • レイヤー2からネットワークスタックを作るため、TUNではなくTAPデバイスが必要になる
  • TAPデバイスは/dev/net/tapを開き、ioctl(fd, TUNSETIFF, ...)で作成する
    • IFF_TAPはTAPデバイスを選択する
    • IFF_NO_PIはイーサネットフレームの前に不要なパケット情報が付かないようにする
  • 作成後、返されたファイルディスクリプタfdで仮想デバイスのイーサネットバッファを読み書きする

イーサネットフレーム形式

  • イーサネットはLANでコンピュータを接続する基盤技術であり、最初のイーサネット標準は1980年にDigital Equipment Corporation、Intel、Xeroxが発表した
  • 初期バージョンは約10Mb/sの速度と半二重通信を使っていたため、データフローを調整するMACプロトコルが必要だった
    • 半二重のイーサネットインターフェースでは、MAC方式としてCSMA/CDが必要となる
    • 100BASE-Tはtwisted-pair配線を用いて全二重通信とより高いスループットを可能にする
    • イーサネットスイッチが広く普及したことで、CSMA/CDの必要性は概ね薄れた
  • イーサネット標準はIEEE 802.3ワーキンググループが管理している

実装で使うイーサネットヘッダ

  • 実装ではLinuxのif_ether.hを取り込み、ethertypeと16進数値の対応を利用する
  • イーサネットヘッダはC構造体で次のフィールドとして表現される
    • dmac: 宛先MACアドレス
    • smac: 送信元MACアドレス
    • ethertype: ペイロード長または種別
    • payload: ARPまたはIPv4パケットを保持するペイロードポインタ
  • ethertype2オクテットのフィールドで、値によって意味が変わる
    • 値が1536以上なら、IPv4やARPのようなペイロード種別を表す
    • それより小さい値なら、ペイロード長を表す
  • イーサネットフレームにはVLANやQoSを示すタグが付くことがあるが、この実装ではフレームタグを除外している
  • ペイロード長がタグなしで最小要件である48バイトより小さい場合、末尾にパディングバイトが付加される
  • Ethernet Frame Formatの末尾にはCRCで完全性を確認するFrame Check Sequenceフィールドがあるが、この実装では処理しない

イーサネットフレームのパース方法

  • 構造体宣言のpacked属性は、GNU Cコンパイラがデータ整列用のパディングバイトで構造体メモリレイアウトを最適化しないようにする
  • 実装でのパースは、バッファを適切なプロトコル構造体へ型キャストする方式である
    • 例: struct eth_hdr *hdr = (struct eth_hdr *) buf;
  • より移植性の高い方法は、プロトコルデータを手動でシリアライズすること
    • この場合、コンパイラはプロセッサごとのデータ整列要件に合わせてパディングバイトを追加する可能性がある
  • 受信したイーサネットフレームの処理は単純な流れに従う
    • TAPデバイスからバッファを読む
    • init_eth_hdr(buf)でイーサネットヘッダを初期化する
    • handle_frame(&netdev, hdr)ethertype値を見て次の動作を決める

ARPパケット構造と役割

  • ARP(Address Resolution Protocol) は、IPv4アドレスのようなプロトコルアドレスを48ビットのイーサネットアドレスであるMACアドレスへ動的にマッピングする
  • ARPはIPv4に限定されず、複数のL3プロトコルとともに利用できる
    • 例としてCHAOSは16ビットのプロトコルアドレスを定義している
  • 一般的なLAN通信では、サービスのIPアドレスを知っていても、実際の送信にはMACアドレスが必要になる
  • ARPはネットワークへブロードキャスト問い合わせを送り、そのIPアドレスの所有者に自分のハードウェアアドレスを通知させる

ARPヘッダとIPv4向けペイロード

  • ARPヘッダは次のフィールドで構成される
    • hwtype: リンク層タイプを表す2オクテットフィールドで、イーサネットの値は0x0001
    • protype: プロトコルタイプを表す2オクテットフィールドで、IPv4の値は0x0800
    • hwsize: ハードウェアアドレスサイズを表す1オクテットフィールドで、MACアドレスは6バイト
    • prosize: プロトコルアドレスサイズを表す1オクテットフィールドで、IPv4アドレスは4バイト
    • opcode: ARPメッセージ種別を表す2オクテットフィールド
  • opcodeの値は4種類に分かれる
    • ARP request: 1
    • ARP reply: 2
    • RARP request: 3
    • RARP reply: 4
  • IPv4向けARPデータはarp_ipv4構造体で扱う
    • smac: 送信者MACアドレス
    • sip: 送信者IPアドレス
    • dmac: 受信者MACアドレス
    • dip: 受信者IPアドレス

アドレス解決アルゴリズムとキャッシュ

  • RFC 826のアドレス解決アルゴリズムは、ハードウェアタイプとプロトコルタイプを確認した後に変換テーブルを更新し、対象アドレスが自分自身なら応答を作る流れになっている
  • translation tableはARPの結果を保存し、ホストがすでに持っているエントリをキャッシュから参照できるようにする
  • このキャッシュは、重複したARP要求でネットワークが不要に埋まることを防ぐ
  • 実装コードはarp.cにある

ARP応答のテストと次のステップ

  • ARP実装の最終テストは、要求に正しく応答するかを確認すること
  • arping -I tap0 10.0.0.4の実行結果、10.0.0.4から00:0C:29:6D:50:25のMACアドレスでユニキャスト応答が返る
  • その後、arpの出力でLinuxカーネルのARPキャッシュに10.0.0.4 ether 00:0c:29:6d:50:25 tap0エントリが現れる
  • 最小限のイーサネットフレーム処理とARP実装だけでも、カスタムイーサネットデバイスがLinuxホストのARPキャッシュを埋めることを確認できる
  • プロジェクトのソースコードはGitHubにあり、次のステップはICMP echo/replyであるpingとIPv4パケットのパース実装である

1件のコメント

 
GN⁺ 2025-03-05
Hacker Newsのコメント
  • 数年前にCでユーザー空間ネットワークスタックを作ってみたことがあり、TUNインターフェースで生のパケットを処理して、ある程度動くようにしていた
    今はIPアドレスやルートなどを設定できる簡単なシェルが入っていて、ネットワークパケットはmbufとsk_bufを混ぜたようなハイブリッド構造に格納している
    ただ、UDPの実装を終えたあと、TCPを実装する時間もやる気も出ず、コードはここにある: https://github.com/cakturk/unet

    • かなり昔にpcap/tcpdumpパーサを純粋なbashで書いたことがあるが、そのときは「プログラム」を書ける道具がそれしかなかった
      当然、史上もっとも遅くて壊れやすい代物に近かったが、実際に動いたし、かなり面白くもあった。そのコードがまだどこかに残っていたらいいのだが
    • 多くの組み込み機器がTCP/IP実装としてlwipを使っている
      lwipの「POSIXポート」も同じ方法でTUN/TAPデバイスから生のEthernetバイト列を取得している
      https://github.com/lwip-tcpip/lwip/blob/master/contrib/ports...
  • 最小のLinuxカーネルをTCP/IPスタックなしでコンパイルすると400KBで、TCP/IPスタックを入れると800KBになる
    温度だけ送れればいいプロジェクトでは、ユーザー空間の小さなCプログラムで自前のUDPメッセージに値を詰めて送っており、容量と複雑さを大きく減らせた

    • 何も知らない立場からするとかなり驚きだが、だからといってTCP/IP部分がカーネル全体のソースコードの半分という意味ではないのでは、と気になる
    • なぜIPスタックがそんなに大きいのか気になる。400KBバイナリだとコード量はかなり多いが、大規模サーバー向けに高度に最適化されているからだろうか?
  • ARPを無効化すると、同じネットワーク上の複数のサーバーに同じIPを設定できる
    ルーティングフロントエンドの役割をするサーバーが、MACアドレスを基準にバックエンドサーバーのネットワークインターフェースへパケットを転送できるなら、そのバックエンドは自分宛てだと認識し、送信元/宛先IPを入れ替えてクライアントに直接応答できる。このときルーティングフロントエンドは再び経由しない
    あるいはARPを切らずに共通IPアドレスをloopbackインターフェースのエイリアスとして追加しても同じ効果が得られ、バックエンドが自分を宛先だと認識しつつARP競合を避けられる。90〜00年代にIBM WebSphereのソフトウェアロードバランサーが使っていたトリックだ

    • Cisco IOS SLBも似たように動かせる。サーバーファームの各サーバーのloopbackに仮想IPをエイリアスとして追加する方式だ
      より広く使われるL3負荷分散より良い点は、IPパケットヘッダーを書き換える必要がないことだ
    • これはDSR(Direct Server Return) としても知られている: https://www.haproxy.com/blog/layer-4-load-balancing-direct-s...
    • ARPを無効化し、同じネットワーク上の複数のサーバーに同じIPを設定すると、スイッチ/ブリッジがMACアドレスを学習できず、そのセグメントの全ポートへパケットを継続的にフラッディング/ブロードキャストするようになる
      そのため、この方式を使うなら専用VLANを作るのがよい
    • F5にはARPプロキシ設定があるので、こうしなくても済む。欠点はDHCPを壊すことが多い点だ
    • こうした低レベルの遊びにはDPDKも触ってみるといい。ARPはデフォルトで無効になっている
  • 似たようなことをPythonでやってみたことがある: https://github.com/georgek/notebooks/blob/master/internet.ip...
    たぶんコード品質はもっと低く、正直なところアドレス解決アルゴリズムもただのでっち上げだった。ICMPでインターネットホストにpingを送るところまでは成功した
    短いノートブック1つに完全に収まっている点は気に入っている。元の記事は、参照されているより大きなソースコードにある多くの詳細を本文では省いている
    これは元記事を見ず、Wikipediaだけを見て作った。ただ、TCPからは複雑さが大きく跳ね上がるので、そこで少し興味が薄れた。第3部がその部分を扱うというので、いつか読んで仕上げるかもしれない。ネットワーキングに興味があるなら、どのレベルのプログラマーにとっても試す価値があり、やりがいのある作業だと思う

  • 数年前、原子力発電所の計装の仕事をしていた。クライアント側の開発はSunワークステーションで行い、実際、TCP/IPの経験があったおかげで採用されたのだが、その経験はCMUの「オペレーティングシステム」の授業で得たものだった
    一方、発電所のコンピューターはTCP/IPスタックのないミニコンピューターだったので、そのチームは自分たちでスタックを作らなければならなかった

  • 記事の冒頭1分くらいで「dmacとsmacはかなり自明なフィールド」と言っているが、それが何か分からない読者はそこで離脱してしまうかもしれない
    「この記事はそのフィールドが自明な人向けなのだな。自分向けではないから読むのをやめよう」と思ってしまう

    • 文全体としては「dmacとsmacはかなり自明なフィールドです。通信当事者のMACアドレスを入れます(それぞれ宛先と送信元です)」なので、実際には説明している
      しかもネットワークスタックを作る記事なのだから、読者がネットワーキングについてある程度知っていると仮定しても問題ない
    • たった今更新されたのでなければ、そのすぐ次の文で「通信当事者のMACアドレスを入れます(それぞれ宛先と送信元です)」と説明している
  • 関連記事:
    Let’s code a TCP/IP stack (2016) - https://news.ycombinator.com/item?id=27654182 - 2021年6月、コメント49件
    Let’s code a TCP/IP stack, 1: Ethernet & ARP (2016) - https://news.ycombinator.com/item?id=17316487 - 2018年6月、コメント47件
    Let’s Code a TCP/IP Stack: TCP Retransmission - https://news.ycombinator.com/item?id=14701199 - 2017年7月、コメント30件
    Let’s code a TCP/IP stack, 1: Ethernet and ARP - https://news.ycombinator.com/item?id=11234229 - 2016年3月、コメント49件

  • 筆者がARP解析テストで使っている 10.0.0.4 IPアドレス をどこから持ってきたのかわからない
    これは何のアドレスなのか? ここで作った偽のEthernetデバイスから到達可能な偽のデバイスなのか、それとも筆者のネットワークに実際に存在するデバイスなのか?

    • 記事には書かれていないが、インターフェース初期化時に筆者がハードコードしておいた値: https://github.com/saminiir/level-ip/blob/e9ceb08f01a5499b85...
      TAPデバイスは、ソフトウェアでエミュレートされたEthernetリンクのようなもの。そこにパケットを送るとユーザー空間のプログラムに直接渡され、そのプログラムがどのIPアドレスを持つか、またARPにどう応答するかを決める
      普通はこうしたことはOSが処理し、インターフェースにIPアドレスを追加するにはroot権限が必要。TAPデバイスを開くのも同様。ネットワーキングは大半が協調的に成り立っており、ネットワーク上でroot権限を持つ悪意ある行為者は悪さができる
  • 覚えている限りでは、ARPはローカルセグメントでしか動作しない。ルーターが自分のアドレスを入れてパケットを転送する
    またRARPもあり、これは「ネットワーク」に自分のIPアドレスを尋ねる方法の1つ。RARPが今でも実環境で動いているのかはわからない