自己发出第一个网络包:用 ARP 找到网关的 MAC 地址

本文是对 Crafting ARP packets to find a remote host's MAC address 的整理与翻译。

内容结构概览

  1. 为什么不能直接序列化 ICMP/IP/Ethernet:发送完整 Ethernet frame 前,需要源 MAC、目标 MAC、源 IP、目标 IP。
  2. 目标 MAC 不是 8.8.8.8 的 MAC:局域网内第一跳是网关,Ethernet 帧的目标 MAC 应该是网关的 MAC。
  3. ARP 的作用:通过 ARP 查询某个局域网 IPv4 地址对应的 MAC 地址。
  4. 为什么这里不用 SendARP():Windows 已有 API 和 ARP 缓存,但项目目标是自己构造包。
  5. 先获取本机 IP 和 MAC:ARP 请求里必须填 sender MAC 和 sender IP,方便对方回复。
  6. GetIpAddrTable 获取本机 IPv4 地址:复用第 8 篇的变长结构体处理方式。
  7. GetAdaptersInfo 获取本机 MAC 地址 :处理链表式 C 结构体、NonNullDerefMut 和新的错误码。
  8. 定义 NIC 结构体:一次性返回网卡 GUID、网关 IP、本机 IP、本机 MAC。
  9. 建模 ARP 包 :定义 OperationHardwareTypearp::Packet,只支持 Ethernet + IPv4。
  10. 解析 ARP 包 :用 nom 读取 hardware type、protocol type、地址长度、operation、sender/target 地址。
  11. 序列化 ARP 包 :用 cookie-factory 写出 ARP 字段。
  12. 把 ARP 放进 Ethernet frame :ARP 的 EtherType 是 0x0806,所以需要扩展 ethernet::Payload
  13. 构造广播 ARP Request :目标 MAC 未知,所以 Ethernet 目标地址用 FF-FF-FF-FF-FF-FF
  14. 第一次真正发包 :序列化 Ethernet frame 后通过 iface.send() 发到网卡。
  15. 接收 ARP Reply :修改 process_packet,不再只打印 ICMP,也能打印 ARP。
  16. 用 channel 等待 ARP 结果 :用 HashMap<ipv4::Addr, Sender<ethernet::Addr>> 记录等待中的 ARP 查询。
  17. 线程与生命周期问题 :普通 std::thread::spawn 要求捕获值 'static,改用 crossbeam_utils::thread::scope
  18. 小型 API 重构 :给 arp::Packetethernet::Payloadethernet::Frame 加便捷方法,让查询代码变得更清楚。
  19. 阶段性成果:程序第一次自己构造网络流量、发出 ARP 请求,并成功收到网关 MAC 地址。

上一篇已经完成了 ICMP 这一层的解析和序列化。程序能从 rawsock 抓到的 Ethernet frame 里解析 IPv4,再进入 ICMP,读出 Echo Request、Echo Reply、Time Exceeded 等消息;也能把解析出的 ICMP packet 用 cookie-factory 序列化回字节,并通过 checksum 验证结果和原始包完全一致。

如果只是顺着协议栈往下想,下一步似乎很自然:把 ICMP packet 放进 IPv4 packet,再把 IPv4 packet 放进 Ethernet frame,最后序列化整个 frame,然后发出去。这样看起来就离"自己实现 ping"只差一步了。

但这一步不能直接跳。因为一个 Ethernet frame 想要真正发出去,需要知道四个关键地址:源 MAC 地址、目标 MAC 地址、源 IP 地址、目标 IP 地址。目标 IP 是用户输入的,比如 8.8.8.8;源 IP 是本机默认网卡的 IPv4 地址;源 MAC 是本机默认网卡的物理地址;目标 MAC 则不是 8.8.8.8 的 MAC,而是下一跳网关的 MAC。这个区别非常重要。

在 IP 层,我们的目标确实是 8.8.8.8,它可能在互联网的另一端,要经过多个路由器才能到达。但在 Ethernet 层,我们只能把 frame 发给同一个二层网络里的下一跳设备。对于普通家庭或办公网络来说,下一跳通常就是路由器,也就是默认网关。因此,要发送一个封装了 IPv4/ICMP 的 Ethernet frame,目标 MAC 应该是网关的 MAC 地址。

那么,怎样从网关 IP 找到网关 MAC?答案就是 ARP,Address Resolution Protocol,地址解析协议。


一、为什么目标 MAC 是网关,而不是最终目标

要理解这一篇,先要重新摆正 MAC 地址和 IP 地址的关系。IP 地址表示逻辑上的目标。你执行 ping 8.8.8.8,目标 IP 就是 8.8.8.8。这个 IP 地址会在多个网络之间被路由,最终如果一切顺利,会到达 Google DNS 服务器。

但 Ethernet 帧只在当前链路层网络中传递。它不知道"整个互联网路径",也不会直接跨越很多跳去找远端服务器。它只关心当前这一跳:这个 frame 应该交给同一个局域网中的哪台设备。

如果目标 IP 就在同一个局域网内,比如你要访问 192.168.1.20,那么 Ethernet 目标 MAC 可以是那台机器的 MAC。但如果目标 IP 在外网,比如 8.8.8.8,本机不会直接知道远端服务器的 MAC,也不需要知道。它只需要把包交给默认网关,由网关继续转发。于是 Ethernet frame 的目标 MAC 就应该是网关的 MAC。

前面第 8 篇已经能从 Windows 的路由表里找到默认路由,也就是 0.0.0.0/0,并拿到下一跳地址。示例里下一跳是 192.168.1.254。这告诉我们:如果要访问外网,下一跳是 192.168.1.254。现在要解决的问题就是:192.168.1.254 对应的 MAC 地址是什么?

ARP 正是用来回答这个问题的。它在局域网里广播一个问题:"谁拥有这个 IP?请把你的 MAC 告诉我。"拥有该 IP 的设备收到后,会返回 ARP Reply,告诉查询者自己的 MAC 地址。


二、为什么不用系统已有的 ARP 能力

Windows 其实有现成的 API,例如 SendARP(),可以查询某个 IP 地址对应的 MAC 地址。操作系统也维护着 ARP 表。只要本机能通过 DHCP 获取地址,通常也已经和默认网关通信过,所以网关的 MAC 地址大概率已经存在本地 ARP 缓存里。

