【Rust】400行以内でEthernet、ARP、IP、ICMPを書いてみるRTA 17:20:56

2025/01/12

あけましておめでとうございます。sititou70です。

新年で暇でしょうがないので、RTAを走ることにしました。

レギュレーション

  • Linux環境上のTAPデバイスを使って、8.8.8.8 にICMP Echoを要求し、Replyを受信するRustのプログラムを作成する。
  • そのために、Ethernet、ARP、IP、ICMPの基本的な実装を行う。
  • あくまで速さを優先する。実装の綺麗さや正確さは問わない。

注意

時系列で書いていくので、バグがあるコードも登場します。最終的なコードは以下のリポジトリを参照してください。

https://github.com/sititou70/rust-icmp

開始

2025-01-11T18:59:12+09:00にタイマースタート、グッドラックです!

環境構築

Rustをインストールします。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

VS Codeにrust-analyzer拡張を導入します。

cargo init します。

Wiresharkをインストールします。

sudo apt install wireshark

TAPデバイスをセットアップします。

TAP_NAME="tap0"
TAP_ADDR="192.168.70.1/24"
GATEWAY_DEVICE="wlp0s20f3"

sudo ip tuntap add mode tap user $USER name $TAP_NAME
sudo ip addr add $TAP_ADDR dev $TAP_NAME
sudo ip link set $TAP_NAME up

echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward >/dev/null
sudo iptables -A FORWARD -o $TAP_NAME -j ACCEPT
sudo iptables -A FORWARD -i $TAP_NAME -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s $TAP_ADDR -o $GATEWAY_DEVICE -j MASQUERADE

tun_tapクレート

tun_tapクレートを使用すると、プログラムからTAPデバイスを簡単に利用できます。つまり、自分で /dev/net/tun をopenしてフラグやらなにやらをちくちく設定しなくて良いというわけです。最高ですね。

cargo add tun_tap

実際にtapデバイスを開いてみます。

fn main() {
    let iface = Iface::new("tap0", Mode::Tap).expect("Failed to create a TAP device");
}

これを実行してエラーが出なければOKです。

この iface にはsendとかrecvメソッドが生えているので、それらを使ってイーサネットフレームを送受信し放題ってわけです。

ARPの実装

さて、iface を使用してさっそくICMP Echo要求を送信したいわけですが、現時点ではゲートウェイのハードウェアアドレスすらわかりません。なので、先にARPを実装します。

ARPメッセージはざっくり以下のような構造になっています。カッコの中はオクテット数です。

  • ハードウェアタイプ(2):Ethernetの場合は1
  • プロトコルタイプ(2):上位プロトコルの種類。IPは0x0800
  • ハードウェアアドレスサイズ(1):Ethernetは6
  • プロトコルアドレスサイズ(1):IPは4
  • オペレーション(2):送信者の動作。要求は1、返信は2
  • 送信元ハードウェアアドレス(6)
  • 送信元プロトコルアドレス(4)
  • 送信先ハードウェアアドレス(6)
  • 送信先プロトコルアドレス(4)

これを生成する関数、create_arp_request_message を作成します。今回は、バイト列を Vec<u8> で取り回すことにします。

pub fn create_arp_request_message(
    sender_ipaddr_str: &str,
    sender_hwaddr_str: &str,
    target_ipaddr_str: &str,
) -> Vec<u8> {
    let hardware_type = 1_u16.to_be_bytes().to_vec();
    let protocol_type = 0x0800_u16.to_be_bytes().to_vec(); // IPv4
    let hardware_size = vec![6_u8];
    // ...

    return [
        hardware_type,
        protocol_type,
        hardware_size,
        // ...
    ]
    .concat();
}

特に難しい部分はありませんが、最終的にネットワークへ流すデータはネットワークエンディアン(ビッグエンディアン)であることに注意します。

Ethernetの実装

