DHCP 协议分析及实现 - Ⅱ
前一篇文章介绍了 DHCP 协议定义以及相关信令之间的流转, 本文主要通过 Rust 从头实现一个 DHCP 协议客户端.
环境准备⌗
首先需要安装 Rust, 目前 Rust 官方主要提供 Rustup 这一工具管理 Rust 各版本. 如果是 Windows 可直接下载 Rustup-init.exe 运行即可, 如果是 Linux 或 macOS 则可在 Shell 中运行一下命令快速安装:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Rustup 在安装 Rust 同时也会同时安装 Cargo 这一构建和包管理工具, 相对于 C/C++ 而言极大地减轻依赖和构建管理的心智负担.
在安装完 Rust 和 Cargo 后由于涉及到网络报文, 因此还建议安装 Wireshark(Linux 环境下使用 tcpdump 亦可)以便后续对报文进行分析.
IDE 可以选择 VS Code 或 JetBrains 新发布的 RustRover, 如果选择 VS Code 的话为了进行调试还需同时安装 CodeLLDB 插件.
开发⌗
我们首先通过 cargo new inode-rs 新建一个项目, 其中 inode-rs 是项目名称. 为便于项目管理 Cargo 为我们提供了 workspace 的概念, 此处我们在 inode-rs 下新建 packet 和 cli 两个子项目. 此时项目结构看起来像这样:
.
├── Cargo.toml
├── cli
└── packet
为了后续开发我们还需要至少添加 bytes, pnet 和 tokio 依赖. 其中 bytes 主要用于二进制报文的读写, pnet 可用于获取本机网卡信息, tokio 是一个异步运行时库, 我们使用其提供的 async/await 运行环境简化异步和多线程操作.
我们在 packet 子项目下依次定义子 mod 为 udp 并在 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 枚举, 因此我们需要实现 Into 和 From 两个 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 进行匹配时由于我们只关注 0x01 和 0x02 两种场景, 针对其他情况统一使用 _ 进行匹配并返回一个默认的 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);
}
}
以上除了定义 enum 和 struct 外, 我们再定义一个 DHCPBytes trait:
pub trait DHCPBytes {
fn to_bytes(&self) -> BytesMut;
}
并要求所有实现该 trait 的元素都必须实现 to_bytes 方法以便将对应元素转换为二进制 bytes 从而在 UDP 报文中进行传输. 其中 BytesMut 由 bytes 这个 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, secs 和 ciaddr 等字段由于在标准中分别采用 4, 2 和 4 字节, 分别对应了 32, 16 和 32 BIT, 因此为简化此处直接按对应 BIT 定义了无符整型数据.
在完成以上相关 enum 和 struct 定义的最后我们继续来实现 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/await 是 Rust 提供的一个内置使得编写异步代码像同步代码一样的工具, 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, DHCPREQUEST 和 DHCPACK 等消息的转换, 发送与解析我们不再赘述, 感兴趣的可以参考 liloew/inode-rs 完整实现.