从实用角度看,直接查系统 ARP 表或调用 SendARP() 更简单,也更礼貌。因为发送 ARP Request 本质上是广播,会让局域网里的所有设备都听到。为了查询一个已经可能缓存好的网关 MAC,再广播一次 ARP,从工程角度看并不必要。

但这个系列不是为了写最省事的工具,而是为了自己理解并构造网络协议。前面已经自己解析了 Ethernet、IPv4、ICMP,也自己序列化了 ICMP。到了 ARP 这里,如果直接调用 SendARP(),又会把关键步骤交还给操作系统。为了真正走通"自己发网络包"这条路,这一篇选择自己构造 ARP Request。

这也是这篇的关键意义:这是项目第一次真正自己构造一个 Ethernet frame,注入网卡,并收到对方回复。前面虽然能抓包、能解析、能序列化,但还没有真正把自制网络流量发出去。


三、ARP 请求需要哪些信息

构造 ARP Request 之前,先要知道 ARP 包里需要填什么。对于最常见的 Ethernet + IPv4 场景,ARP 包大致包含这些字段:

text 复制代码
Hardware Type        以太网是 1
Protocol Type        IPv4 是 0x0800
Hardware Length      MAC 地址长度是 6
Protocol Length      IPv4 地址长度是 4
Operation            Request 是 1,Reply 是 2
Sender MAC           发送者 MAC
Sender IP            发送者 IP
Target MAC           目标 MAC,请求时未知,填 0
Target IP            要查询的目标 IP

在我们的问题里,目标 IP 是网关 IP,例如 192.168.1.254。目标 MAC 还不知道,所以在 ARP Request 中填全 0。发送者 MAC 是本机网卡 MAC,发送者 IP 是本机网卡 IP。对方收到请求后,会根据 sender MAC 和 sender IP 知道该把回复发给谁。

这就引出另一个准备工作:除了上一节已经拿到的默认网卡 GUID 和网关 IP,还需要拿到本机默认网卡的 IPv4 地址和 MAC 地址。

上一节的 GetInterfaceInfo 能拿到接口 index 和 name,但它并不直接给出本机 IP 或本机 MAC。因此这一篇继续扩展 netinfo 模块,引入两个 Win32 API:GetIpAddrTable 用于获取接口到 IPv4 地址的映射,GetAdaptersInfo 用于获取更详细的网卡信息,其中包括物理地址,也就是 MAC。


四、用 GetIpAddrTable 找本机 IP 地址

前面已经用 GetIpForwardTable 解析路由表,用 GetInterfaceInfo 解析接口表。这些 API 都有一个共同模式:先传空指针获取所需缓冲区大小,再分配足够内存,第二次调用真正填充数据。第 8 篇为这种 C 变长结构体封装了 VLS<T>,现在可以继续复用。

先在 bind! 宏里加入 GetIpAddrTable

rust 复制代码
crate::bind! {
    library "IPHLPAPI.dll";

    fn GetIpForwardTable(
        table: *mut IpForwardTable,
        size: *mut u32,
        order: bool
    ) -> u32;

    fn GetInterfaceInfo(
        info: *mut IpInterfaceInfo,
        size: *mut u32
    ) -> u32;

    fn GetIpAddrTable(
        table: *mut IpAddrTable,
        size: *mut u32,
        order: bool
    ) -> u32;
}

GetIpAddrTable 返回的表结构和前面的路由表类似:头部有数量字段,后面跟着变长数组。Rust 里可以写成:

rust 复制代码
#[repr(C)]
#[derive(Debug)]
pub struct IpAddrTable {
    num_entries: u32,
    entries: [IpAddrRow; 1],
}

再给它一个 entries() 方法:

rust 复制代码
impl IpAddrTable {
    fn entries(&self) -> &[IpAddrRow] {
        unsafe {
            slice::from_raw_parts(
                &self.entries[0],
                self.num_entries as usize,
            )
        }
    }
}

每一行对应 C 里的 MIB_IPADDRROW_W2K

rust 复制代码
#[repr(C)]
#[derive(CustomDebug)]
pub struct IpAddrRow {
    pub addr: ipv4::Addr,
    pub index: u32,
    pub mask: ipv4::Addr,
    pub bcast_addr: ipv4::Addr,
    pub reasm_size: u32,

    #[debug(skip)]
    unused1: u16,

    #[debug(skip)]
    unused2: u16,
}

这里有几个字段是 IPv4 地址,因此直接用项目已有的 ipv4::Addr 表示。index 对应接口 index。前面从默认路由里已经拿到了默认接口的 if_index,现在只需要在 IpAddrTable 里找到 index == if_index 的那一行,就能得到本机在默认网卡上的 IPv4 地址。

示例输出类似:

text 复制代码
IpAddrRow {
    addr: 192.168.1.16,
    index: 5,
    mask: 255.255.255.0,
    bcast_addr: 1.0.0.0,
    reasm_size: 65535,
}

这就拿到了本机 IP,例如 192.168.1.16


五、用 GetAdaptersInfo 找本机 MAC 地址

接下来要找本机默认网卡的 MAC 地址。GetIpNetTable 可以查询 IP 到物理地址的邻居表,但它主要包含邻居,也就是其他主机的 MAC,不适合直接查本机网卡 MAC。这里需要用 GetAdaptersInfo

GetAdaptersInfo 的 C 签名是:

c 复制代码
ULONG GetAdaptersInfo(
  PIP_ADAPTER_INFO AdapterInfo,
  PULONG           SizePointer
);

它返回的不是"表头 + 变长数组",而是一组链表节点。每个 IP_ADAPTER_INFO 里有一个 Next 指针,指向下一个适配器信息。这个结构体很长,字段很多,包括 adapter name、description、address length、address、index、DHCP 信息、IP 地址列表、网关列表、租约时间等。

我们现在只关心少数字段:NextAddressLengthAddressIndex。Rust 结构体可以只覆盖到我们需要的部分:

rust 复制代码
const MAX_ADAPTER_NAME_LENGTH: usize = 256;
const MAX_ADAPTER_DESCRIPTION_LENGTH: usize = 128;