ARP要求はEthernetで送信するので、同じように create_ethernet_frame を作成します。

  • 送信先アドレス(6):xx:xx:xx:xx:xx:xx
  • 送信元アドレス(6):xx:xx:xx:xx:xx:xx
  • 上位プロトコルのタイプ(2):EtherType
  • データ(46〜1500)
pub fn create_ethernet_frame(
    protocol_type: u16,
    dest_hwaddr_str: &str,
    src_hwaddr_str: &str,
    data: &Vec<u8>,
) -> Vec<u8> {
    if data.len() > 1500 {
        panic!("too long data is not supported.");
    }

    let dest_hwaddr = parse_hwaddr(dest_hwaddr_str);
    let src_hwaddr = parse_hwaddr(src_hwaddr_str);
    let protocol_type = protocol_type.to_be_bytes().to_vec();

    let mut padding: Vec<u8> = vec![];
    if data.len() < 46 {
        padding.resize(46 - data.len(), 0);
    }

    return [
        dest_hwaddr,
        src_hwaddr,
        protocol_type,
        data.clone(),
        padding,
    ]
    .concat();
}

dataが大きすぎる場合はパニックして楽をしています。今回扱うフレームは小さいものばかりなので問題ありません。

dataが小さすぎる場合はパディングを行っています。衝突検知のための仕様だそうで、詳しくは謎ですが速さを優先して深入りしません。

150046 などのマジックナンバーがハードコードされていますね。スピードは行儀の良さに代えられません。

parse_hwaddr とかのユーティリティはよしなに作成しました。

ARP要求を送ってみる

let gateway_ipaddr = "192.168.70.1";
let my_ipaddr = "192.168.70.2";
let my_hwaddr = "00:00:5e:00:53:01";

let arp_message = create_arp_request_message(my_ipaddr, my_hwaddr, gateway_ipaddr);
let arp_frame = create_ethernet_frame(0x0806, "ff:ff:ff:ff:ff:ff", my_hwaddr, &arp_message);
iface.send(&arp_frame).unwrap();

自分のハードウェアアドレスとIPアドレスを定義し、ゲートウェイのハードウェアアドレスを調べに行きます。

イーサネットフレームのプロトコルタイプにはARP(0x0806)を設定し、ブロードキャスト("ff:ff:ff:ff:ff:ff")で送信します。

デバッグのためにWiresharkを起動しておきます。なんか普通に起動するとtap0がWiresharkから見えないのでsudoで起動しました。確か何かしらのファイルの権限を調整すると、通常の起動でも動作するようにできた記憶がありますが、お行儀よりも速さを優先します。

それではさっそくARP要求を投げてみましょう。

cargo run

Wiresharkのスクリーンショット。Sourceが53:01:08:06:00:01、Destinationがff:ff:00:00:5e:00となっている。またEthernetの上位プロトコルがIPv4と識別されている

全部おかしくて草。まずSourceとDestinationが変だし、ARPを送信したはずがIPv4と思われてます。

デバッグ

Wiresharkでフレームの中身を見てみると、

0000   ff ff 00 00 5e 00 53 01 08 06 00 01 08 00 06 04   ....^.S.........
...

なんか先頭が欠けてるような気がするんですよね。ff が4個くらい足りなくない?

念の為、以下のような Vec<u8> をダンプする関数を作って、プログラム側からフレームの中身を見てみます。

pub fn dump_vec(data: &Vec<u8>) {
    println!(
        "{}",
        data.iter()
            .map(|x| format!("{:02x}", x))
            .collect::<Vec<String>>()
            .join(" ")
    )
}

それをWiresharkのやつと比較してみると

プログラムでダンプしたフレーム:
ff ff ff ff ff ff 00 00 5e 00 53 01 08 06 00 01 08 00 06 04 ...

Wiresharkに表示されたフレーム:
            ff ff 00 00 5e 00 53 01 08 06 00 01 08 00 06 04 ...

