Rustでマルチインターフェースパケットキャプチャを実装する
この記事は Rust Advent Calendar 2018 の3日目の記事です。
本記事では、タイトルの通り、Rustでマルチインターフェースパケットキャプチャを実装します。 今回の記事で達成したい目標は以下2点です。
- Rustでネットワークプログラミングをしたい
- マルチインターフェースにすることでマルチスレッド対応したい
どうやって低レイヤを扱うか
Rustでネットワークプログラミングを行うには、libpnetが便利なので、今回はこちらを利用します。libpnetを使えるようCargo.toml
には以下を記載しておきます。
[dependencies] serde = "1.0" serde_derive = "1.0" [dependencies.pnet] version = "0.21.0"
アーキテクチャ
今回実装するパケットキャプチャのアーキテクチャは以下です。
I/F1
からI/Fn
の各インターフェース用のスレッドがrx
を監視します。
rx
で受信したパケットをqueue
に渡します。
packet handlerは、queue
にあるパケットを一つずつ処理(標準出力に表示)する役割を果たします。
tcpdump
などでは対象や表示形式など引数を設定できますが、今回はL2-4までを対象として、表示対象を以下の通りとします。ARPは遊び心で加えました。
Layer | 表示対象 |
---|---|
L2 | 送信元MACアドレス |
L2 | 宛先MACアドレス |
L2-3 | ARP request |
L2-3 | ARP reply |
L3 | 送信元IPアドレス |
L3 | 宛先IPアドレス |
L4 | 送信元ポート番号 |
L4 | 宛先ポート番号 |
実装
パケットの実装
各パケットが どのインターフェースから着信したものか を保持するために、queueに渡すパケットを以下の構造体にまとめます。
#[derive(Clone, Debug)] struct PacketWithInterface { interface: NetworkInterface, packet: Vec<u8>, }
Queueの実装
各インターフェースのrx
を監視する部分をマルチスレッドで実装します。
各スレッドからqueue
を共有できるようArc
とMutex
で包みます。
#[derive(Clone, Debug)] struct Queue<T: Send> { inner: Arc<Mutex<VecDeque<T>>>, }
上記Queue
に対してnew
、get
、add
を実装します。
注意が必要なのはget
、add
を行う際にlock
を取ってあげることくらいでしょうか。
なお、lock
を取った際に_queue
で束縛しており、_queue
がスコープから外れたタイミングで自動的にunlock
されます。
impl<T: Send> Queue<T> { fn new() -> Self { Self { inner: Arc::new(Mutex::new(VecDeque::new())) } } fn get(&self) -> Option<T> { let _queue = self.inner.lock(); if let Ok(mut queue) = _queue { queue.pop_front() } else { None } } fn add(&self, obj: T) -> usize { let _queue = self.inner.lock(); if let Ok(mut queue) = _queue { queue.push_back(obj); queue.len() } else { 0 } } }
packet handlerの実装
queue
からパケットを取り出す部分はmain内で実装するので、ここでは取り出したパケットの処理することに注力します。
基本的にはバイト列の先頭からL2
-> L3
-> L4
と、各レイヤーのヘッダを確認し、該当するフィールドを表示する処理になります。
なお、ARPパケットはEthernetヘッダに記載されているEtherTypeで判断できます。
fn handle_ethernet_frame(interface: &NetworkInterface, ethernet: &EthernetPacket) { println!( "{}: {} > {}", interface.name, ethernet.get_source(), ethernet.get_destination(), ); print!(" {}: ", ethernet.get_ethertype()); match ethernet.get_ethertype() { EtherTypes::Arp => { let arp = arp::ArpPacket::new(ethernet.payload()).unwrap(); match arp.get_operation() { arp::ArpOperations::Reply => { println!( "ARP reply({}): {} -> {}", arp.get_sender_proto_addr(), arp.get_sender_hw_addr(), arp.get_target_hw_addr() ); } arp::ArpOperations::Request => { println!( "ARP request({}): {} -> {}", arp.get_target_proto_addr(), arp.get_sender_hw_addr(), arp.get_target_hw_addr() ); } _ => (), } } EtherTypes::Ipv4 => { let ip = Ipv4Packet::new(ethernet.payload()).unwrap(); println!("{} -> {}", ip.get_source(), ip.get_destination()); handle_ip_packet(&interface, &ip) } _ => (), } } fn handle_ip_packet(interface: &NetworkInterface, ip: &Ipv4Packet) { print!(" {}: ", ip.get_next_level_protocol()); match ip.get_next_level_protocol() { IpNextHeaderProtocols::Tcp => { let tcp = tcp::TcpPacket::new(ip.payload()).unwrap(); handle_tcp_packet(&interface, &tcp); } IpNextHeaderProtocols::Udp => { let udp = udp::UdpPacket::new(ip.payload()).unwrap(); handle_udp_packet(&interface, &udp); } _ => (), } } fn handle_tcp_packet(_interface: &NetworkInterface, tcp: &tcp::TcpPacket) { println!("{} -> {}", tcp.get_source(), tcp.get_destination()); } fn handle_udp_packet(_interface: &NetworkInterface, udp: &udp::UdpPacket) { println!("{} -> {}", udp.get_source(), udp.get_destination()); }
mainの実装
インターフェースを初期化
キャプチャ対象とするインターフェースをここで指定します。 configファイルから読み取る実装もしてみましたが、今回は簡単のためmainにべた書きとしました。
let interface_names: HashSet<&str> = vec!["RT2_veth0", "RT2_veth1"] .into_iter() .collect(); let interfaces: Vec<NetworkInterface> = datalink::interfaces() .into_iter() .filter(|interface: &NetworkInterface| interface_names.contains(interface.name.as_str())) .collect();
パケットを受信する
各インターフェースのrx
を取得して、ループでrx
を監視します。そしてrx
で受信したパケットをqueue
に投げ込みます。
今回の実装でqueue
に投げ込む部分が一番ハマりました。
rx.next()
で受信できるパケットは&[u8]
なので参照を渡そうとすると、spawn
で作ったクロージャより長生きできず全然コンパイルが通りませんでした。
let mut handles: Vec<_> = interfaces.into_iter() .map(|interface| { let queue = queue.clone(); thread::spawn(move || { let mut rx = datalink::channel(&interface, Default::default()) .map(|chan| match chan { Ethernet(_, rx) => rx, _ => panic!("could not initialize datalink channel {:?}", interface.name), }).unwrap(); loop { match rx.next() { Ok(src) => { queue.add(PacketWithInterface { interface: interface.clone(), packet: src.to_owned(), }); } Err(_) => { continue; } } } }) } ) .collect();
パケットを処理する
queue
からパケットを取り出し、EthernetPacket
にmatch
した後、handle_ethernet_frame
に渡します。渡した後は上述のpacket handlerの責務です。
異常パケットを受信した場合はパケットをドロップし、無視します。異常パケット1つ受信しただけでpanic
してプロセスごと落ちない設計としました。
handles.push(thread::spawn(move || { loop { let queue = queue.clone(); match queue.get() { Some(packet_with_interface) => { let _packet = packet_with_interface.packet.deref(); match EthernetPacket::new(_packet) { Some(packet) => { handle_ethernet_frame(&packet_with_interface.interface, &packet); }, _ => { continue; } } }, _ => { continue; } } } }));
実際に動かしてみる
環境
検証用の環境は以下の通りです。
$ uname -a Linux vagrant-ubuntu-trusty-64 3.13.0-161-generic #211-Ubuntu SMP Wed Oct 3 14:52:35 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
ネットワーク構築
netns
で以下のネットワークを用意します。
構築スクリプトはこちら。
RT2
の★マークの箇所RT2_veth0
とRT2_veth1
がパケットキャプチャの対象インターフェースです。
pingを打ったときのパケットキャプチャ
host
の192.168.0.1
からRT1
の192.168.1.254
へpingを打ってみます。
# host $ ping 192.168.1.254 -c 1 PING 192.168.1.254 (192.168.1.254) 56(84) bytes of data. 64 bytes from 192.168.1.254: icmp_seq=1 ttl=63 time=0.706 ms --- 192.168.1.254 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.706/0.706/0.706/0.000 ms
このときのパケットキャプチャは以下のようになりました。
# RT2 $ sudo ./target/debug/pcap RT2_veth1: d6:c7:d0:99:8e:bc > ff:ff:ff:ff:ff:ff Arp: ARP request(192.168.1.254): d6:c7:d0:99:8e:bc -> 00:00:00:00:00:00 RT2_veth1: f6:63:73:99:cc:cd > d6:c7:d0:99:8e:bc Arp: ARP reply(192.168.1.254): f6:63:73:99:cc:cd -> d6:c7:d0:99:8e:bc RT2_veth1: d6:c7:d0:99:8e:bc > f6:63:73:99:cc:cd Ipv4: 192.168.0.1 -> 192.168.1.254 Icmp: RT2_veth1: f6:63:73:99:cc:cd > d6:c7:d0:99:8e:bc Ipv4: 192.168.1.254 -> 192.168.0.1 Icmp: RT2_veth1: f6:63:73:99:cc:cd > d6:c7:d0:99:8e:bc
最初はまったく通信がないので、ARPによってIPアドレスからMACアドレスの解決を行っています。
その後、192.168.0.1
から192.168.1.254
へのICMP通信が発生し、その応答が返ってきていることがわかります。
TCP通信を発生させたときのパケットキャプチャ
RT1
で以下のように1234
ポートでリッスンしておきます。
# RT1 $ nc -l -p 1234
host
の192.168.0.1
からRT1
の192.168.1.254:1234
へ接続します。
# host $ nc 192.168.1.254 1234
このときのパケットキャプチャは以下のようになりました。
# RT2 RT2_veth1: d6:c7:d0:99:8e:bc > f6:63:73:99:cc:cd Ipv4: 192.168.0.1 -> 192.168.1.254 Tcp: 45693 -> 1234 RT2_veth1: f6:63:73:99:cc:cd > d6:c7:d0:99:8e:bc Ipv4: 192.168.1.254 -> 192.168.0.1 Tcp: 1234 -> 45693 RT2_veth1: d6:c7:d0:99:8e:bc > f6:63:73:99:cc:cd Ipv4: 192.168.0.1 -> 192.168.1.254 Tcp: 45693 -> 1234
正しくキャプチャできていますね。
45693
は送信元なのでhighポートがOSによって割り当てられています。
また、3つ目のパケットから、nc
コマンドでリッスンしていると応答がないのでTCPの再送が行われていることがわかります。
最後に
以上、Rustによるマルチインターフェースパケットキャプチャでした。 本文中でも述べましたがlifetimeですごいハマりました。 少しずつRustを勉強していますが、まだまだですね。今回みたいになにか作っているといろいろハマって理解が深まるので前向きにがんばります。
ご指摘等あればtwitterまでご連絡ください。