#[repr(C)]
#[derive(CustomDebug)]
pub struct IpAdapterInfo {
    pub next: Option<NonNull<IpAdapterInfo>>,
    pub combo_index: u32,

    #[debug(skip)]
    pub adapter_name: [u8; MAX_ADAPTER_NAME_LENGTH + 4],

    #[debug(skip)]
    pub description: [u8; MAX_ADAPTER_DESCRIPTION_LENGTH + 4],

    pub address_length: u32,
    pub address: ethernet::Addr,
    pub address_rest: u16,
    pub index: u32,
    pub typ: u32,
}

这里有几个点很容易踩坑。

第一,C 里的 char 是 1 字节,而 Rust 的 char 是 Unicode scalar value,通常占 4 字节。所以 AdapterNameDescription 不能写成 [char; N],而应该写成 [u8; N]。这类错误在 FFI 里非常隐蔽,因为结构体布局会完全错位。

第二,Address 在 C 里是一个固定长度数组,最大长度不一定只有 6。但对于 Ethernet 网卡,我们期望 MAC 长度是 6。因此这里用 ethernet::Addr 表示前 6 字节,再用 address_rest: u16 占掉剩余两个字节,保证字段偏移正确。后面实际使用前,会检查 address_length == 6

第三,这个结构体没有把 C 结构体后面的所有字段都写出来。为什么这里可以不像 IpForwardRow 那样完整占位?因为遍历链表时,不是靠"当前结构体大小"推导下一项地址,而是靠结构体里的 next 指针跳转。因此只要我们要读取的字段偏移正确,就不需要覆盖所有后续字段。

加入绑定:

rust 复制代码
crate::bind! {
    library "IPHLPAPI.dll";

    fn GetIpForwardTable(
        table: *mut IpForwardTable,
        size: *mut u32,
        order: bool
    ) -> u32;

    fn GetInterfaceInfo(
        info: *mut IpInterfaceInfo,
        size: *mut u32
    ) -> u32;

    fn GetIpAddrTable(
        table: *mut IpAddrTable,
        size: *mut u32,
        order: bool
    ) -> u32;

    fn GetAdaptersInfo(
        list: *mut IpAdapterInfo,
        size: *mut u32
    ) -> u32;
}

然后用 VLS::new 调用它。但这里会遇到两个新问题。

第一个问题是 GetAdaptersInfo 在第一次传空 buffer 时返回的错误码不是 ERROR_INSUFFICIENT_BUFFER,而是 ERROR_BUFFER_OVERFLOW。这两个错误码都表示"缓冲区不够,但 size 已经告诉你需要多大"。因此 VLS::new 需要接受两种情况:

rust 复制代码
const ERROR_INSUFFICIENT_BUFFER: u32 = 122;
const ERROR_BUFFER_OVERFLOW: u32 = 111;

第二个问题是遍历链表时,需要从 VLS<IpAdapterInfo> 得到一个可变引用,进而构造 NonNull。之前 VLS<T> 只实现了 Deref,没有实现 DerefMut,所以不能通过 &mut *adapter_list_head 得到可变引用。要补上:

rust 复制代码
use std::ops::DerefMut;

impl<T> DerefMut for VLS<T> {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { mem::transmute(self.v.as_ptr()) }
    }
}

这里本质上仍然是把内部 Vec<u8> 的内存解释为 TDerefMut 的实现要格外谨慎,因为可变引用意味着独占访问。这个场景下,VLS 确实拥有这段内存,因此可以在封装内部这么做。

遍历链表的逻辑大致是:

rust 复制代码
let mut adapter_list_head =
    VLS::new(|ptr, size| GetAdaptersInfo(ptr, size))?;

let mut current = NonNull::new(&mut *adapter_list_head);

loop {
    if let Some(adapter) = current {
        let adapter = unsafe { adapter.as_ref() };

        if adapter.address_length == 6 && adapter.index == entry.if_index {
            println!("adapter = {:#?}", adapter);
            break;
        }

        current = adapter.next;
    } else {
        break;
    }
}

找到的结果类似:

text 复制代码
IpAdapterInfo {
    next: None,
    combo_index: 5,
    address_length: 6,
    address: F4-D1-08-0B-7E-BC,
    index: 5,
}

这就拿到了本机默认网卡的 MAC 地址。


六、把默认网卡信息收拢成 NIC

现在 netinfo 模块已经能得到四类信息:网卡 GUID、默认网关 IP、本机 IP、本机 MAC。与其继续只返回 GUID,不如定义一个结构体,把这些信息一起返回:

rust 复制代码
#[derive(Debug)]
pub struct NIC {
    pub guid: String,
    pub gateway: ipv4::Addr,
    pub address: ipv4::Addr,
    pub phy_address: ethernet::Addr,
}

原来的 default_nic_guid() 可以改成 default_nic()。它内部仍然做几件事:

先用 GetIpForwardTable 找默认路由,拿到默认接口 index 和下一跳地址,也就是网关 IP。再用 GetInterfaceInfo 找接口 name,从中截出 GUID。然后用 GetIpAddrTable 找该接口对应的本机 IPv4 地址。最后用 GetAdaptersInfo 遍历 adapter 链表,找 index 相同且 address_length == 6 的 adapter,取出 MAC 地址。

逻辑虽然比较长,但都是前几篇已经建立过的模式:Win32 API、变长结构体、链表、接口 index、GUID、IPv4 地址、MAC 地址。完成后,主程序启动时可以打印:

text 复制代码
Using NIC {
    guid: "{0E89380B-814A-48FC-86C4-5C51B8040CB2}",
    gateway: 192.168.1.254,
    address: 192.168.1.16,
    phy_address: F4-D1-08-0B-7E-BC,
}

这一步很重要。后面所有手工发包都需要这些信息。构造 ARP Request 要用本机 IP、本机 MAC 和网关 IP;构造 Ethernet frame 要用本机 MAC;构造 IPv4 packet 要用本机 IP 和目标 IP。NIC 就是把这些"本机默认出站接口上下文"收拢到一个地方。

同时,错误类型也要继续扩展。例如找不到默认接口 IP、找不到默认接口 MAC,都应该有独立错误:

rust 复制代码
#[derive(Debug, Error)]
pub enum Error {
    #[error("ersatz could not determine the IP address of the default network interface")]
    DefaultInterfaceNoIPAddr,

    #[error("ersatz could not determine the MAC address of the default network interface")]
    DefaultInterfaceNoMACAddr,
}

这里已经开始用 thiserror 管理错误。相比手写 Display,它更适合项目继续变大后的错误定义。


七、建模 ARP 包

拿到本机 IP、本机 MAC 和网关 IP 后,可以开始实现 ARP 模块。先在 main.rs 中加入:

rust 复制代码
mod arp;

ARP 包里有几个枚举非常适合用 Rust 表示。Operation 只有 Request 和 Reply:

rust 复制代码
#[derive(Debug, TryFromPrimitive, Clone, Copy)]
#[repr(u16)]
pub enum Operation {
    Request = 1,
    Reply = 2,
}

Hardware Type 对当前项目只支持 Ethernet:

rust 复制代码
#[derive(Debug, TryFromPrimitive, Clone, Copy)]
#[repr(u16)]
pub enum HardwareType {
    Ethernet = 1,
}

ARP packet 本身可以定义为:

rust 复制代码
#[derive(Debug)]
pub struct Packet {
    pub operation: Operation,
    pub sender_hw_addr: ethernet::Addr,
    pub sender_ip_addr: ipv4::Addr,
    pub target_hw_addr: ethernet::Addr,
    pub target_ip_addr: ipv4::Addr,
}

这里没有把 hardware_typeprotocol_typehlenplen 都存进结构体。原因是当前只支持 Ethernet + IPv4,因此这些字段都是固定值:hardware type 是 Ethernet,protocol type 是 IPv4,hardware length 是 6,protocol length 是 4。结构体只保存真正变化的部分:操作类型、发送者地址、目标地址。

这个设计和前面 ICMP 的序列化类似。不是所有协议字段都必须作为可变字段保存。如果某些字段由类型或上下文唯一决定,序列化时可以直接写固定值。


八、解析 ARP 包

解析简单枚举时,可以读取 big-endian u16,再用 try_from 转成 enum:

rust 复制代码
impl Operation {
    pub fn parse(i: parse::Input) -> parse::Result<Option<Self>> {
        use nom::{
            combinator::map,
            error::context,
            number::complete::be_u16,
        };

        context("Operation", map(be_u16, Self::try_from))(i)
    }
}

impl HardwareType {
    pub fn parse(i: parse::Input) -> parse::Result<Option<Self>> {
        use nom::{
            combinator::map,
            error::context,
            number::complete::be_u16,
        };

        context("HardwareType", map(be_u16, Self::try_from))(i)
    }
}

这里返回 Option<Self>,而不是直接返回错误。意思是:解析本身成功读到了一个 u16,但这个值可能不是我们支持的枚举值。具体要不要把它当错误,由上层 Packet::parse 决定。

Packet::parse 的逻辑是按 ARP 格式逐字段读取:

rust 复制代码
impl Packet {
    pub fn parse(i: parse::Input) -> parse::Result<Self> {
        let original_i = i;

        use nom::{
            number::complete::be_u8,
            sequence::tuple,
        };

        let (i, (htype, ptype, _hlen, _plen)) = tuple((
            HardwareType::parse,
            ethernet::EtherType::parse,
            be_u8,
            be_u8,
        ))(i)?;

        if let Some(HardwareType::Ethernet) = htype {
            // supported
        } else {
            let msg = "arp: only Ethernet is supported".into();
            return Err(nom::Err::Error(parse::Error::custom(original_i, msg)));
        }

        if let Some(ethernet::EtherType::IPv4) = ptype {
            // supported
        } else {
            let msg = "arp: only IPv4 is supported".into();
            return Err(nom::Err::Error(parse::Error::custom(original_i, msg)));
        }

        let (i, operation) = Operation::parse(i)?;

        let operation = match operation {
            Some(operation) => operation,
            _ => {
                let msg =
                    "arp: only Request and Reply operations are supported".into();

                return Err(nom::Err::Error(parse::Error::custom(original_i, msg)));
            }
        };

        let (i, (sender_hw_addr, sender_ip_addr)) =
            tuple((ethernet::Addr::parse, ipv4::Addr::parse))(i)?;

        let (i, (target_hw_addr, target_ip_addr)) =
            tuple((ethernet::Addr::parse, ipv4::Addr::parse))(i)?;

        let res = Self {
            operation,
            sender_hw_addr,
            sender_ip_addr,
            target_hw_addr,
            target_ip_addr,
        };

        Ok((i, res))
    }
}

这里有几个设计选择。

第一,只支持 Ethernet + IPv4。ARP 也可以用于其他硬件类型或协议类型,但当前项目只为了在 Ethernet 上查询 IPv4 对应的 MAC,因此不支持的组合直接返回自定义解析错误。

第二,读取 _hlen_plen 后并没有进一步检查它们是否等于 6 和 4。更严格的实现应该检查。当前代码通过 hardware/protocol 类型和固定 parser 结构隐含了这个假设,但协议解析器越完整,越应该把这些字段也验证掉。

第三,sender/target 地址解析直接复用已有的 ethernet::Addr::parseipv4::Addr::parse。这就是前面模块化的收益。MAC 和 IPv4 地址已经有类型和 parser,现在 ARP parser 可以像拼积木一样组合它们。


九、序列化 ARP 包

解析完成后,还要能生成 ARP 包。简单字段先实现 serializer:

rust 复制代码
impl Operation {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::bytes::be_u16;
        be_u16(*self as u16)
    }
}

impl HardwareType {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::bytes::be_u16;
        be_u16(*self as u16)
    }
}

IPv4 地址和 Ethernet 地址也需要能序列化。它们本质上就是固定长度字节数组:

rust 复制代码
impl ipv4::Addr {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::combinator::slice;
        slice(&self.0)
    }
}

impl ethernet::Addr {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::combinator::slice;
        slice(&self.0)
    }
}

EtherType 也需要 serializer:

rust 复制代码
impl ethernet::EtherType {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::bytes::be_u16;
        be_u16(*self as u16)
    }
}

然后 ARP packet 的 serializer 可以直接组合:

rust 复制代码
impl Packet {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::{
            bytes::be_u8,
            sequence::tuple,
        };

        let htype = HardwareType::Ethernet.serialize();
        let ptype = ethernet::EtherType::IPv4.serialize();
        let hlen = be_u8(6);
        let plen = be_u8(4);

        tuple((
            htype,
            ptype,
            hlen,
            plen,
            self.operation.serialize(),
            self.sender_hw_addr.serialize(),
            self.sender_ip_addr.serialize(),
            self.target_hw_addr.serialize(),
            self.target_ip_addr.serialize(),
        ))
    }
}

这段代码和 ARP 格式几乎一一对应。cookie-factory 的优势在这里非常明显:序列化代码不再是手动 push 字节,而是像协议声明一样组合字段。


十、ARP 不能单独发送,必须放进 Ethernet frame

现在有了 arp::Packet::serialize(),但还不能直接把 ARP bytes 注入网卡。因为 rawsock 发送和接收的是 Ethernet frame。前面抓包时,看到的也是完整 Ethernet frame:目标 MAC、源 MAC、EtherType、payload。ARP 必须作为 Ethernet payload 发送,EtherType 是 0x0806

因此要扩展 ethernet::EtherType

rust 复制代码
#[derive(Debug, TryFromPrimitive, Clone, Copy)]
#[repr(u16)]
pub enum EtherType {
    IPv4 = 0x0800,
    ARP = 0x0806,
}

再扩展 Ethernet payload:

rust 复制代码
#[derive(Debug)]
pub enum Payload {
    IPv4(ipv4::Packet),
    ARP(arp::Packet),
    Unknown,
}

parser 也要支持 ARP:

rust 复制代码
impl Frame {
    pub fn parse(i: parse::Input) -> parse::Result<Self> {
        context("Ethernet frame", |i| {
            let (i, (dst, src)) =
                tuple((Addr::parse, Addr::parse))(i)?;

            let (i, ether_type) = EtherType::parse(i)?;

            let (i, payload) = match ether_type {
                Some(EtherType::IPv4) => {
                    map(ipv4::Packet::parse, Payload::IPv4)(i)?
                }

                Some(EtherType::ARP) => {
                    map(arp::Packet::parse, Payload::ARP)(i)?
                }

                None => (i, Payload::Unknown),
            };

            let res = Self {
                dst,
                src,
                ether_type,
                payload,
            };

            Ok((i, res))
        })(i)
    }
}

序列化也要跟上。可以让 ethernet::Payload 在序列化时负责写出 EtherType 和 payload 本体:

rust 复制代码
impl Payload {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::sequence::tuple;

        move |out| match self {
            Self::ARP(ref packet) => {
                tuple((
                    EtherType::ARP.serialize(),
                    packet.serialize(),
                ))(out)
            }

            Self::IPv4(_) => unimplemented!(),
            Self::Unknown => unimplemented!(),
        }
    }
}

然后 Ethernet frame serializer 就只需要写目标 MAC、源 MAC 和 payload:

rust 复制代码
impl Frame {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::sequence::tuple;

        tuple((
            self.dst.serialize(),
            self.src.serialize(),
            self.payload.serialize(),
        ))
    }
}

这里的设计有一点值得注意:Frame 结构体里可能仍然有 ether_type 字段,但序列化时实际 EtherType 可以由 Payload 决定。这样可以避免 ether_typepayload 不一致的问题。例如 payload 是 ARP,就应该写 0x0806;payload 是 IPv4,就应该写 0x0800。更理想的设计可以进一步移除冗余字段,或者让类型系统保证一致性。


十一、构造一个 ARP Request

现在准备工作全部就绪。要查询网关 MAC,需要构造一个 ARP Request:

rust 复制代码
let arp_packet = arp::Packet {
    operation: arp::Operation::Request,
    sender_hw_addr: nic.phy_address,
    sender_ip_addr: nic.address,
    target_hw_addr: ethernet::Addr::zero(),
    target_ip_addr: nic.gateway,
};

这里的 target_hw_addr 填全 0,因为我们正是要查询它。可以给 ethernet::Addr 加一个方便方法:

rust 复制代码
impl Addr {
    pub fn zero() -> Self {
        Self([0, 0, 0, 0, 0, 0])
    }
}

接下来要把这个 ARP packet 放进 Ethernet frame。这里会遇到一个典型的"鸡生蛋"问题:Ethernet frame 必须有目标 MAC,但我们发送 ARP 的目的就是为了知道目标 MAC。

解决办法是广播。Ethernet 有一个特殊广播 MAC 地址:

text 复制代码
FF-FF-FF-FF-FF-FF

发往这个地址的 frame 会被同一个二层网络里的设备接收。于是 ARP Request 的 Ethernet 目标地址用广播地址,ARP payload 里的 target MAC 用全 0,target IP 填网关 IP。拥有该 IP 的设备收到后,会单播回复给本机。

添加方法:

rust 复制代码
impl Addr {
    pub fn broadcast() -> Self {
        Self([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
    }
}

构造完整 frame:

rust 复制代码
let frame = ethernet::Frame {
    src: nic.phy_address,
    dst: ethernet::Addr::broadcast(),
    ether_type: None,
    payload: ethernet::Payload::ARP(arp_packet),
};

调试输出类似:

text 复制代码
Frame {
    dst: FF-FF-FF-FF-FF-FF,
    src: F4-D1-08-0B-7E-BC,
    payload: ARP(
        Packet {
            operation: Request,
            sender_hw_addr: F4-D1-08-0B-7E-BC,
            sender_ip_addr: 192.168.1.16,
            target_hw_addr: 00-00-00-00-00-00,
            target_ip_addr: 192.168.1.254,
        },
    ),
}

这正是一个标准 ARP Request 的结构:本机告诉局域网,"我是 192.168.1.16,我的 MAC 是 F4-D1-08-0B-7E-BC。谁是 192.168.1.254?请告诉我你的 MAC。"


十二、第一次真正发送网络流量

有了 frame 后,可以用 cookie-factory 把它序列化成 bytes:

rust 复制代码
use cookie_factory as cf;

let serialized =
    cf::gen_simple(frame.serialize(), Vec::new()).unwrap();

为了确认发送内容,可以临时定义一个十六进制打印辅助类型:

rust 复制代码
struct AsHex<'a>(&'a [u8]);

impl<'a> fmt::Display for AsHex<'a> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for x in self.0 {
            write!(f, "{:02x} ", x)?;
        }

