前一篇文章介绍了 DHCP 协议定义以及相关信令之间的流转, 本文主要通过 Rust 从头实现一个 DHCP 协议客户端.

环境准备

首先需要安装 Rust, 目前 Rust 官方主要提供 Rustup 这一工具管理 Rust 各版本. 如果是 Windows 可直接下载 Rustup-init.exe 运行即可, 如果是 LinuxmacOS 则可在 Shell 中运行一下命令快速安装:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Rustup 在安装 Rust 同时也会同时安装 Cargo 这一构建和包管理工具, 相对于 C/C++ 而言极大地减轻依赖和构建管理的心智负担.

在安装完 RustCargo 后由于涉及到网络报文, 因此还建议安装 Wireshark(Linux 环境下使用 tcpdump 亦可)以便后续对报文进行分析.

IDE 可以选择 VS CodeJetBrains 新发布的 RustRover, 如果选择 VS Code 的话为了进行调试还需同时安装 CodeLLDB 插件.

开发

我们首先通过 cargo new inode-rs 新建一个项目, 其中 inode-rs 是项目名称. 为便于项目管理 Cargo 为我们提供了 workspace 的概念, 此处我们在 inode-rs 下新建 packetcli 两个子项目. 此时项目结构看起来像这样:

.
├── Cargo.toml
├── cli
└── packet

为了后续开发我们还需要至少添加 bytes, pnettokio 依赖. 其中 bytes 主要用于二进制报文的读写, pnet 可用于获取本机网卡信息, tokio 是一个异步运行时库, 我们使用其提供的 async/await 运行环境简化异步和多线程操作.

我们在 packet 子项目下依次定义子 modudp 并在 udp 模块下定义 dhcp 模块, 最终文件结构为 packet/udp/dhcp.rs. 在 dhcp.rs 内定义具体的数据结构.

依据 RFC 2132 中关于 options 章节我们首先定义一个 DHCP Message Type 消息类型的枚举:

#[derive(Debug, Clone, Copy)]
pub enum DHCPMessageType {
    DHCPDISCOVER = 0x01,
    DHCPOFFER = 0x02,
    DHCPREQUEST = 0x03,
    DHCPDECLINE = 0x04,
    DHCPACK = 0x05,
    DHCPNAK = 0x06,
    DHCPRELEASE = 0x07,
    DHCPINFORM = 0x08,
}

同样, 依据 RFC 2131 中关于报文格式章节我们首先定义 op 枚举:

#[derive(Debug, Clone, Copy, Default)]
pub enum DHCPOP {
    #[default]
    BOOTREQUEST = 0x01,
    BOOTREPLY = 0x02,
}

此处 DHCPOP 枚举由于在网络传输过程中需要序列化为 u8, 在客户端接收到报文后再将对应的 u8 数值反序列化为 DHCPOP 枚举, 因此我们需要实现 IntoFrom 两个 Trait:

impl Into<DHCPOP> for u8 {
    fn into(self) -> DHCPOP {
        match self {
            0x01 => DHCPOP::BOOTREQUEST,
            0x02 => DHCPOP::BOOTREPLY,
            _ => DHCPOP::BOOTREQUEST,
        }
    }
}

impl From<DHCPOP> for u8 {
    fn from(value: DHCPOP) -> Self {
        value as u8
    }
}

在实现 Into Trait 时我们使用了 match 模式匹配. 当前 Rust 要求针对 match 模式匹配必须是 穷举 的 - 即必须保护所有可能情况, 因此在对 u8 进行匹配时由于我们只关注 0x010x02 两种场景, 针对其他情况统一使用 _ 进行匹配并返回一个默认的 DHCPOP::BOOTREQUEST.

其他一些类似的枚举我们不在反复展示.

在定义了必要的一些 enum 后我们再定义一个 DHCPDiscover 结构体:

#[derive(Debug, Clone)]
pub struct DHCPDiscover {
    pub op: DHCPOP,
    pub htype: DHCPHType,
    pub hlen: DHCPHLen,
    pub hops: u8,
    pub xid: u32,
    pub secs: u16,
    pub flags: u16,
    pub ciaddr: u32,
    pub yiaddr: u32,
    pub siaddr: u32,
    pub giaddr: u32,
    pub chaddr: [u8; 16],
    pub sname: [u8; 64],
    pub file: [u8; 128],
    pub options: Vec<u8>,
}

同时为 DHCPDiscover 实现 Default Trait:

impl Default for DHCPDiscover {
    fn default() -> Self {
        DHCPDiscover {
            op: DHCPOP::BOOTREQUEST,
            htype: DHCPHType::ETHERNET,
            hlen: DHCPHLen::ETHERNET,
            hops: 0x00,
            xid: 0x00000000,
            secs: 0x0000,
            flags: 0x0000,
            ciaddr: 0x00000000,
            yiaddr: 0x00000000,
            siaddr: 0x00000000,
            giaddr: 0x00000000,
            chaddr: [0x00; 16],
            sname: [0; 64],
            file: [0; 128],
            options: vec![99, 130, 83, 99],
        }
    }
}

在为 DHCPDiscover 实现 Default Trait 时我们为字段 options 预先存入了 magic cookie[99, 130, 83, 99](10 进制表示).

除了实现 Default Trait 我们同时还定义一个 fn with_mac(mac: &str) 的关联函数, 其作用是根据 MAC 地址初始化 DHCPDiscover 对象; 同时为了支持动态设置 options 字段, 我们为 DHCPDiscover 添加一个 fn insert_options(&mut self, options: Vec<DHCPOPTIONS>) 方法, 该方法关联到 DHCPDiscover 的实例并可变(mut)借用其实例自身以更新相关属性:

impl DHCPDiscover {
    pub fn with_mac(mac: &str) -> Self {
        let mut mac_addr: Vec<u8> = if mac.contains("-") {
            str::split(mac, "-")
                .map(|u| u8::from_str_radix(u, 16).unwrap())
                .collect()
        } else {
            str::split(mac, ":")
                .map(|u| u8::from_str_radix(u, 16).unwrap())
                .collect()
        };
        mac_addr.resize(16, 0);
        let mut discover = DHCPDiscover::default();
        discover.chaddr = mac_addr.try_into().unwrap();
        discover
    }

    pub fn insert_options(&mut self, options: Vec<DHCPOPTIONS>) {
        for ele in options.iter() {
            self.options.push(ele.tp as u8);
            self.options.push(ele.len);
            self.options.extend(ele.va.clone());
        }
        self.options.push(0xff);
    }
}

以上除了定义 enumstruct 外, 我们再定义一个 DHCPBytes trait:

pub trait DHCPBytes {
    fn to_bytes(&self) -> BytesMut;
}

并要求所有实现该 trait 的元素都必须实现 to_bytes 方法以便将对应元素转换为二进制 bytes 从而在 UDP 报文中进行传输. 其中 BytesMutbytes 这个 crate 提供, 而 bytes 库主要在于提供高效的针对字节缓冲的操作. Bytes 作为一个高效的存储与操作连续内存空间的容器, 其设计的主要目标在于网络编码以及其他一些应用场景.

impl DHCPBytes for DHCPDiscover {
    fn to_bytes(&self) -> BytesMut {
        let mut discover_bytes = BytesMut::new();
        discover_bytes.put_u8(self.op as u8);
        discover_bytes.put_u8(self.htype as u8);
        discover_bytes.put_u8(self.hlen as u8);
        discover_bytes.put_u8(self.hops);
        discover_bytes.put_u32(self.xid);
        discover_bytes.put_u16(self.secs);
        discover_bytes.put_u16(self.flags);
        discover_bytes.put_u32(self.ciaddr);
        discover_bytes.put_u32(self.yiaddr);
        discover_bytes.put_u32(self.siaddr);
        discover_bytes.put_u32(self.giaddr);
        for ele in self.chaddr.iter() {
            discover_bytes.put_u8(*ele);
        }
        for ele in self.sname.iter() {
            discover_bytes.put_u8(*ele);
        }
        for ele in self.file.iter() {
            discover_bytes.put_u8(*ele);
        }
        for ele in self.options.iter() {
            discover_bytes.put_u8(*ele);
        }
        discover_bytes
    }
}

