🏓

Rustでpingを実装してICMPを嗜む

に公開

はじめに

普段よく利用するpingコマンドですが、中身はどのようになっているのか気になったので今回はpingとICMPについて掘り下げてみます。
※使用するコードはLLMを使用しました。

マシンスペック

MacBook Air M2 arm64

事前知識

ping

通信したいホストやルータなどにIPパケットが到達するか否かを確認するコマンドです。
下記の様に、ipアドレスかドメイン名などを指定して疎通を確認します。

ping xxxx.com
PING xxxx.com (xx.xx.xx.xx) 56(84) bytes of data.
64 bytes from xx.xx.xx.xx.xxx (xx.xx.xx.xx): icmp_seq=1 ttl=63 time=14.6 ms
64 bytes from xx.xx.xx.xx.xxx (xx.xx.xx.xx): icmp_seq=2 ttl=63 time=18.6 ms
64 bytes from xx.xx.xx.xx.xxx (xx.xx.xx.xx): icmp_seq=3 ttl=63 time=17.0 ms
64 bytes from xx.xx.xx.xx.xxx (xx.xx.xx.xx): icmp_seq=4 ttl=63 time=18.5 ms

このpingにはICMPプロトコルが使用されています。
簡単な図で示すと下記の様になります。

ICMP

ICMPはInternaet Control Messagae Protcolという名称です。ICMPにはIPパケットが目的のホストまで届くか否かを確認する機能や、途中でパケットが廃棄される際にその原因を通知してくれる機能などがあります。

準備

Dockerfileの準備・ビルド・接続

mkdir -p ping-icmp && cd ping-icmp
touch Dockerfile
vim Dockerfile # 下記のコードを記述する。
# 最新のRust公式イメージをベースにする
FROM rust:latest

# vimやiputils-ping(疎通確認用)など、必要なツールをインストール
RUN apt-get update && apt-get install -y vim iputils-ping

# コンテナ内の作業ディレクトリを指定
WORKDIR /work

# コンテナ起動時のデフォルトコマンド
CMD ["bash"]
docker build -t ping-icmp-dev .
docker run -it --rm --cap-add=NET_RAW -v "$(pwd)":/work ping-icmp-dev

Rustの準備

cargo init .

Cargo.tomlの編集
dependenciesに下記を追加。

[dependencies]
socket2 = "0.5" # 生ソケットの作成に便利
nix = { version = "0.29", features = ["time"] } # 高精度な時刻取得
rand = "0.8" # Identifierをランダムに生成

src/main.rs

// --- 必要なクレート(ライブラリ)をインポート ---

// 低レベルなソケット操作を行うためのクレート
use socket2::{Socket, Domain, Type, Protocol};
// コマンドライン引数を読み取るためのモジュール
use std::env;
// IPアドレスやソケットアドレスを扱うためのモジュール
use std::net::{SocketAddr, ToSocketAddrs, Ipv4Addr};
// Rustで初期化されていないメモリを安全に扱うための仕組み
use std::mem::MaybeUninit;
// 時間を扱うためのモジュール
use std::time::Duration;
// OSの機能を直接呼び出すためのクレート (今回は高精度な時刻取得に使用)
use nix::time::{ClockId, clock_gettime};
// 乱数を生成するためのクレート (ICMPのID生成に使用)
use rand::random;
// プログラムを一定時間停止させるためのモジュール
use std::thread;


/// ICMPパケットのチェックサムを計算します。
/// チェックサムは、データが通信途中で壊れていないかを確認するためのものです。
/// ICMPでは「1の補数和」という少し特殊なアルゴリズムが使われます。
fn checksum(data: &[u8]) -> u16 {
    let mut sum = 0u32;

    // 1. データを2バイトずつの塊(16ビットワード)として、すべて足し合わせる
    for chunk in data.chunks(2) {
        if let Some(word_bytes) = chunk.get(0..2) {
            // ネットワークデータはビッグエンディアンなので、正しくu16に変換
            let word = u16::from_be_bytes(word_bytes.try_into().unwrap());
            sum += u32::from(word);
        }
    }
    // 2. データ長が奇数の場合、最後の1バイトを8ビット左にシフトして加算
    if data.len() % 2 != 0 {
        sum += u32::from(data[data.len() - 1]) << 8;
    }

    // 3. 計算途中で発生した桁上がり(キャリー)を、結果の下位16ビットに足し込む
    //    この処理を、桁上がりがなくなるまで繰り返す
    while (sum >> 16) > 0 {
        sum = (sum & 0xFFFF) + (sum >> 16);
    }

    // 4. 最後に、計算結果のビットをすべて反転(1の補数を取る)して返す
    !sum as u16
}