やっぱり先頭の4バイトが欠けて送信されてますよねこれ。

なので、インターネットで「linux tap 4bytes missing」などと検索したところ、「why the leading 4bytes data missing when sending raw bytes data to a tap device?」という、同じ症状の民が見つかり、「TAPの IFF_NO_PI フラグの設定をミスってるよ」とのこと。

公式ドキュメントによれば、

IFF_NO_PI - Do not provide packet information

If flag IFF_NO_PI is not set each frame format is::

  • Flags [2 bytes]
  • Proto [2 bytes]
  • Raw protocol(IP, IPv6, etc) frame.

出典:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/networking/tuntap.rst?id=HEAD

とあり、今回はEthernetフレームを直に書いているのでpacket informationなるものは不要そうです。

というわけでこれはtun_tapクレートの不具合ですたぶん。

なんとかして IFF_NO_PI を設定する方法を探す時間はないので、送信時には FlagsProto にみせかけたゼロ埋めのパディングをはさみます。そして受信時には FlagsProto を読み捨てます。

修正後

送信処理と受信処理を清書します。

iface
    .send(
        &[
            vec![0_u8, 0, 0, 0], // for IFF_NO_PI
            arp_frame,
        ]
        .concat()
        .to_vec(),
    )
    .unwrap();

let gateway_hwaddr;
loop {
    let mut frame = vec![0; 1500];
    iface.recv(&mut frame).unwrap();
    frame.drain(0..4); // for IFF_NO_PI

    // check
    //// destination is my hwaddr
    if frame[0..6] != parse_hwaddr(my_hwaddr) {
        continue;
    }
    //// type is arp
    if frame[12..12 + 2] != [0x08_u8, 0x06_u8] {
        continue;
    }

    let message = get_ethernet_frame_data(&frame);
    //// opecode is reply
    if message[6..6 + 2] != vec![0x00, 0x02] {
        continue;
    }

    gateway_hwaddr = print_hwaddr(&message[8..8 + 6].to_vec());

    println!("arp reply received, gateway_hwaddr: {}", gateway_hwaddr);
    break;
}

送信した後は、無限ループに入ってそれっぽいフレームを受信するまで待ちます。本当はここでどのようなチェックを行うかも仕様で決められてそうですが、今回は簡単さを優先しています。

実行すると、gatewayのハードウェアアドレスが表示されました!

かわいい〜〜!!

就寝

2025-01-12T02:00:00+09:00 とかなので流石に寝ます。

起床

2025-01-12T08:00:00+09:00 頃に復帰しました。

ICMPの実装

Echo要求メッセージは以下のような構造になっています。

  • タイプ(1):Echo要求は8。
  • コード(1):いまのところは0固定らしい
  • チェックサム(2):メッセージ全体から計算される
  • 識別子(2):送信元で決める適当な値。プログラムのプロセスIDとかが使用されるらしい
  • シーケンス番号(2):同じ識別子で繰り返し要求する場合にインクリメントされる
  • データ(可変):適当。Replyで同じデータが返ってくる
pub fn create_icmp_echo_request_message(
    identifire: u16,
    sequence_number: u16,
    data: &Vec<u8>,
) -> Vec<u8> {
    let message_type = vec![8_u8]; // echo request
    let code = vec![0_u8];
    // ...

    return [
        message_type,
        code,
        // ...
    ]
    .concat();
}

最も厄介なのがチェックサムです。以前の記事でもお世話になった、micropsのスライドの、チェックサムの計算方法を参照します。

おおざっぱな理解としては、対象のデータを2バイトごとに区切って整数とみなして合計し、補数やらなにやらを取れば良いそうです。以下のように実装しました。