        Ok(())
    }
}

然后发送:

rust 复制代码
println!("sending {}", AsHex(&serialized));
iface.send(&serialized).unwrap();

输出可能是:

text 复制代码
sending ff ff ff ff ff ff f4 d1 08 0b 7e bc 08 06
        00 01 08 00 06 04 00 01
        f4 d1 08 0b 7e bc c0 a8 01 10
        00 00 00 00 00 00 c0 a8 01 fe

拆开看:

text 复制代码
ff ff ff ff ff ff                 Ethernet dst: broadcast
f4 d1 08 0b 7e bc                 Ethernet src: 本机 MAC
08 06                             EtherType: ARP

00 01                             Hardware type: Ethernet
08 00                             Protocol type: IPv4
06                                Hardware length: 6
04                                Protocol length: 4
00 01                             Operation: request
f4 d1 08 0b 7e bc                 Sender MAC
c0 a8 01 10                       Sender IP: 192.168.1.16
00 00 00 00 00 00                 Target MAC: unknown
c0 a8 01 fe                       Target IP: 192.168.1.254

这就是项目第一次真正构造并发送出去的网络包。它不是调用系统 ICMP API,也不是让操作系统代替我们构造协议字段,而是自己拼出了 Ethernet + ARP,然后通过 rawsock 注入网卡。


十三、为什么一开始没有看到回复

发送之后,如果原来的 process_packet 只打印 ICMP,那么屏幕上可能什么都没有。原因很简单:我们发出去的是 ARP,不是 ICMP;收到的也会是 ARP Reply,不会走原来的 ICMP 打印分支。

因此要修改 process_packet,让它也识别并打印 ARP:

rust 复制代码
fn process_packet(now: Duration, packet: &BorrowedPacket) {
    let frame = match ethernet::Frame::parse(packet) {
        Ok((_remaining, frame)) => frame,

        Err(nom::Err::Error(e)) => {
            println!("{:?} | {:?}", now, e);
            return;
        }

        _ => unreachable!(),
    };

    match frame.payload {
        ethernet::Payload::IPv4(ref ip_packet) => {
            match ip_packet.payload {
                ipv4::Payload::ICMP(ref icmp_packet) => {
                    println!(
                        "{:?} | ({:?}) => ({:?}) | {:#?}",
                        now,
                        ip_packet.src,
                        ip_packet.dst,
                        icmp_packet,
                    );
                }

                _ => {}
            }
        }

        ethernet::Payload::ARP(ref arp_packet) => {
            println!("{:?} | {:#?}", now, arp_packet);
        }

        _ => {}
    }
}

再次运行,就能看到两条 ARP:

text 复制代码
Packet {
    operation: Request,
    sender_hw_addr: F4-D1-08-0B-7E-BC,
    sender_ip_addr: 192.168.1.16,
    target_hw_addr: 00-00-00-00-00-00,
    target_ip_addr: 192.168.1.254,
}

Packet {
    operation: Reply,
    sender_hw_addr: 14-0C-76-6A-71-BD,
    sender_ip_addr: 192.168.1.254,
    target_hw_addr: F4-D1-08-0B-7E-BC,
    target_ip_addr: 192.168.1.16,
}

第一条是我们自己发出的广播 ARP Request。第二条是网关返回的 ARP Reply。它告诉我们:192.168.1.254 的 MAC 是 14-0C-76-6A-71-BD

这就是这一篇最重要的时刻:程序真正发出了自制网络包,并收到了网络中另一台设备的真实响应。


十四、把 ARP 查询结果异步交给等待者

现在能收到 ARP Reply,但只是打印出来还不够。后面要发送 ICMP/IPv4/Ethernet 时,需要把网关 MAC 保存下来,供构造 frame 使用。因此,需要让"发送 ARP 请求的代码"能等待"抓包线程收到 ARP Reply"。

这就需要一点并发通信。可以定义一个 PendingQueries

rust 复制代码
use std::collections::HashMap;
use std::sync::mpsc;

pub struct PendingQueries {
    arp: HashMap<ipv4::Addr, mpsc::Sender<ethernet::Addr>>,
}

含义是:对于某个目标 IP,如果我们正在等待它的 ARP 结果,就在 arp 这个 HashMap 里存一个 Sender。等抓包线程收到 ARP Reply 后,根据 reply 里的 sender_ip_addr 查这个 HashMap,找到对应 Sender,把 sender_hw_addr 发回去。

因为 ipv4::Addr 要作为 HashMap key,需要派生 Hash

rust 复制代码
#[derive(PartialEq, Eq, Clone, Copy, Hash)]
pub struct Addr(pub [u8; 4]);

发送查询的函数可以叫 make_queries

rust 复制代码
fn make_queries(
    iface: &dyn rawsock::traits::DynamicInterface,
    nic: &netinfo::NIC,
    pending: &Mutex<PendingQueries>,
) {
    let arp_packet = arp::Packet {
        operation: arp::Operation::Request,
        sender_hw_addr: nic.phy_address,
        sender_ip_addr: nic.address,
        target_hw_addr: ethernet::Addr::zero(),
        target_ip_addr: nic.gateway,
    };

    let (tx, rx) = mpsc::channel();

    {
        let mut pending = pending.lock().unwrap();
        pending.arp.insert(arp_packet.target_ip_addr, tx);
    }

    let frame = ethernet::Frame {
        src: nic.phy_address,
        dst: ethernet::Addr::broadcast(),
        ether_type: None,
        payload: ethernet::Payload::ARP(arp_packet),
    };

    let serialized =
        cf::gen_simple(frame.serialize(), Vec::new()).unwrap();

    iface.send(&serialized).unwrap();

    let gateway_phy_address = rx.recv().unwrap();
    println!("gateway physical address: {:?}", gateway_phy_address);
}

这里的 rx.recv() 会阻塞当前线程,直到另一个线程通过 tx.send(...) 发送结果。pendingMutex 包住,因为它会被发送线程和抓包线程共享。发送线程插入等待项,抓包线程收到 ARP Reply 后移除等待项并发送结果。

