はじめに 本年もよろしくお願いいたします🎍
毎年、年末には何かしら新しい学びをインプットする時間を作っているのだが、 今年は諸々の事情で年内に間に合わず、年始から勉強を始めることに!
仕事上Rustに触る機会がなく、明確に「これを作りたい」というテーマもなかったのだが、 せっかくなので「低レイヤーで何か作りたい」という気持ちを優先することにしました。
そこで以前から一度やってみたかった、TCPの3ウェイハンドシェイクをRaw Socketで実装することに挑戦してみた。
HTTPなどのアプリケーション層のプロトコルは一切使わず、 トランスポート層(L4)で直接通信し、文字列を返すだけのシンプルなサーバーを作る。 いわゆる「エコーサーバー」をL4だけで完結させるというのが今回のテーマです。
完成形はこれ:
1 2 $ echo "toppage" | nc 172.17.0.2 8888 hello world
シンプルだけど、これを実現するまでにTCPの仕組みを体系的に理解できた。 一点注意としては、本件は実用性は全く意識しておらず、ハードコーディングのオンパレードです。
何を作ったか L4 String Server - トランスポート層で文字列を返すだけのサーバー
Raw Socketで実装
TCP 3ウェイハンドシェイク(SYN, SYN-ACK, ACK)
データパケットの送受信
使用技術:
Rust (初めて)
Raw Socket (libcでシステムコール呼び出し)
Docker (macOSのloopback制限回避)
Claude Code(Agentic codingで学びを加速🙇)
作った経緯 OSI参照モデル 見るたびに「アプリケーション層を通さずに3ウェイハンドシェイクの検証ってできないんだっけ?」とずっと思った時期があったので、実験的な意味も含めてやってみたかった。
よく見るこの図の赤枠部分だけでデータやり取りするという試み。 この辺は先人の方々がすでにやられているがあくまでRustの理解を深めるというモチベーションで頑張る。
いざ開発 1. IPヘッダーの解析 IPヘッダーの仕様理解(RFC 791 )と受信したパケットからIPヘッダーをパースする。
IPヘッダーの構成 このアスキーアートはAIで出力。 難しい。
1 2 3 4 5 6 7 8 9 10 11 12 13 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+|Version| IHL |Type of Service| Total Length | ← 0-3バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Identification |Flags| Fragment Offset | ← 4-7バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Time to Live | Protocol | Header Checksum | ← 8-11バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Source Address | ← 12-15バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Destination Address | ← 16-19バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+
1 2 3 4 5 6 7 8 9 10 11 12 13 | バイト位置 | フィールド | 説明 | |------------ |-------------------- |--------------------------- | | 0 | Version (4bit) | IPバージョン (4 = IPv4) | | | IHL (4bit) | ヘッダー長 (5 = 20バイト) | | 1 | Type of Service | サービスタイプ | | 2-3 | Total Length | パケット全体の長さ | | 4-5 | Identification | パケット識別子 | | 6-7 | Flags + Fragment | フラグメント情報 | | 8 | Time to Live (TTL) | 生存時間 | | 9 | Protocol | プロトコル (6=TCP) | | 10-11 | Header Checksum | チェックサム | | 12-15 | Source IP | 送信元IPアドレス | | 16-19 | Destination IP | 宛先IPアドレス |
受信したパケットからIPヘッダーをパース 重量なのは送信元IPアドレス、宛先IPアドレス
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct IpHeader { version: u8 , ihl: u8 , src_ip: [u8 ; 4 ], dst_ip: [u8 ; 4 ], } impl IpHeader { fn parse (data: &[u8 ]) -> Option <Self > { if data.len() < 20 { return None ; } let version_ihl = data[0 ]; let version = version_ihl >> 4 ; let ihl = version_ihl & 0x0F ; Some (IpHeader { version, ihl, src_ip: [data[12 ], data[13 ], data[14 ], data[15 ]], dst_ip: [data[16 ], data[17 ], data[18 ], data[19 ]], }) } }
ビット演算でバージョンとヘッダー長を取り出す。
2. TCPヘッダー解析 TCPヘッダーの理解(RFC 793 )とパース。
TCP ヘッダーの構成 1 2 3 4 5 6 7 8 9 10 11 12 13 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Source Port | Destination Port | ← 0-3バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Sequence Number | ← 4-7バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Acknowledgment Number | ← 8-11バイト +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+| Data | |U|A|P|R|S|F| | | Offset| Reserved |R|C|S|S|Y|I| Window | ← 12-15バイト | | |G|K|H|T|N|N| | +-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+-+ -+
1 2 3 4 5 6 7 8 9 10 11 | バイト位置 | フィールド | 説明 | |------------ |----------------------- |-------------------------- | | 0-1 | Source Port | 送信元ポート番号 | | 2-3 | Destination Port | 宛先ポート番号 | | 4-7 | Sequence Number | シーケンス番号 | | 8-11 | Acknowledgment Number | ACK番号 | | 12 | Data Offset | ヘッダー長 | | 13 | Flags | SYN, ACK, FIN等 ← 重要! | | 14-15 | Window | ウィンドウサイズ | | 16-17 | Checksum | チェックサム | | 18-19 | Urgent Pointer | 緊急ポインタ |
TCP ヘッダーのパース 1 2 3 4 5 6 7 struct TcpHeader { src_port: u16 , dst_port: u16 , seq_num: u32 , ack_num: u32 , flags: u8 , }
フラグ判定が重要
1 2 3 4 5 6 7 8 const TCP_FIN: u8 = 0x01 ; const TCP_SYN: u8 = 0x02 ; const TCP_PSH: u8 = 0x08 ; const TCP_ACK: u8 = 0x10 ; fn is_syn (&self ) -> bool { self .flags & TCP_SYN != 0 }
3. SYN-ACKパケット作成 ここが一番ハマった。
問題1: カーネルがRSTを送ってくる
SYNを受信してSYN-ACKを返そうとすると、カーネルが勝手にRSTパケットを送信してしまう。カーネルから見ると「知らないポートへのSYN」なので、RSTで拒否するのは正しい動作。
解決: iptablesでRSTをブロック
これでカーネルのRSTを破棄できた。 実用性皆無。
1 iptables -A OUTPUT -p tcp --tcp-flags RST RST --sport 8888 -j DROP
問題2: SYN-ACKがeth0に出る
SYNはloインターフェースから来るのに、SYN-ACKがeth0から出て行ってしまう。
解決: 送信用ソケットをloにバインド
受信用と送信用でソケットを分けることで解決。 これまた実用性皆無。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 unsafe fn create_send_socket () -> Result <c_int, String > { let sock = libc::socket(AF_INET, SOCK_RAW, IPPROTO_TCP); let interface_name = b"lo\0" ; libc::setsockopt( sock, libc::SOL_SOCKET, libc::SO_BINDTODEVICE, interface_name.as_ptr() as *const c_void, interface_name.len() as u32 , ); Ok (sock) }
4. チェックサム計算 IPとTCPのチェックサムを計算する必要がある。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 fn calculate_checksum (data: &[u8 ]) -> u16 { let mut sum: u32 = 0 ; let mut i = 0 ; while i < data.len() - 1 { let word = u16 ::from_be_bytes([data[i], data[i + 1 ]]); sum += word as u32 ; i += 2 ; } while sum >> 16 != 0 { sum = (sum & 0xFFFF ) + (sum >> 16 ); } !sum as u16 }
TCPは疑似ヘッダーを含めてチェックサムを計算する:
1 2 3 4 5 6 7 8 9 10 11 12 13 fn calculate_tcp_checksum (src_ip: &[u8 ; 4 ], dst_ip: &[u8 ; 4 ], tcp_segment: &[u8 ]) -> u16 { let tcp_len = tcp_segment.len() as u16 ; let mut pseudo = Vec ::new(); pseudo.extend_from_slice(src_ip); pseudo.extend_from_slice(dst_ip); pseudo.push(0 ); pseudo.push(6 ); pseudo.extend_from_slice(&tcp_len.to_be_bytes()); pseudo.extend_from_slice(tcp_segment); calculate_checksum(&pseudo) }
5. データパケット送受信 コマンドを受け取って、レスポンスを返す部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let data = &buffer[data_offset..recv_len as usize ];let command = String ::from_utf8_lossy(data).trim().to_string();let response = match command.as_str() { "toppage" => "hello world\n" , "about" => "This is TCP server\n" , "help" => "Available commands: toppage, about, help\n" , _ => "Unknown command\n" , }; let data_packet = build_data_packet( my_server_ip, conn.client_ip, 8888 , conn.client_port, conn.server_seq, conn.client_seq, response.as_bytes(), );
6. FIN処理 (最後の仕上げ) 最初はncが終了しなかった。
問題: ncが終了を待っている
データを受信しても、ncは接続を閉じずに待機していた。
1 2 3 4 1. nc → サーバー: "toppage" (PSH+ACK)2. サーバー → nc: "hello world" (PSH+ACK)3. nc → サーバー: ACK4. 【ここで待機】← ncがサーバーの切断を待っている
解決: サーバーからFIN-ACKを送る
データレスポンス送信直後に、サーバーからFIN-ACKを送ることで解決。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 send_packet(send_sock, &data_packet, conn.client_ip)?; let fin_ack = build_fin_ack_packet( my_server_ip, conn.client_ip, 8888 , conn.client_port, conn.server_seq, conn.client_seq, ); send_packet(send_sock, &fin_ack, conn.client_ip)?;
これでncが自動終了するようになった!
1 2 $ echo "toppage" | nc 172.17.0.2 8888 hello world
入力のパケットをWiresharkで確認
出力のパケットをWiresharkで確認
最終的なパケットフロー %%{init: {'theme':'forest'}}%%
sequenceDiagram
participant Client as クライアント(nc)
participant Kernel as Linuxカーネル(TCPスタック)
participant Program as プログラム(Raw Socket)
Note over Client,Program: ① 3ウェイハンドシェイク
Client->>Kernel: SYN (lo)
Kernel->>Program: SYN
Note right of Program: 受信
Kernel--xClient: RST (blocked!)
Note left of Kernel: iptablesで破棄
Note right of Program: SYN-ACK作成
Program->>Client: SYN-ACK (lo)
Note right of Program: 送信
Client->>Kernel: ACK (lo)
Kernel->>Program: ACK
Note right of Program: 受信 Established
Note over Client,Program: ② データ交換
Client->>Kernel: PSH+ACK ("toppage") (lo)
Kernel->>Program: PSH+ACK ("toppage")
Note right of Program: 受信 コマンド解析
Program->>Client: PSH+ACK ("hello world") (lo)
Note right of Program: 送信
Client->>Kernel: ACK (lo)
Kernel->>Program: ACK
Note right of Program: 受信
Note over Client,Program: ③ 接続クローズ
Program->>Client: FIN+ACK (lo)
Note right of Program: 送信 接続リセット
Client->>Kernel: FIN+ACK (lo)
Kernel->>Program: FIN+ACK
Note right of Program: (無視)
Note left of Client: 終了
Note over Kernel: 無視
Note right of Program: 次の接続待機
学んだこと Rustの基本的な構文(構造体、パターンマッチ、Option<T>、テスト)は触ってて楽しかった。
TCPの3ウェイハンドシェイクは理論では知ってたけど、実装してみるとシーケンス番号とACK番号の管理が思ったより重要だった。チェックサム計算も疑似ヘッダー含めて計算する必要があって、細かい仕様が多い。
ハマったポイントは:
macOSのloopback制限でDocker使った
カーネルが勝手にRST送ってくるのをiptablesでブロック(実用性皆無)
送信パケットがeth0に出ちゃうのを送信用ソケット分離で解決(これも実用性皆無)
ncが終了しない問題はサーバーからFIN送って解決
おわりに 最初は「SYNパケット受信してSYN-ACK返すだけでしょ?」って思ってたけど、予想外に面倒だった。でもWiresharkでパケット見ながら「ちゃんと動いてる!」ってなるのは楽しい。
ということでRustを学んでいきました〜 仕事でも活用していきたい〜
今回使ったコードは以下https://github.com/kurum-inc/rust-l4-echo-server