pub fn checksum16(data: &Vec<u8>, init: u32) -> u16 {
    let mut sum = init
        + data
            .chunks(2)
            .map(|chunk| u32::from_be_bytes([0, 0, chunk[0], chunk[1]]))
            .reduce(|x, y| x + y)
            .unwrap();

    while sum & 0xffff0000 != 0 {
        sum = (sum & 0x0000ffff) + (sum >> 16);
    }

    return !(sum as u16);
}

2バイトごとに区切るので、chunks(2) したあと map で処理して reduce すればいいですね。

IPの実装

8.8.8.8 にICMPメッセージを送るので、当たり前ですがIPの実装が必要です。IPパケットの構造は以下のようになります。

  • バージョン(4ビット):IPv4は4
  • ヘッダ長(4ビット):ヘッダの長さ。4オクテット単位。拡張情報が無いなら5
  • サービス種別(1):QoS機能?今回はとりあえず0
  • 全長(2):IPヘッダを含むパケット全体の長さ
  • 識別子(2):フラグメントの制御に使われる。割愛
  • フラグ(3ビット):フラグメントの制御に使われる。割愛
  • 断片位置(13ビット):フラグメントの制御に使われる。割愛
  • 生存時間(1):パケットの生存期間。ルータを通るたびにデクリメントされ、0になると破棄される。パケットがネットワーク上を無限ループするのを防ぐ。今回は255を設定。
  • プロトコル(1):上位のプロトコル番号。ICMPは1
  • チェックサム(2)
  • 送信元アドレス(4)
  • 宛先アドレス(4)
  • 拡張情報:なぞ
  • データ(可変長)
pub fn create_ip_packet(
    protocol: u8,
    id: u16,
    src_ipaddr_str: &str,
    dest_ipaddr_str: &str,
    data: &Vec<u8>,
) -> Vec<u8> {
    let internet_header_length = 20_u8; // min ip header size
    let version_and_ihl = vec![4 << 4 & 0xf0 | (internet_header_length >> 2) & 0x0f];
    let type_of_service = vec![0_u8];
    // ...

    return [
        version_and_ihl,
        type_of_service,
        // ...
    ]
    .concat();
}

チェックサムの計算には、先程の checksum16 が使えます。

ICMP要求を送ってみる

以下のコードを実行してみると……

let icmp_target_ipaddr = "8.8.8.8";
let icmp_data = "test test test test test test test test test test test test !";
let icmp_message =
    create_icmp_echo_request_message(process::id() as u16, 0, &icmp_data.as_bytes().to_vec());
let icmp_packet = create_ip_packet(1, 123, my_ipaddr, icmp_target_ipaddr, &icmp_message);
let icmp_frame = create_ethernet_frame(0x0800, &gateway_hwaddr, my_hwaddr, &icmp_packet);
iface
    .send(
        &[
            vec![0_u8, 0, 0, 0], // for IFF_NO_PI
            icmp_frame,
        ]
        .concat()
        .to_vec(),
    )
    .unwrap();
thread 'main' panicked at src/util.rs:126:62:
index out of bounds: the len is 1 but the index is 1

はい、Out of Boundsです。

どうやらチェックサムの chunks(2) で、データが奇数個の時に2人組を作れなくて怒ってるみたいです。小学生時代のトラウマが蘇りますね

というわけで、端数が出たらそれも sum に足してやるように修正します。chunks とかはやめて、漢は黙ってwhileループですよ。

pub fn checksum16(data: &Vec<u8>, init: u32) -> u16 {
    let mut sum = init;
    let mut index = 0;
    while index + 1 < data.len() {
        sum += u32::from_be_bytes([0, 0, data[index], data[index + 1]]);
        index += 2;
    }

    if index < data.len() {
        sum += u32::from_be_bytes([0, 0, 0, data[index]]);
    }

    while sum & 0xffff0000 != 0 {
        sum = (sum & 0x0000ffff) + (sum >> 16);
    }

    return !(sum as u16);
}

で、再度実行してみるとWiresharkくんに怒られました。