抓包处理函数也要改:

rust 复制代码
fn process_packet(
    pending: &Mutex<PendingQueries>,
    packet: &BorrowedPacket,
) {
    let frame = match ethernet::Frame::parse(packet) {
        Ok((_remaining, frame)) => frame,

        Err(nom::Err::Error(e)) => {
            println!("{:?}", e);
            return;
        }

        _ => unreachable!(),
    };

    match frame.payload {
        ethernet::Payload::IPv4(ref ip_packet) => {
            match ip_packet.payload {
                ipv4::Payload::ICMP(ref icmp_packet) => {
                    println!(
                        "({:?}) => ({:?}) | {:#?}",
                        ip_packet.src,
                        ip_packet.dst,
                        icmp_packet,
                    );
                }

                _ => {}
            }
        }

        ethernet::Payload::ARP(ref arp_packet) => {
            if let arp::Operation::Reply = arp_packet.operation {
                let mut pending = pending.lock().unwrap();

                if let Some(tx) =
                    pending.arp.remove(&arp_packet.sender_ip_addr)
                {
                    tx.send(arp_packet.sender_hw_addr).unwrap();
                }
            }
        }

        _ => {}
    }
}

这段逻辑非常清楚:只关心 ARP Reply;reply 的 sender_ip_addr 就是我们查询的目标 IP;reply 的 sender_hw_addr 就是查询结果。找到等待者后,把结果发回,并从 HashMap 中移除,避免重复处理。


十五、线程、生命周期和 crossbeam scoped threads

现在需要同时做两件事:一个线程发 ARP 请求并等待结果,另一个线程持续监听网卡包。最直接可能会想到 std::thread::spawn

rust 复制代码
thread::spawn(|| {
    make_queries(iface.as_ref(), &nic, &pending);
});

thread::spawn(|| {
    iface.loop_infinite_dyn(&mut |packet| {
        process_packet(&pending, packet);
    }).unwrap();
});

但这样会遇到 Rust 生命周期错误。std::thread::spawn 要求闭包捕获的东西满足 'static,也就是理论上可以活到程序结束。因为线程可能比当前函数活得更久,编译器不能允许它借用当前栈上的局部变量,比如 ifacenicpending

虽然从人的角度看,也许可以 join 线程,确保它们在函数返回前结束,但普通 spawn 的类型签名并不能表达这种作用域约束。Rust 编译器不会基于"你之后会 join"来放宽生命周期。

解决办法是使用 scoped threads。crossbeam-utils 提供了 thread::scope。在 scope 内启动的线程,保证在 scope 返回前全部结束。因此这些线程可以安全借用 scope 外的局部变量,只要这些变量至少活到 scope 结束。

加入依赖后,可以写成:

rust 复制代码
crossbeam_utils::thread::scope(|s| {
    s.spawn(|_| {
        make_queries(iface.as_ref(), &nic, &pending);
    });

    s.spawn(|_| {
        iface
            .loop_infinite_dyn(&mut |packet| {
                process_packet(&pending, packet);
            })
            .unwrap();
    });
})
.unwrap();

这段代码和普通 spawn 看起来差不多,但生命周期语义完全不同。scope() 结束之前,里面的线程必须结束,所以线程闭包可以借用 ifacenicpending。这正是 Rust 并发模型的一个重要特点:不是不能共享,也不是不能借用,而是必须让生命周期关系明确。

运行后,程序能打印出:

text 复制代码
Using NIC {
    guid: "{0E89380B-814A-48FC-86C4-5C51B8040CB2}",
    gateway: 192.168.1.254,
    address: 192.168.1.16,
    phy_address: F4-D1-08-0B-7E-BC,
}

gateway physical address: 14-0C-76-6A-71-BD

这说明 ARP 请求发出去了,抓包线程收到了 ARP Reply,等待线程通过 channel 拿到了网关 MAC。


十六、最后做一轮 API 重构

功能已经跑通,但查询代码还可以写得更清楚。现在构造 ARP Request、把它包进 Ethernet payload、再包进广播 frame、再序列化发送,这几步都可以做成方法。

先给 ethernet::Payload 加方法:

rust 复制代码
impl Payload {
    pub fn as_frame(
        self,
        nic: &crate::netinfo::NIC,
        dst: Addr,
    ) -> Frame {
        Frame {
            src: nic.phy_address,
            dst,
            ether_type: None,
            payload: self,
        }
    }

    pub fn as_broadcast_frame(
        self,
        nic: &crate::netinfo::NIC,
    ) -> Frame {
        self.as_frame(nic, Addr::broadcast())
    }
}

再给 ethernet::Frame 加发送方法:

rust 复制代码
impl Frame {
    pub fn send(
        &self,
        iface: &dyn rawsock::traits::DynamicInterface,
    ) {
        let serialized =
            cf::gen_simple(self.serialize(), Vec::new()).unwrap();

        iface.send(&serialized).unwrap();
    }
}

再给 arp::Packet 加构造和转换方法:

rust 复制代码
impl Packet {
    pub fn request(
        nic: &crate::netinfo::NIC,
        target_ip_addr: ipv4::Addr,
    ) -> Self {
        Self {
            operation: Operation::Request,
            sender_ip_addr: nic.address,
            sender_hw_addr: nic.phy_address,
            target_ip_addr,
            target_hw_addr: ethernet::Addr::zero(),
        }
    }

    pub fn as_ethernet_payload(self) -> ethernet::Payload {
        ethernet::Payload::ARP(self)
    }
}

这样 make_queries 可以变得非常短:

rust 复制代码
fn make_queries(
    iface: &dyn rawsock::traits::DynamicInterface,
    nic: &netinfo::NIC,
    pending: &Mutex<PendingQueries>,
) {
    let gateway_ip = nic.gateway;

    let (tx, rx) = mpsc::channel();

    pending
        .lock()
        .unwrap()
        .arp
        .insert(gateway_ip, tx);

    arp::Packet::request(nic, gateway_ip)
        .as_ethernet_payload()
        .as_broadcast_frame(nic)
        .send(iface);

    let gateway_mac = rx.recv().unwrap();

    println!("gateway MAC: {:?}", gateway_mac);
}