fn main() -> std::io::Result<()> {
    // --- 1. 宛先の準備 ---

    // コマンドライン引数の2番目(1番目はプログラム名)を宛先として取得
    let dest_str = env::args().nth(1).expect("使い方: my-ping <宛先ホスト名 or IPアドレス>");

    // ドメイン名(例: "google.com")をIPアドレスに変換(名前解決)
    // 複数のIPアドレスが返ってくる可能性があるので、最初のIPv4アドレスを使う
    let dest_addr = (dest_str.as_str(), 0)
        .to_socket_addrs()?
        .filter_map(|addr| if let SocketAddr::V4(ipv4_addr) = addr { Some(ipv4_addr) } else { None })
        .next()
        .expect("IPv4アドレスに解決できませんでした");
    
    // IPアドレスとポート番号(ICMPでは不要なので0)をまとめたソケットアドレスを作成
    let dest_sock_addr = SocketAddr::from(dest_addr);

    println!("PING {} ({}): {} data bytes", dest_str, dest_addr.ip(), 56);

    // --- 2. ソケットの作成 ---

    // ICMPv4プロトコルを話すための、特別な「生ソケット」を作成する
    // 生ソケットは管理者権限が必要なため、Dockerでは `--cap-add=NET_RAW` が必要
    let socket = Socket::new(Domain::IPV4, Type::from(libc::SOCK_RAW), Some(Protocol::ICMPV4))?;
    // 応答が1秒以内に返ってこなかったら、タイムアウトエラーにする設定
    socket.set_read_timeout(Some(Duration::from_secs(1)))?;

    // --- 3. メインループ ---

    // どのpingプロセスからのパケットかを識別するための、ランダムなID
    let identifier: u16 = random();
    // 送信するパケットの通し番号
    let mut sequence: u16 = 0;

    // Ctrl+Cで停止するまで無限に繰り返す
    loop {
        sequence += 1;

        // --- 4. ICMP Echo Requestパケットの組み立て ---
        let mut packet = [0u8; 64]; // ヘッダ8バイト + ペイロード56バイト = 64バイト
        packet[0] = 8; // Type: 8は「Echo Request」を意味する
        packet[1] = 0; // Code: 0
        // packet[2..4] はチェックサム。後で計算して入れる。
        packet[4..6].copy_from_slice(&identifier.to_be_bytes());
        packet[6..8].copy_from_slice(&sequence.to_be_bytes());

        // ペイロード(データ部)に現在の高精度な時刻を記録しておく
        // これにより、応答が返ってきたときに往復時間(RTT)を計算できる
        let timestamp = clock_gettime(ClockId::CLOCK_MONOTONIC).unwrap();
        let timestamp_ns = timestamp.tv_sec() as u64 * 1_000_000_000 + timestamp.tv_nsec() as u64;
        packet[8..16].copy_from_slice(&timestamp_ns.to_be_bytes());

        // チェックサムフィールドを0にした状態で一度計算し...
        let checksum_val = checksum(&packet);
        // ...計算した値を正しい位置にセットする
        packet[2..4].copy_from_slice(&checksum_val.to_be_bytes());
        
        // 5. パケットを送信
        socket.send_to(&packet, &dest_sock_addr.into())?;

        // --- 6. 応答の受信と解析 ---

        // 応答を受信するための空っぽの箱(バッファ)を用意する
        // `MaybeUninit`は、まだ中身が何もないことをコンパイラに伝えるための安全装置
        let mut recv_buf = [MaybeUninit::uninit(); 256];
        match socket.recv_from(&mut recv_buf) {
            Ok((size, peer)) => {
                let recv_time = clock_gettime(ClockId::CLOCK_MONOTONIC).unwrap();
                
                // ここが少し難しいポイントです。`recv_from`は`size`バイト分のデータを
                // バッファに書き込んだことを保証してくれます。
                // この`unsafe`ブロックは、「プログラマである私が、この`size`バイト分は
                // もう安全に読めるデータになったことをコンパイラに誓います」という宣言です。
                let initialized_buf = unsafe {
                    std::slice::from_raw_parts(recv_buf.as_ptr() as *const u8, size)
                };

                // 受信データにはIPヘッダが付いてくるので、その長さを計算して読み飛ばし、
                // 目的のICMP部分だけを取り出す
                let ip_header_len = ((initialized_buf[0] & 0x0F) * 4) as usize;
                let icmp_reply = &initialized_buf[ip_header_len..];

                // 応答パケットのTypeが`0`(Echo Reply)で、かつIDが自分が送ったものと一致するかを確認する
                if !icmp_reply.is_empty() && icmp_reply[0] == 0 && &icmp_reply[4..6] == &identifier.to_be_bytes() {
                    // ペイロードから送信時刻を読み出す
                    let sent_timestamp_ns = u64::from_be_bytes(icmp_reply[8..16].try_into().unwrap());
                    let recv_timestamp_ns = recv_time.tv_sec() as u64 * 1_000_000_000 + recv_time.tv_nsec() as u64;
                    // 現在の時刻と、パケットに記録しておいた送信時刻の差から、往復時間(RTT)を計算
                    let rtt = Duration::from_nanos(recv_timestamp_ns.saturating_sub(sent_timestamp_ns));

                    println!(
                        "{} bytes from {}: icmp_seq={} ttl={} time={:.3} ms",
                        icmp_reply.len(),
                        peer.as_socket_ipv4().unwrap().ip(),
                        u16::from_be_bytes(icmp_reply[6..8].try_into().unwrap()),
                        initialized_buf[8], // IPヘッダからTTL(生存時間)を拝借
                        rtt.as_secs_f64() * 1000.0
                    );
                }
            },
            // タイムアウトエラーの場合の処理
            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => {
                println!("Request timed out for icmp_seq={}", sequence);
            },
            // その他の受信エラーの場合の処理
            Err(e) => {
                eprintln!("An error occurred while receiving: {}", e);
                break;
            }
        }

        // 実際の`ping`コマンドのように、1秒待ってから次のパケットを送信
        thread::sleep(Duration::from_secs(1));
    }
    
    Ok(())
}

実験

cargo run -- 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=1 ttl=63 time=11.314 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=63 time=14.665 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=63 time=11.840 ms
64 bytes from 8.8.8.8: icmp_seq=4 ttl=63 time=11.270 ms
64 bytes from 8.8.8.8: icmp_seq=5 ttl=63 time=15.313 ms

8.8.8.8に対してうまく疎通が確認できました。

まとめ

今回はpingについて取り扱いました。
皆さんの学習の一助になれば幸いです。

Discussion