Wiresharkのスクリーンショット。ICMPのチェックサムが間違っていると表示されている。ペイロードでは0x9fb8だが、正しくは0x3f19であることが表示されている。

ICMPのチェックサムが間違っているとのこと。ご丁寧に正しいチェックサムまで併記されてて便利ですね。

結論としては端数の計算が間違っていて、以下のように修正しました。

- sum += u32::from_be_bytes([0, 0, 0, data[index]]);
+ sum += u32::from_be_bytes([0, 0, data[index], 0]);

エンディアンの認識を間違ってました。

これで再度実行すると……

Wiresharkのスクリーンショット。Seq 3の行は、192.168.70.2から8.8.8.8へのICMP Echo要求が送信されたことを示している。Seq 5の行は、8.8.8.8から192.178.70.2へ応答が返ってきていることを示している。

8.8.8.8 からお返事が来てる!やった〜〜!

受信用のコードも書きます。ホントはここでもチェックの手順が仕様で決まっているのですが、今回は雰囲気で済ませます。

loop {
    let mut frame = vec![0; 1500];
    iface.recv(&mut frame).unwrap();
    frame.drain(0..4); // for IFF_NO_PI

    // check
    //// destination is my hwaddre
    if frame[0..6] != parse_hwaddr(my_hwaddr) {
        continue;
    }
    //// type is IPv4
    if frame[12..12 + 2] != [0x08_u8, 0x00_u8] {
        continue;
    }

    let packet = get_ethernet_frame_data(&frame);
    //// version is 4
    if (packet[0] & 0xf0) >> 4 != 4_u8 {
        continue;
    }
    //// protocol is icmp
    if packet[9] != 1 {
        continue;
    }
    //// source addr is icmp_target_ipaddr
    if packet[12..12 + 4] != parse_ipaddr(&icmp_target_ipaddr) {
        continue;
    }

    let message = get_ip_packet_data(&packet);
    //// type is reply
    if message[0] != 0_u8 {
        continue;
    }

    let data = get_icmp_message_data(&message);
    //// data is icmp_data
    if data[0..icmp_data.len()] != icmp_data.as_bytes().to_vec() {
        continue;
    }

    println!(
        "icmp reply received from {}",
        print_ipaddr(&packet[12..12 + 4].to_vec())
    );
    break;
}

というわけで、最終的なコードを実行して問題がなければ……

タイマーストップです!

完走した感想

終了時刻は 2025-01-12T12:20:08+09:00、記録は17:20:56でした。このカテゴリを走っている人が他にいないので、これが世界記録です。

実際、ネットワークとかRustとかが久々すぎてミスが多かったり、Wiresharkで検証できることをコードで書いてて後から気づいて消したりと、チャートがボロボロだったので、もっと良いタイムを狙えた気がします。

今回書いたコードは、テストコードを除くと374行でした。毎回参考にさせていただいているpandax381さんの資料にある、比較的読みやすい実装でさえ、プロトコルやインターフェースの管理機構、非同期処理機構、ARPのキャッシュ機構、ルーティングテーブルの実装などを含んでいて、今回のより複雑です。

なので、今回のコードは簡単すぎてプロトコルの実装と呼べるのかも怪しいのですが、勉強や趣味の目的には良いかもしれません。

大切に育てたICMPメッセージが立派になって(Replyになって)戻ってくるのを見ると、親としての誇りを感じます。

気が向いたら走ってみるとおもしろいかもですね。

続けて読む…

プロトコルスタックを写経してネットワークを完全に理解したかった日記

2022/10/16

SICPの備忘録

2023/07/29

ラムダ計算で型のリハビリ

2024/02/20

Advent of Code 2021攻略ガイド

2021/12/28

TypeScriptにおける配列の共変性

2022/12/15

TypeScriptで型レベルScheme

2024/01/02

書いた人

sititou70のアイコン画像
sititou70

都内の社会人エンジニア4年生。Web技術、3DCG、映像制作が好き。