这段代码读起来已经非常接近业务描述:我要查询网关 IP;登记一个等待中的 ARP 查询;构造 ARP Request;把它作为 Ethernet payload;放进广播 frame;发出去;等待 gateway MAC。

这就是前面一系列封装的回报。底层是 Win32 API、Npcap、rawsock、ARP、Ethernet、cookie-factory、channel、Mutex、scoped threads;但最终调用处可以像描述流程一样清楚。


十七、这一篇真正完成了什么

这一篇完成了整个系列中非常关键的一步:第一次自己构造并发送网络流量,并收到真实网络设备的回复。

前面调用 Windows ICMP API 时,发送行为由系统网络栈完成。前面解析 Ethernet、IPv4、ICMP 时,只是在观察网络流量。上一篇序列化 ICMP 时,只是把抓到的字节重建出来,并没有发出去。这一篇不同:程序自己构造了 Ethernet frame,里面装着 ARP Request,通过 rawsock 发到网卡,然后网关返回 ARP Reply。这个 reply 被我们自己的 Ethernet parser 和 ARP parser 解析出来,最终得到网关 MAC 地址。

从协议层看,这一步补上了发送 ICMP 前缺失的关键信息:网关 MAC。后面要把 ICMP Echo Request 放进 IPv4,再放进 Ethernet frame 时,Ethernet 的目标 MAC 就可以使用这个 ARP 查询结果。

从工程结构看,这一篇也把 netinfo 扩展成了真正可用的默认网卡上下文提供者。NIC 同时包含 GUID、网关 IP、本机 IP、本机 MAC,为后续所有发包逻辑提供基础。

从 Rust 技术点看,这一篇覆盖了很多系统编程常见主题:Win32 链表结构体绑定、NonNullDerefMut、C char 与 Rust char 的区别、变长结构体错误码差异、thiserrornom parser、cookie-factory serializer、Ethernet payload 扩展、mpsc channel、Mutex<HashMap<...>>、scoped threads,以及最后的小型 API 重构。


十八、当前还有哪些不足

当前实现仍然有一些简化和临时处理。

首先,ARP parser 只支持 Ethernet + IPv4,也只支持 Request 和 Reply。对于当前项目足够,但不是完整 ARP 实现。更严格的 parser 应该检查 hardware length 是否为 6、protocol length 是否为 4,并对不匹配的包返回明确错误。

其次,错误处理还有不少 unwrap()。比如序列化、发送、接收 channel、锁 Mutex 等地方都用了 unwrap()。实验阶段可以接受,但如果要做成可靠工具,应该把这些错误纳入统一错误类型。

再次,process_packet 里的 ARP Reply 匹配逻辑比较简单。它只根据 sender_ip_addr 找等待项,然后发送 sender_hw_addr。更严格的实现还应该验证 target IP 是否是本机 IP,target MAC 是否是本机 MAC,operation 是否确实是 Reply,甚至可以检查 reply 是否来自当前默认接口。

另外,当前 ARP 查询没有超时机制。rx.recv() 会一直阻塞,直到收到 reply。如果网关没有响应、接口不可用、发送失败或包被过滤,程序可能卡住。后续应该使用 recv_timeout,并返回超时错误。

最后,并发结构只是刚刚搭起来。监听线程和查询线程的生命周期、退出机制、错误传播都还比较粗糙。文章也明确把更深入的线程问题留到后续继续讨论。


十九、总结

这一篇从一个看似简单的问题开始:要自己发送 ICMP/IPv4/Ethernet,目标 MAC 应该填什么?答案不是最终目标 8.8.8.8 的 MAC,而是下一跳网关的 MAC。IP 层的目标可以是远端主机,但 Ethernet 层只能发给当前局域网内的下一跳设备。因此,发送真正的 ping 前,必须先知道网关 IP 对应的 MAC 地址。

为了得到这个 MAC,项目实现了 ARP。实现 ARP 前,又先扩展 netinfo:用 GetIpAddrTable 找默认接口的本机 IPv4 地址,用 GetAdaptersInfo 找默认接口的物理地址。这里继续复用了第 8 篇的 VLS<T> 变长结构体方案,并补充了 ERROR_BUFFER_OVERFLOWDerefMut、链表结构体、NonNull、C 字符数组等处理。最终 default_nic() 返回一个 NIC,包含网卡 GUID、网关 IP、本机 IP 和本机 MAC。

接着新增 arp 模块,建模 OperationHardwareTypePacket,只支持 Ethernet + IPv4。解析使用 nom,序列化使用 cookie-factory。同时扩展 ethernet 模块,加入 EtherType::ARP = 0x0806,让 Ethernet frame 可以解析和序列化 ARP payload。构造 ARP Request 时,sender 填本机 MAC 和 IP,target IP 填网关 IP,target MAC 填全 0;因为目标 MAC 未知,外层 Ethernet frame 的目标地址使用广播 MAC FF-FF-FF-FF-FF-FF

随后,程序第一次通过 rawsock 发送了自己构造的 Ethernet frame。发送内容是一个广播 ARP Request,网关返回 ARP Reply,里面包含网关 MAC。修改 process_packet 后,程序能解析并打印 ARP Request 和 Reply。进一步通过 PendingQueriesmpsc::channelMutex<HashMap<...>> 把"收到 ARP Reply"这件事转成可等待的结果。因为普通线程要求捕获值满足 'static,又引入 crossbeam_utils::thread::scope 使用 scoped threads,让监听线程和查询线程可以安全借用当前作用域中的 ifacenicpending

最后做了一轮 API 重构:arp::Packet::request(...) 构造 ARP 请求,.as_ethernet_payload() 转成 Ethernet payload,.as_broadcast_frame(nic) 包成广播 frame,.send(iface) 完成序列化和发送。最终查询网关 MAC 的代码变得非常接近自然语言描述。

到这里,项目已经迈过一个重要门槛:不再只是解析网络流量,也不再只是调用系统 API,而是真正自己构造了网络包并收到了回应。下一步,就可以把 ARP 查询到的网关 MAC 用到 ICMP Echo Request 上,继续向"完全手工发送 ping"靠近。