L4 Echo Server作りながらRustを嗜む

はじめに

本年もよろしくお願いいたします🎍

毎年、年末には何かしら新しい学びをインプットする時間を作っているのだが、
今年は諸々の事情で年内に間に合わず、年始から勉強を始めることに!

仕事上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; // 上位4ビット
let ihl = version_ihl & 0x0F; // 下位4ビット

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);

// loインターフェースにバインド
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;

// 2バイトずつ足していく
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);
}

// 1の補数
!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); // Protocol = TCP
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 → サーバー: ACK
4. 【ここで待機】← 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)?;

// 即座にFIN-ACK送信
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