以上 DHCPDiscover 实现的 DHCPBytes 主要是按 RFC 2131 内定义的报文格式将对应字段转换为 Bytes. 另外像其中 xid, secsciaddr 等字段由于在标准中分别采用 4, 24 字节, 分别对应了 32, 1632 BIT, 因此为简化此处直接按对应 BIT 定义了无符整型数据.

在完成以上相关 enumstruct 定义的最后我们继续来实现 main() 方法:

#[tokio::main]
async fn main() -> Result<()> {
    let socket = UdpSocket::bind("0.0.0.0:68").await?;
    let broadcast = socket.set_broadcast(true);
    if let Ok(r) = broadcast {
        println!("开启广播模式");
    }
    let mut buf = [0; 1024];
    let mac = "AC-48-78-43-C7-B8";
    let mut v = vec![DHCPHType::ETHERNET as u8];
    let a: Vec<u8> = str::split(mac, "-")
        .map(|u| u8::from_str_radix(u, 16).unwrap())
        .collect();
    v.extend_from_slice(&a);
    let parameter_request_list: Vec<u8> = vec![
        DHCPOPTION::SubnetMask as u8,
        DHCPOPTION::RouterOption as u8,
        DHCPOPTION::DomainNameServerOption as u8,
        DHCPOPTION::NetworkTimeProtocolServersOption as u8,
    ];
    let mut discovery_message = DHCPDiscover::with_mac(mac);
    discovery_message.insert_options(vec![
        DHCPOPTIONS {
            tp: DHCPOPTION::DHCPMessageType,
            len: 1,
            va: vec![DHCPMessageType::DHCPDISCOVER as u8],
        },
        DHCPOPTIONS {
            tp: DHCPOPTION::ClientIdentifier,
            len: v.len() as u8,
            va: v.clone(),
        },
        DHCPOPTIONS {
            tp: DHCPOPTION::ParameterRequestList,
            len: parameter_request_list.len() as u8,
            va: parameter_request_list.clone(),
        },
    ]);
    let tmp_bytes = discovery_message.to_bytes();

    let broadcast_result = socket.send_to(&tmp_bytes, "255.255.255.255:67").await;
    match broadcast_result {
        Ok(n) => println!("发送数据包: {}", n),
        Err(e) => println!("错误: {:?}", e),
    }
    Ok(())
}

首先通过 UdpSocket::bind<A: ToSocketAddrs>(addr: A) 函数绑定到本机对应端口, 值得注意的是此处在调用 bind 函数时后跟了一个 await? 关键字, 这里的 async/awaitRust 提供的一个内置使得编写异步代码像同步代码一样的工具, async 会将相应的代码块转换为一种实现了 Future Trait 的状态机, 当调用 await 时具体的 Runtime 会挂起当前运行环境直至其返回结果 Future 准备好. Rust 并未在标准中提供 async/await 运行时, 因此我们需要通过 crate 添加对应依赖, 在此处我们使用 tokio.

以上我们同时还通过 socket.set_broadcast(true) 显示地将 UDPSocket 实例设置为广播模式, 在前一篇文章(DHCP 协议分析及实现 - Ⅰ)内我们也曾介绍过由于在 DHCP 协议通信之初 客户端 尚无具体 IP 地址切也不知道对应 服务端 所在网络中位置, 而这这好利用 UDP 的广播特性使得客户端向局域网广播 DHCPDISCOVER 报文. 对应地通过向 255.255.255.255:67 发送广播报文.

至此我们完成了初步的 DHCP 协议中 DHCPDISCOVER 协议的转换与广播, 后续还有包括 DHCPOFFER, DHCPREQUESTDHCPACK 等消息的转换, 发送与解析我们不再赘述, 感兴趣的可以参考 liloew/inode-rs 完整实现.

REF

  1. Rust Programming Language
  2. Tokio - An asynchronous Rust runtime
  3. tokio-rs/bytes
  4. liloew/inode-rs
  5. DHCP 协议分析及实现 - Ⅰ