diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e4c9a95 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,378 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const_fn" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60" + +[[package]] +name = "dhcp4r" +version = "0.2.3" +dependencies = [ + "time", +] + +[[package]] +name = "discard" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" +dependencies = [ + "sha1_smol", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "standback" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" +dependencies = [ + "version_check", +] + +[[package]] +name = "stdweb" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" +dependencies = [ + "discard", + "rustc_version", + "stdweb-derive", + "stdweb-internal-macros", + "stdweb-internal-runtime", + "wasm-bindgen", +] + +[[package]] +name = "stdweb-derive" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_derive", + "syn 1.0.109", +] + +[[package]] +name = "stdweb-internal-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" +dependencies = [ + "base-x", + "proc-macro2", + "quote", + "serde", + "serde_derive", + "serde_json", + "sha1", + "syn 1.0.109", +] + +[[package]] +name = "stdweb-internal-runtime" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" +dependencies = [ + "const_fn", + "libc", + "standback", + "stdweb", + "time-macros", + "version_check", + "winapi", +] + +[[package]] +name = "time-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" +dependencies = [ + "proc-macro-hack", + "time-macros-impl", +] + +[[package]] +name = "time-macros-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "standback", + "syn 1.0.109", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..239eac4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "dhcp4r" +version = "0.2.3" +authors = ["Richard Warburton "] +description = "IPv4 DHCP library with working server example." +edition = "2018" + +[profile.dev] +opt-level = 0 + +[profile.release] +opt-level = 3 +# Cross-crate inlining + whole-program vectorization for bench/server +# binaries (lib <-> example calls). Keeps unwind (tests rely on panics). +lto = true +codegen-units = 1 + + +# These URLs point to more information about the repository. +#documentation = "..." +#homepage = "..." + +# This points to a file in the repository (relative to this `Cargo.toml`). The +# contents of this file are stored and indexed in the registry. +# readme = "..." + +[dependencies] + + +[dev-dependencies] +time = "0.2" diff --git a/README.md b/README.md index ff14fc2..ce03934 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ A simple and zero-dependency DHCP server written in Rust, with credit to Richard - Fast and efficient. - Cross-platform support and cross-compilation. - Customizable Leases File: Support for a "leases" file that allows you to define permanent leases, ensuring clients always receive the same IP address. +- Managed DHCPv6 range: stateful DHCPv6 (RFC 8415) address allocation from a configured IPv6 pool, with DUID-based leases. +- Single `dhcp.conf` file: every IPv4 and IPv6 setting in one place, no recompiling to reconfigure. ## Table of Contents @@ -35,47 +37,81 @@ A simple and zero-dependency DHCP server written in Rust, with credit to Richard git clone https://github.com/pj1234678/RustyDHCP.git ``` -3. Build the server: + 3. Build the server: - ```bash - cd RustyDHCP - cargo build --release --example server - ``` + ```bash + cd RustyDHCP + cargo build --release + ``` ## Usage -1. Start the DHCP server: +1. Start the DHCP server (no arguments; it always reads `./dhcp.conf`): - ```bash - sudo ./target/release/examples/server - ``` + ```bash + sudo ./target/release/dhcp4r + ``` - The server will listen on the default DHCP ports (67 and 68) and start serving DHCP requests. + The server will listen on the configured DHCP ports (67 for IPv4, 547 for IPv6 when enabled) and start serving DHCP requests. Every DHCP Request is also printed monitor-style (`timestamp\tmac\tip\thostname\tOnline`). 2. Make DHCP requests from clients, and the server will respond with IP addresses and other configuration details. +### Diagnosing clients + +Every handshake step is logged: `DISCOVER -> OFFER/ACK`, `REQUEST -> ACK/NAK (reason)`, `RELEASE -> freed`, pool-exhaustion (`no reply ... used`), ignored packets (`unsupported message type`, `no client ID`, `server_id mismatch`), and malformed datagrams (`ignoring malformed ...: reason`). If a client (e.g. an IoT thermostat) won't connect while dnsmasq works, watch for: +- `INFORM ... ignored` — this server does not answer DHCPINFORM; dnsmasq does. +- `-> NAK ... outside pool range` — the client asks for an address outside `ip_start + lease_num`. +- `no reply (pool exhausted ...)` — all leases are handed out. +- `ignoring malformed ...` — the client's packet fails to decode at all. +- `-> OFFER ... to [192.168.2.255:68, 255.255.255.255:68]` but the client +never sends Request — check for a unicast copy in the list. With the +broadcast flag clear the server unicasts a copy to the offered address +(`..., 192.168.2.x:68`, RFC 2131 §4.1, like dnsmasq) alongside the +broadcasts, for clients/APs that drop every broadcast flavor. If even the +unicast copy never arrives, suspect AP broadcast handling or firewall, and +compare a dnsmasq capture (`tcpdump -i -e -vvv`). + ## Configuration -To configure the server, edit the following fields in the `examples/server.rs` file: +All settings live in a single `dhcp.conf` file using a minimal `key = value` format (no dependencies to parse it). Blank lines and lines starting with `#` or `;` are ignored, keys are case-insensitive, a trailing ` # comment` is stripped, duplicate keys use the last value, and unknown keys are ignored. Any malformed line or invalid value fails loudly with the key and line number. If the file is missing, the server warns and uses the built-in defaults below. -```rust -// Server configuration -const SERVER_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 2, 1); -const IP_START: [u8; 4] = [192, 168, 2, 2]; -const SUBNET_MASK: Ipv4Addr = Ipv4Addr::new(255, 255, 255, 0); -const DNS_IPS: [Ipv4Addr; 1] = [ - // Google DNS servers - Ipv4Addr::new(8, 8, 8, 8), -]; -const ROUTER_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 2, 1); -const BROADCAST_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 2, 255); -const LEASE_DURATION_SECS: u32 = 86400; -const LEASE_NUM: u32 = 252; -``` +| Key | Default | Description | +| --- | ------- | ----------- | +| `server_ip` | `192.168.2.1` | This server's address (DHCP option 54) | +| `ip_start` | `192.168.2.2` | First address of the managed IPv4 pool | +| `lease_num` | `252` | IPv4 pool size (covers `.2` .. `.253`) | +| `subnet_mask` | `255.255.255.0` | Advertised subnet mask (option 1) | +| `router_ip` | `192.168.2.1` | Advertised gateway (option 3) | +| `dns_ips` | `8.8.8.8` | Advertised DNS servers, comma-separated (option 6) | +| `enable_ntp` | `false` | Master switch for advertising NTP servers (option 42) | +| `ntp_ips` | _(empty)_ | Advertised NTP servers, comma-separated (option 42); sent only when `enable_ntp` is true and the list is non-empty | +| `enable_rapid_commit` | `false` | Master switch for DHCP Rapid Commit (option 80 for IPv4, option 14 for IPv6) | +| `broadcast_ip` | `192.168.2.255` | Where broadcast replies are sent; comma-separated list fans each reply out to every address (e.g. `192.168.2.255, 255.255.255.255`) | +| `extra_broadcast_ips` | _(empty)_ | Additional broadcast destinations, comma-separated; appended to `broadcast_ip` | +| `lease_duration_secs` | `86400` | IPv4 lease lifetime (option 51) | +| `leases_file` | `leases` | Permanent `mac,ip` reservations | +| `listen_addr` | `0.0.0.0:67` | Address to bind for DHCPv4 | +| `enable_dhcpv6` | `false` | Master switch for the DHCPv6 service | +| `server_duid` | `00:03:00:01:02:00:5e:aa:bb:cc` | Server DUID for SERVERID (1..=128 hex bytes) | +| `ipv6_start` | `fd00::100` | First address of the managed IPv6 pool | +| `ipv6_lease_num` | `1000` | IPv6 pool size | +| `ipv6_dns` | `2001:4860:4860::8888` | Advertised DNS servers, comma-separated (option 23) | +| `ipv6_lease_duration_secs` | `86400` | IPv6 preferred + valid lifetimes (IAADDR) | +| `leases_file_v6` | `leases6` | Permanent `duid-hex,ipv6` reservations | +| `listen_addr_v6` | `[::]:547` | Address to bind for DHCPv6 | -You can customize these configuration parameters according to your network requirements. +See the shipped `dhcp.conf` for a fully commented example. Boolean values accept `true/false`, `yes/no`, `on/off`, `1/0` (any case). +### DHCPv6 managed range (RFC 8415) +Set `enable_dhcpv6 = true` and configure `ipv6_start` / `ipv6_lease_num` to serve stateful DHCPv6 on port 547 alongside DHCPv4: + +- **Solicit → Advertise**: offers the client's current address, else the first free address of the managed range (round-robin). +- **Request / Renew / Rebind → Reply**: commits the requested IA_NA address; T1/T2 are set to half / seven-eighths of the lease lifetime. +- **Release / Decline → frees** the client's lease (only when the Server ID matches this server). +- Pool exhaustion (or any failure) gets a Reply with Status Code `NoAddrsAvail` (2). + +Clients are identified by their exact DUID bytes and leases work like the IPv4 side, with one deliberate difference: infinite (file-reserved) IPv6 leases are never stealable. Malformed lines in `leases6` are skipped without killing the server. To create a "leases" file with the example permanent lease, you can manually create a file named "leases" in the same directory as the compiled program with the following content: @@ -83,6 +119,22 @@ f4:5c:19:af:96:8d,192.168.2.90 This lease format specifies the MAC address and the corresponding IP address for the client. The DHCP server will read this file to assign permanent leases based on its contents. +### Client host names (DHCP option 12) + +When a client announces a host name (option 12, RFC 2132 §3.14), the server records it against the lease: it is stored when the lease is created, refreshed whenever a later Discover or Request carries a name, and left untouched by packets that omit it (so renewals without the option don't flap the recorded name). Releasing the lease drops the name with it. Malformed (non-UTF-8) names never reach the server logic — the decoder already rejects them. The monitor prints the recorded name per Request (`-` when the client sent none): + +```text +2026-01-01T12:00:00 f4:5c:19:af:96:8d 192.168.2.90 laptop Online +``` + +The IPv6 equivalent is a "leases6" file with `duid-hex,ipv6` lines, for example: + +00:03:00:01:02:00:5e:aa:bb:cc,fd00::100 + +### DHCP Rapid Commit + +Set `enable_rapid_commit = true` to allow the 2-message exchange: a Discover (IPv4, RFC 4039 option 80) or Solicit (IPv6, RFC 8415 option 14) carrying the rapid-commit option is answered immediately with an Ack/Reply that commits the lease, skipping Offer/Advertise. Without both the flag and the option on the wire, the server uses the legacy 4-message flow. Rapid-commit selection is Discover-style (current lease, else round-robin); pool exhaustion behaves like the unanswered Discover/Advertise path. + ## Contributions diff --git a/dhcp.conf b/dhcp.conf new file mode 100644 index 0000000..795be17 --- /dev/null +++ b/dhcp.conf @@ -0,0 +1,71 @@ +# RustyDHCP configuration file (dhcp.conf). +# +# All server settings live here. Lines are `key = value`; blank lines and +# lines starting with '#' or ';' are ignored. Keys are case-insensitive. +# A trailing " # comment" (whitespace + '#') is stripped from values. +# Duplicate keys: the last occurrence wins. Unknown keys are ignored. +# +# Start the server with an optional config path (default: ./dhcp.conf): +# sudo ./target/release/examples/server [path/to/dhcp.conf] +# +# If the file is missing or unreadable, the server logs a warning and uses +# the built-in defaults below. + +# --- IPv4 (DHCPv4, RFC 2131) --------------------------------------------- +# Address of this server (put in Server Identifier, option 54). +server_ip = 192.168.2.1 +# First address of the managed pool (pool is [ip_start, ip_start+lease_num)). +ip_start = 192.168.2.2 +# Pool size. 192.168.2.2 + 252 covers .2 .. .253. +lease_num = 252 +# Subnet mask advertised to clients (option 1). +subnet_mask = 255.255.255.0 +# Default gateway advertised to clients (option 3). +router_ip = 192.168.2.1 +# DNS servers advertised to clients, comma-separated (option 6). +dns_ips = 8.8.8.8 +# Master switch for advertising NTP servers (option 42). Off means option 42 +# is never sent, exactly like the old hardcoded server. +enable_ntp = true +# NTP servers advertised to clients, comma-separated (option 42). Only sent +# when enable_ntp is true and the list is non-empty. +ntp_ips = 192.168.2.1 +# Master switch for DHCP Rapid Commit: IPv4 option 80 (RFC 4039) and IPv6 +# option 14 (RFC 8415). When true, a Discover/Solicit carrying the +# rapid-commit option is answered immediately (Ack/Reply, 2-message exchange) +# instead of Offer/Advertise. Off by default (legacy 4-message behavior). +enable_rapid_commit = true +# Where to send replies when the client asked for broadcast. A +# comma-separated list fans each reply out to every address: some +# clients/APs only accept the limited broadcast 255.255.255.255, others +# only the subnet directed broadcast — e.g. +# broadcast_ip = 192.168.2.255 +# (Unicast replies always go once to the peer.) +broadcast_ip = 192.168.2.255 +# Lease lifetime, seconds, advertised to clients (option 51). +lease_duration_secs = 86400 +# "leases" file with permanent reservations, one `mac,ip` per line, e.g.: +# f4:5c:19:af:96:8d,192.168.2.90 +leases_file = leases +# Local address to bind for DHCPv4 (server port 67). +listen_addr = 0.0.0.0:67 + +# --- IPv6 managed range (DHCPv6, RFC 8415) --------------------------------- +# Master switch for the DHCPv6 service (server port 547). Off by default so +# a missing dhcp.conf behaves exactly like the old hardcoded server. +enable_dhcpv6 = true +# Server DUID served in SERVERID options (opaque hex, 1..=128 bytes). +server_duid = 00:03:00:01:02:00:5e:aa:bb:cc +# First address of the managed IPv6 pool. +ipv6_start = fd00::100 +# Pool size. fd00::100 + 1000 covers fd00::100 .. fd00::4e7. +ipv6_lease_num = 1000 +# DNS servers advertised to DHCPv6 clients, comma-separated (option 23). +ipv6_dns = 2001:4860:4860::8888 +# IPv6 lease lifetime, seconds (preferred + valid lifetimes in IAADDR). +ipv6_lease_duration_secs = 86400 +# v6 reservations file, one `duid-hex,ipv6` per line, e.g.: +# 00:03:00:01:02:00:5e:aa:bb:cc,fd00::100 +leases_file_v6 = leases6 +# Local address to bind for DHCPv6 (server port 547). +listen_addr_v6 = [::]:547 diff --git a/examples/bench.rs b/examples/bench.rs new file mode 100644 index 0000000..4fce838 --- /dev/null +++ b/examples/bench.rs @@ -0,0 +1,664 @@ +//! Zero-dependency throughput benchmarks (std only, no criterion). +//! +//! Run: `cargo run --release --example bench` +//! Reports req/s (higher is better) for decode/encode/filter/pools + UDP loopback. +//! Old-vs-new: record numbers before optimization, re-run after; same machine, +//! same build flags, median of 3 runs. + +use dhcp4r::{dhcpv6, options, packet, server}; +use std::hint::black_box; +use std::net::{Ipv4Addr, Ipv6Addr, UdpSocket}; +use std::time::{Duration, Instant}; + +fn bench_fn(name: &str, iters: u64, mut f: impl FnMut()) -> f64 { + // warmup (not timed) + for _ in 0..5_000 { + f(); + } + let t = Instant::now(); + for _ in 0..iters { + f(); + } + let el = t.elapsed(); + let rps = iters as f64 / el.as_secs_f64(); + println!( + "{:<28} {:>12.0} req/s ({:.1} ns/req)", + name, + rps, + el.as_nanos() as f64 / iters as f64 + ); + rps +} + +fn discover_packet() -> packet::Packet { + packet::Packet { + reply: false, + hops: 0, + xid: 0x1234_5678, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [1, 2, 3, 4, 5, 6], + options: vec![ + options::DhcpOption::DhcpMessageType(options::MessageType::Discover), + options::DhcpOption::ParameterRequestList(vec![1, 3, 6, 15]), + options::DhcpOption::HostName("bench-host".to_string()), + ], + } +} + +fn request_packet() -> packet::Packet { + packet::Packet { + reply: false, + hops: 0, + xid: 0x9abc_def0, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff], + options: vec![ + options::DhcpOption::DhcpMessageType(options::MessageType::Request), + options::DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 10)), + options::DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + options::DhcpOption::ParameterRequestList(vec![1, 3, 6, 51]), + options::DhcpOption::HostName("bench-request-host".to_string()), + options::DhcpOption::IpAddressLeaseTime(3600), + ], + } +} + +fn offer_options() -> Vec { + vec![ + options::DhcpOption::IpAddressLeaseTime(86400), + options::DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + options::DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 2, 1)]), + options::DhcpOption::DomainNameServer(vec![ + Ipv4Addr::new(8, 8, 8, 8), + Ipv4Addr::new(8, 8, 4, 4), + ]), + ] +} + +/// reply assembly without sockets: mirrors Server::reply's Vec build +/// (auto [Msg,SrvId] + extend + conditional PRL filter). Isolates the +/// ~80-150ns assembly cost from the ~15µs UDP number. +fn reply_assembly(offer: &[options::DhcpOption], prl: Option<&[u8]>) -> Vec { + let mut opts = Vec::with_capacity(offer.len() + 2); + opts.push(options::DhcpOption::DhcpMessageType( + options::MessageType::Offer, + )); + opts.push(options::DhcpOption::ServerIdentifier( + Ipv4Addr::new(192, 168, 2, 1), + )); + opts.extend(offer.iter().cloned()); + if let Some(p) = prl { + server::filter_options_by_req(&mut opts, p); + } + opts +} + +fn bench_udp_roundtrip() -> f64 { + // Minimal Offer handler over real loopback UDP, exercising + // Server::reply -> filter_options_by_req -> Packet::encode -> send_to. + // Offer options are cached once (like the real server) so the bench + // measures reply/filter/encode/send, not option rebuilds. + struct BenchHandler { + server_ip: Ipv4Addr, + offer: Vec, + } + impl server::Handler for BenchHandler { + fn handle_request(&mut self, s: &server::Server, p: packet::Packet) { + let _ = s.reply( + options::MessageType::Offer, + self.offer.clone(), + Ipv4Addr::new(192, 168, 2, 10), + p, + ); + } + } + + let srv_sock = UdpSocket::bind("127.0.0.1:0").expect("bind srv"); + let srv_addr = srv_sock.local_addr().unwrap(); + srv_sock + .set_read_timeout(Some(Duration::from_millis(5000))) + .ok(); + let server_ip = Ipv4Addr::new(127, 0, 0, 1); + let broadcast_ip = Ipv4Addr::new(127, 0, 0, 1); + let offer = offer_options(); + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, broadcast_ip, BenchHandler { server_ip, offer }); + }); + // Give server a moment to bind. + std::thread::sleep(Duration::from_millis(100)); + + let cli = UdpSocket::bind("127.0.0.1:0").expect("bind cli"); + cli.connect(srv_addr).expect("connect"); + cli.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let mut buf = [0u8; dhcp4r::packet::WIRE_MAX]; + let wire = { + let mut tmp = [0u8; dhcp4r::packet::WIRE_MAX]; + discover_packet().encode(&mut tmp).to_vec() + }; + // warmup + let mut rx = [0u8; 1500]; + for _ in 0..200 { + cli.send(black_box(&wire)).unwrap(); + let _ = cli.recv(&mut rx); + } + const N: u64 = 5_000; + let t = Instant::now(); + let mut ok = 0u64; + for _ in 0..N { + cli.send(black_box(&wire)).unwrap(); + match cli.recv(&mut rx) { + Ok(l) => { + black_box(l); + ok += 1; + } + Err(_) => break, + } + } + let el = t.elapsed(); + // Silence unused warning for buf + black_box(&mut buf); + let rps = ok as f64 / el.as_secs_f64(); + println!( + "{:<28} {:>12.0} req/s ({:.1} ns/req, {}/{{}} ok)", + "udp/offer-roundtrip", + rps, + el.as_nanos() as f64 / (ok.max(1) as f64), + ok, + ); + // Server thread is detached; process exit kills it. + rps +} + +fn main() { + println!("=== dhcp4r bench (release, std Instant, black_box) ==="); + println!( + "sizeof Packet={} DhcpOption={} RawDhcpOption={} Server={}", + std::mem::size_of::(), + std::mem::size_of::(), + std::mem::size_of::(), + std::mem::size_of::(), + ); + // Wire ceiling is WIRE_MAX = 300 (BOOTP minimum: 240 header+cookie, + // up to 59 bytes options + END, PAD to 300); the shipped + // send path already uses [u8; WIRE_MAX], so the bench does too. + let mut encode_buf = [0u8; dhcp4r::packet::WIRE_MAX]; + let d = discover_packet(); + let r = request_packet(); + let d_wire = d.encode(&mut encode_buf).to_vec(); + let r_wire = r.encode(&mut encode_buf).to_vec(); + // sanity: roundtrip preserves options + assert_eq!( + packet::Packet::from(&d_wire).ok().expect("decode").options, + d.options + ); + + const N: u64 = 200_000; + bench_fn("decode/discover", N, || { + black_box(packet::Packet::from(black_box(&d_wire)).ok().expect("decode")); + }); + bench_fn("decode/request", N, || { + black_box(packet::Packet::from(black_box(&r_wire)).ok().expect("decode")); + }); + bench_fn("encode/discover", N, || { + let n = black_box(&d).encode(black_box(&mut encode_buf)).len(); + black_box(n); + }); + bench_fn("encode/request", N, || { + let n = black_box(&r).encode(black_box(&mut encode_buf)).len(); + black_box(n); + }); + + // filter_options_by_req: typical Offer(4 opts) filtered by PRL[1,3,6] + bench_fn("filter/prl-3", N, || { + let mut o = offer_options(); + // prepend auto opts like Server::reply does + o.insert(0, options::DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1))); + o.insert( + 0, + options::DhcpOption::DhcpMessageType(options::MessageType::Offer), + ); + server::filter_options_by_req(black_box(&mut o), black_box(&[1u8, 3, 6])); + black_box(o); + }); + + // options::to_raw throughput (Router x2) for old-vs-new comparison + let router2 = options::DhcpOption::Router(vec![ + Ipv4Addr::new(192, 168, 2, 1), + Ipv4Addr::new(192, 168, 2, 2), + ]); + bench_fn("options/to_raw-router2", N, || { + black_box(black_box(&router2).to_raw()); + }); + + // DHCPv6 codec + let v6_solicit = dhcpv6::Packet { + msg_type: dhcpv6::MsgType::Solicit, + transaction_id: 0x112233, + options: vec![ + dhcpv6::Dhcpv6Option::ClientId(vec![0, 3, 0, 1, 2, 0, 0x5e, 0xaa, 0xbb, 0xcc]), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid: 1, + t1: 0, + t2: 0, + addrs: vec![], + }), + ], + }; + let v6_wire = v6_solicit.encode().unwrap(); + bench_fn("v6/decode-solicit", N, || { + black_box(dhcpv6::Packet::from(black_box(&v6_wire)).expect("v6 decode")); + }); + bench_fn("v6/encode-solicit", N, || { + black_box(black_box(&v6_solicit).encode().unwrap()); + }); + let v6_reply = dhcpv6::Packet { + msg_type: dhcpv6::MsgType::Reply, + transaction_id: 0x445566, + options: vec![ + dhcpv6::Dhcpv6Option::ServerId(vec![0, 3, 0, 1, 2, 0, 0x5e, 0xaa, 0xbb, 0xcc]), + dhcpv6::Dhcpv6Option::ClientId(vec![0, 3, 0, 1, 9, 9, 9, 9, 9, 9]), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid: 7, + t1: 43200, + t2: 75600, + addrs: vec![dhcpv6::IaAddr { + addr: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + preferred: 86400, + valid: 86400, + }], + }), + dhcpv6::Dhcpv6Option::DnsServers(vec![Ipv6Addr::new( + 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888, + )]), + ], + }; + bench_fn("v6/encode-reply-1addr", N, || { + black_box(black_box(&v6_reply).encode().unwrap()); + }); + + // V6Pool: 1000-entry pool, hit vs miss + { + let mut pool = dhcpv6::V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + 1000, + Duration::from_secs(86400), + ); + // prefill 1000 leases with distinct DUIDs + for i in 0..1000u32 { + let mut duid = vec![0, 3, 0, 1]; + duid.extend_from_slice(&i.to_be_bytes()); + let ip = Ipv6Addr::from(u128::from(pool.start_addr()) + i as u128); + pool.insert(ip, duid, Some(Instant::now() + Duration::from_secs(3600))); + } + let owner_duid = { + let mut d = vec![0, 3, 0, 1]; + d.extend_from_slice(&42u32.to_be_bytes()); + d + }; + bench_fn("v6pool/discover-hit-1000", N, || { + black_box(pool.discover(black_box(&owner_duid))); + }); + let stranger: Vec = vec![9, 9, 9, 9, 9, 9, 9, 9]; + // exhausted pool: stranger always misses after full scan + bench_fn("v6pool/discover-miss-full", 5_000, || { + black_box(pool.discover(black_box(&stranger))); + }); + } + { + // v4-style pool simulation via V6Pool with small count for comparison + let mut pool = dhcpv6::V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x200), + 252, + Duration::from_secs(86400), + ); + bench_fn("v6pool/discover-miss-empty-252", 20_000, || { + let mut duid = vec![1, 2, 3, 4]; + duid.extend_from_slice(&black_box(12345u32).to_be_bytes()); + black_box(pool.discover(black_box(&duid))); + }); + } + + // ---- Round-2 additions: Nak, hostname-63, large PRL, DNS4, combined ---- + { + let nak = packet::Packet { + reply: true, + hops: 0, + xid: 0x1234_5678, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [1, 2, 3, 4, 5, 6], + options: vec![ + options::DhcpOption::DhcpMessageType(options::MessageType::Nak), + options::DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + options::DhcpOption::Message("Requested IP not available".to_string()), + ], + }; + bench_fn("encode/nak", N, || { + let n = black_box(&nak).encode(black_box(&mut encode_buf)).len(); + black_box(n); + }); + let mut h63 = discover_packet(); + h63.options + .push(options::DhcpOption::HostName("h".repeat(63))); + let h63_wire = h63.encode(&mut encode_buf).to_vec(); + bench_fn("decode/hostname-63", 100_000, || { + black_box( + packet::Packet::from(black_box(&h63_wire)) + .ok() + .expect("decode"), + ); + }); + bench_fn("encode/hostname-63", 100_000, || { + let n = black_box(&h63).encode(black_box(&mut encode_buf)).len(); + black_box(n); + }); + bench_fn("decode+msgtype/request", N, || { + let p = packet::Packet::from(black_box(&r_wire)) + .ok() + .expect("decode"); + black_box(p.message_type().ok()); + black_box(p.option(50).is_some()); + black_box(p.option(12).is_some()); + }); + } + { + const PRL10: &[u8] = &[1, 3, 6, 15, 51, 54, 58, 59, 42, 12]; + bench_fn("filter/prl-10", N, || { + let mut o = offer_options(); + o.insert( + 0, + options::DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + ); + o.insert( + 0, + options::DhcpOption::DhcpMessageType(options::MessageType::Offer), + ); + server::filter_options_by_req(black_box(&mut o), black_box(PRL10)); + black_box(o); + }); + let dns4 = options::DhcpOption::DomainNameServer(vec![ + Ipv4Addr::new(8, 8, 8, 8), + Ipv4Addr::new(8, 8, 4, 4), + Ipv4Addr::new(1, 1, 1, 1), + Ipv4Addr::new(9, 9, 9, 9), + ]); + bench_fn("options/to_raw-dns4", N, || { + black_box(black_box(&dns4).to_raw()); + }); + bench_fn("reply/asm-no-prl", N, || { + let o = offer_options(); + black_box(reply_assembly(black_box(&o), None)); + }); + bench_fn("reply/asm-prl-4", N, || { + let o = offer_options(); + black_box(reply_assembly(black_box(&o), Some(black_box(&[1u8, 3, 6, 51])))); + }); + } + { + let v6_big = dhcpv6::Packet { + msg_type: dhcpv6::MsgType::Solicit, + transaction_id: 0x112233, + options: vec![ + dhcpv6::Dhcpv6Option::ClientId(vec![ + 0, 3, 0, 1, 2, 0, 0x5e, 0xaa, 0xbb, 0xcc, + ]), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid: 9, + t1: 0, + t2: 0, + addrs: vec![ + dhcpv6::IaAddr { + addr: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x101), + preferred: 100, + valid: 200, + }, + dhcpv6::IaAddr { + addr: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x102), + preferred: 100, + valid: 200, + }, + dhcpv6::IaAddr { + addr: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x103), + preferred: 100, + valid: 200, + }, + dhcpv6::IaAddr { + addr: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x104), + preferred: 100, + valid: 200, + }, + ], + }), + ], + }; + let big_wire = v6_big.encode().unwrap(); + bench_fn("v6/decode-iana-4addr", N, || { + black_box(dhcpv6::Packet::from(black_box(&big_wire)).expect("v6 decode")); + }); + bench_fn("v6/encode-iana-4addr", N, || { + black_box(black_box(&v6_big).encode().unwrap()); + }); + let mut pool = dhcpv6::V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x300), + 252, + Duration::from_secs(86400), + ); + bench_fn("v6pool/release-hit", 20_000, || { + let mut d = vec![8, 8, 8, 8]; + d.extend_from_slice(&black_box(pool.len() as u32).to_be_bytes()); + d.extend_from_slice(&black_box(999u32).to_be_bytes()); + if let Some(ip) = pool.discover(&d) { + let _ = pool.request(&d, ip); + } + black_box(pool.release(black_box(&d))); + }); + } + + // ---- Final-round diagnostics: hostile wire + v6 mix (not headlines) ---- + { + // 4096B / 1285-option bomb: proves Vec growth stays linear/bounded. + let mut opts = vec![53u8, 1, 1]; + for _ in 0..1284 { + opts.extend_from_slice(&[200, 1, 7]); + } + opts.push(255); + let mut raw = vec![0u8; 236]; + raw[0] = 1; + raw[1] = 1; + raw[2] = 6; + raw.extend_from_slice(&[99, 130, 83, 99]); + raw.extend_from_slice(&opts); + assert_eq!(raw.len(), 4096); + bench_fn("diag/decode-4096b", 5_000, || { + let p = packet::Packet::from(black_box(&raw)).ok().expect("decode"); + black_box(p.options.len()); + }); + // Mixed reject rate: truncated, bad type, short, bad cookie, + // non-UTF8 hostname, Router fast-fail. No-cliff proof only. + let mut bad_cookie = vec![0u8; 236]; + bad_cookie[0] = 1; + bad_cookie[1] = 1; + bad_cookie[2] = 6; + bad_cookie.extend_from_slice(&[9, 9, 9, 9, 53, 1, 1, 255]); + let cases: Vec> = vec![ + { + let mut v = vec![0u8; 236]; + v[0] = 1; + v[1] = 1; + v[2] = 6; + v.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 200, 255, 7]); + v + }, + { + let mut v = vec![0u8; 236]; + v[0] = 1; + v[1] = 1; + v[2] = 6; + v.extend_from_slice(&[99, 130, 83, 99, 53, 1, 9]); + v + }, + vec![0u8; 10], + bad_cookie, + ]; + bench_fn("diag/reject-mixed", 20_000, || { + for c in &cases { + black_box(packet::Packet::from(black_box(c)).is_err()); + } + }); + } + { + // v6 traffic mix: solicit-fresh / renew-hit / release, half-full pool. + let mut pool = dhcpv6::V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x400), + 252, + Duration::from_secs(86400), + ); + for i in 0..126u32 { + let mut duid = vec![0, 3, 0, 1]; + duid.extend_from_slice(&i.to_be_bytes()); + let ip = Ipv6Addr::from(u128::from(pool.start_addr()) + i as u128); + pool.insert(ip, duid, Some(Instant::now() + Duration::from_secs(3600))); + } + let mut state: u64 = 0x1234_5678_9abc_def0; + let mut next_rand = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + bench_fn("mix/v6-solicit-renew-release", 50_000, || { + let r = next_rand(); + let mut duid = vec![0, 3, 0, 1]; + duid.extend_from_slice(&((r & 0xFFFF) as u32).to_be_bytes()); + match r % 3 { + 0 => { + black_box(pool.discover(black_box(&duid))); + } + 1 => { + if let Some(ip) = pool.discover(&duid) { + black_box(pool.request(black_box(&duid), black_box(ip)).ok()); + } + } + _ => { + black_box(pool.release(black_box(&duid))); + } + } + }); + } + + // Full UDP loopback (dominant for "requests per second it can do"). + bench_udp_roundtrip(); + if std::env::var("BENCH_SUSTAINED").is_ok() { + bench_udp_sustained(); + } + println!("=== done (median of 3 runs; higher req/s is better) ==="); +} + +fn bench_udp_sustained() { + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + struct CountHandler { + n: Arc, + offer: Vec, + } + impl server::Handler for CountHandler { + fn handle_request(&mut self, s: &server::Server, p: packet::Packet) { + self.n.fetch_add(1, Ordering::Relaxed); + let _ = s.reply( + options::MessageType::Offer, + self.offer.clone(), + Ipv4Addr::new(192, 168, 2, 10), + p, + ); + } + } + let mut medians = vec![]; + for _ in 0..1 { + let srv = UdpSocket::bind("127.0.0.1:0").expect("bind srv"); + let sa = srv.local_addr().unwrap(); + let handled = Arc::new(AtomicU64::new(0)); + let h = handled.clone(); + let offer = offer_options(); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv, + Ipv4Addr::new(127, 0, 0, 1), + Ipv4Addr::new(127, 0, 0, 1), + CountHandler { n: h, offer }, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let tx = UdpSocket::bind("127.0.0.1:0").expect("bind tx"); + tx.connect(sa).unwrap(); + let rx = tx.try_clone().unwrap(); + rx.set_read_timeout(Some(Duration::from_millis(500))).ok(); + let wire = { + let mut t = [0u8; dhcp4r::packet::WIRE_MAX]; + discover_packet().encode(&mut t).to_vec() + }; + let lats = Arc::new(Mutex::new(Vec::with_capacity(1 << 20))); + let run = Arc::new(AtomicBool::new(true)); + let rx_lats = lats.clone(); + let rx_flag = run.clone(); + let rxh = std::thread::spawn(move || { + let mut b = [0u8; 1500]; + let mut n = 0u64; + while rx_flag.load(Ordering::Relaxed) { + let t0 = Instant::now(); + if rx.recv(&mut b).is_ok() { + n += 1; + rx_lats.lock().unwrap().push(t0.elapsed()); + } + } + n + }); + let dur = Duration::from_secs(10); + let t0 = Instant::now(); + let mut sent = 0u64; + while t0.elapsed() < dur { + tx.send(black_box(&wire)).unwrap(); + sent += 1; + } + std::thread::sleep(Duration::from_millis(500)); + run.store(false, Ordering::Relaxed); + let got = rxh.join().unwrap(); + let hv = handled.load(Ordering::Relaxed); + let mut v = lats.lock().unwrap().clone(); + v.sort_unstable(); + let pct = |q: f64| v.get((v.len() as f64 * q) as usize).copied().unwrap_or_default(); + let loss = 100.0 * (sent.saturating_sub(got) as f64) / (sent.max(1) as f64); + let rps = hv as f64 / dur.as_secs_f64(); + println!( + "{:<28} {:>12.0} req/s (sent={} handled={} rx={} loss={:.1}% p50={:?} p99={:?})", + "udp/sustained-10s", + rps, + sent, + hv, + got, + loss, + pct(0.5), + pct(0.99) + ); + medians.push(rps); + } + medians.sort_by(|a, b| a.partial_cmp(b).unwrap()); + println!( + "{:<28} {:>12.0} req/s (median)", + "udp/sustained-median", + medians[medians.len() / 2] + ); +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..303bc0d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,323 @@ +//! `dhcp.conf` configuration file support (zero dependencies). +//! +//! All server settings — previously hard-coded constants in +//! `examples/server.rs` — now live in a single `dhcp.conf` file using a +//! minimal `key = value` format: +//! +//! ```text +//! # lines starting with '#' or ';' are comments; blank lines are ignored +//! server_ip = 192.168.2.1 +//! dns_ips = 8.8.8.8, 8.8.4.4 +//! ``` +//! +//! Rules (locked by `tests/test_config.rs`): +//! - keys are case-insensitive; surrounding whitespace is trimmed; +//! - a trailing ` # comment` (whitespace + `#`) is stripped from values; +//! - duplicate keys: last occurrence wins; +//! - unknown keys are ignored (forward compatibility); +//! - a line without `=` (that is not blank/comment) is an error; +//! - empty keys and empty values are errors; +//! - every value is validated; failures name the key and line number. +//! +//! [`Config::defaults`] exactly matches the historical hard-coded constants, +//! so running without a `dhcp.conf` behaves exactly as before. + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +/// All tunable server settings. See [`Config::defaults`] for the values the +/// server historically used when everything was hard-coded. +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + // -- IPv4 (DHCPv4, RFC 2131) -- + pub server_ip: Ipv4Addr, + pub ip_start: Ipv4Addr, + pub lease_num: u32, + pub subnet_mask: Ipv4Addr, + pub router_ip: Ipv4Addr, + pub dns_ips: Vec, + /// Master switch for advertising NTP servers (DHCP option 42). Off by + /// default so a missing config file preserves old behavior exactly + /// (the old server never sent option 42). + pub enable_ntp: bool, + /// NTP servers advertised to clients (option 42), comma-separated. + /// Only sent when `enable_ntp` is true and the list is non-empty. + pub ntp_ips: Vec, + /// Master switch for DHCP Rapid Commit (RFC 4039 option 80 for IPv4, + /// RFC 8415 option 14 for IPv6): when true, a Solicit/Discover carrying + /// the rapid-commit option is answered immediately (Reply/Ack) instead + /// of Advertise/Offer. Off by default so a missing config file preserves + /// old behavior exactly. + pub enable_rapid_commit: bool, + pub broadcast_ip: Ipv4Addr, + /// Extra broadcast destinations for replies (comma-separated). Every + /// broadcast-routed reply is sent to each address in + /// [`Config::broadcasts`]: some clients/APs only accept the limited + /// broadcast `255.255.255.255`, others only the subnet directed + /// broadcast — listing both reaches both populations. Unicast replies + /// are unaffected (sent once to the peer). + pub extra_broadcast_ips: Vec, + pub lease_duration_secs: u32, + pub leases_file: String, + pub listen_addr: SocketAddr, + // -- IPv6 managed range (DHCPv6, RFC 8415) -- + pub enable_dhcpv6: bool, + /// Server DUID served in SERVERID options (opaque bytes, 1..=128). + pub server_duid: Vec, + /// First address of the managed IPv6 pool. + pub ipv6_start: Ipv6Addr, + /// Number of addresses in the managed IPv6 pool. + pub ipv6_lease_num: u64, + pub ipv6_dns: Vec, + pub ipv6_lease_duration_secs: u32, + pub leases_file_v6: String, + pub listen_addr_v6: SocketAddr, +} + +impl Config { + /// Defaults identical to the historical hard-coded constants + /// (`examples/server.rs` before `dhcp.conf` existed). DHCPv6 is off by + /// default so a missing config file preserves old behavior exactly. + pub fn defaults() -> Config { + Config { + server_ip: Ipv4Addr::new(192, 168, 2, 1), + ip_start: Ipv4Addr::new(192, 168, 2, 2), + lease_num: 252, + subnet_mask: Ipv4Addr::new(255, 255, 255, 0), + router_ip: Ipv4Addr::new(192, 168, 2, 1), + dns_ips: vec![Ipv4Addr::new(8, 8, 8, 8)], + enable_ntp: false, + ntp_ips: vec![], + enable_rapid_commit: false, + broadcast_ip: Ipv4Addr::new(192, 168, 2, 255), + extra_broadcast_ips: vec![], + lease_duration_secs: 86400, + leases_file: "leases".to_string(), + listen_addr: SocketAddr::from(([0, 0, 0, 0], 67)), + enable_dhcpv6: false, + server_duid: vec![ + 0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc, + ], + ipv6_start: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + ipv6_lease_num: 1000, + ipv6_dns: vec![Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)], + ipv6_lease_duration_secs: 86400, + leases_file_v6: "leases6".to_string(), + listen_addr_v6: SocketAddr::from(( + Ipv6Addr::UNSPECIFIED, + 547, + )), + } + } + + /// First usable IPv4 address as a number (pool is + /// `[ip_start_num, ip_start_num + lease_num)`). + pub fn ip_start_num(&self) -> u32 { + u32::from_be_bytes(self.ip_start.octets()) + } + + /// First usable IPv6 address as a number (pool is + /// `[ipv6_start_num, ipv6_start_num + ipv6_lease_num)`). + pub fn ipv6_start_num(&self) -> u128 { + u128::from(self.ipv6_start) + } + + /// All broadcast destinations for replies, primary first, + /// de-duplicated. Never empty (falls back to the primary alone). + pub fn broadcasts(&self) -> Vec { + let mut out = Vec::with_capacity(1 + self.extra_broadcast_ips.len()); + out.push(self.broadcast_ip); + for ip in &self.extra_broadcast_ips { + if !out.contains(ip) { + out.push(*ip); + } + } + out + } + + /// Load and parse the file at `path`. + pub fn load(path: &str) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {}", path, e))?; + Config::parse(&text) + } + + /// Parse `dhcp.conf` text; starts from [`Config::defaults`] and applies + /// each key in order. + pub fn parse(text: &str) -> Result { + let mut cfg = Config::defaults(); + for (idx, raw_line) in text.lines().enumerate() { + let line_no = idx + 1; + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let eq = line.find('=').ok_or_else(|| { + format!("{}: expected `key = value`, got {:?}", line_no, raw_line.trim()) + })?; + let key = line[..eq].trim().to_lowercase(); + let mut value = line[eq + 1..].trim().to_string(); + // strip trailing " # comment" (whitespace + '#'); a '#' glued to + // the value is kept verbatim. + if let Some(pos) = value.find(" #").or_else(|| value.find('\t').and_then(|p| { + if value[p + 1..].starts_with('#') { + Some(p) + } else { + None + } + })) { + value.truncate(pos); + value = value.trim_end().to_string(); + } + if key.is_empty() { + return Err(format!("{}: empty key", line_no)); + } + if value.is_empty() { + return Err(format!("{}: empty value for key {:?}", line_no, key)); + } + cfg.apply(&key, &value, line_no)?; + } + cfg.validate() + } + + fn apply(&mut self, key: &str, value: &str, line: usize) -> Result<(), String> { + let bad = |what: &str| format!("{}: invalid {} for key {:?}: {:?}", line, what, key, value); + match key { + "server_ip" => self.server_ip = value.parse().map_err(|_| bad("IPv4 address"))?, + "ip_start" => self.ip_start = value.parse().map_err(|_| bad("IPv4 address"))?, + "lease_num" => self.lease_num = value.parse().map_err(|_| bad("u32"))?, + "subnet_mask" => self.subnet_mask = value.parse().map_err(|_| bad("IPv4 address"))?, + "router_ip" => self.router_ip = value.parse().map_err(|_| bad("IPv4 address"))?, + "dns_ips" => self.dns_ips = parse_ipv4_list(value).map_err(|_| bad("IPv4 list"))?, + "enable_ntp" => self.enable_ntp = parse_bool(value).ok_or_else(|| bad("bool"))?, + "ntp_ips" => self.ntp_ips = parse_ipv4_list(value).map_err(|_| bad("IPv4 list"))?, + "enable_rapid_commit" => { + self.enable_rapid_commit = parse_bool(value).ok_or_else(|| bad("bool"))? + } + "broadcast_ip" => { + // Accepts a comma-separated list: the first address stays the + // primary `broadcast_ip`, the rest extend + // `extra_broadcast_ips` (de-duplicated by `broadcasts()`). + let mut parts = value.split(','); + let first = parts.next().ok_or_else(|| bad("IPv4 address"))?; + self.broadcast_ip = first + .trim() + .parse() + .map_err(|_| bad("IPv4 address"))?; + for part in parts { + self.extra_broadcast_ips.push( + part.trim().parse().map_err(|_| bad("IPv4 address"))?, + ); + } + } + "extra_broadcast_ips" => { + let mut list = + parse_ipv4_list(value).map_err(|_| bad("IPv4 list"))?; + self.extra_broadcast_ips.append(&mut list); + } + "lease_duration_secs" => { + self.lease_duration_secs = value.parse().map_err(|_| bad("u32"))? + } + "leases_file" => self.leases_file = value.to_string(), + "listen_addr" => { + self.listen_addr = value.parse().map_err(|_| bad("socket address"))? + } + "enable_dhcpv6" => self.enable_dhcpv6 = parse_bool(value).ok_or_else(|| bad("bool"))?, + "server_duid" => self.server_duid = parse_duid_hex(value).map_err(|_| bad("DUID hex"))?, + "ipv6_start" => self.ipv6_start = value.parse().map_err(|_| bad("IPv6 address"))?, + "ipv6_lease_num" => self.ipv6_lease_num = value.parse().map_err(|_| bad("u64"))?, + "ipv6_dns" => self.ipv6_dns = parse_ipv6_list(value).map_err(|_| bad("IPv6 list"))?, + "ipv6_lease_duration_secs" => { + self.ipv6_lease_duration_secs = value.parse().map_err(|_| bad("u32"))? + } + "leases_file_v6" => self.leases_file_v6 = value.to_string(), + "listen_addr_v6" => { + self.listen_addr_v6 = value.parse().map_err(|_| bad("socket address"))? + } + // unknown keys are ignored for forward compatibility + _ => {} + } + Ok(()) + } + + /// Cross-field validation: pool ranges must not overflow the address + /// space, and the DUID must fit RFC 8415 limits (1..=128 bytes). + fn validate(self) -> Result { + if self.server_duid.is_empty() || self.server_duid.len() > 128 { + return Err("server_duid must be 1..=128 bytes".to_string()); + } + let v4_end = u64::from(self.ip_start_num()) + u64::from(self.lease_num); + if v4_end > u64::from(u32::MAX) + 1 { + return Err("ip_start + lease_num overflows IPv4 space".to_string()); + } + if (self.ipv6_start_num()) + .checked_add(self.ipv6_lease_num as u128) + .is_none() + { + return Err("ipv6_start + ipv6_lease_num overflows IPv6 space".to_string()); + } + Ok(self) + } +} + +fn parse_bool(s: &str) -> Option { + match s.to_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Some(true), + "false" | "no" | "off" | "0" => Some(false), + _ => None, + } +} + +fn parse_ipv4_list(s: &str) -> Result, String> { + s.split(',') + .map(|part| { + part.trim() + .parse::() + .map_err(|e| format!("{:?}: {}", part.trim(), e)) + }) + .collect() +} + +fn parse_ipv6_list(s: &str) -> Result, String> { + s.split(',') + .map(|part| { + part.trim() + .parse::() + .map_err(|e| format!("{:?}: {}", part.trim(), e)) + }) + .collect() +} + +/// Parse opaque hex bytes (a DUID), accepting `00:03:00:01...`, `00-03-...` +/// or plain `00030001...` (whitespace between bytes is also tolerated). +/// +/// Used for `server_duid` values and the DUID field of `leases6` lines. +pub fn parse_duid_hex(s: &str) -> Result, String> { + #[inline] + fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 2); + let mut hi: Option = None; + for &c in bytes { + if c == b':' || c == b'-' || c == b' ' || c == b'\t' { + continue; + } + let v = hex_val(c).ok_or_else(|| format!("invalid hex {:?}: {:?}", s, c as char))?; + if let Some(h) = hi { + out.push((h << 4) | v); + hi = None; + } else { + hi = Some(v); + } + } + if hi.is_some() || out.is_empty() { + return Err(format!("odd-length hex: {:?}", s)); + } + Ok(out) +} diff --git a/src/dhcpv6.rs b/src/dhcpv6.rs new file mode 100644 index 0000000..2f46c92 --- /dev/null +++ b/src/dhcpv6.rs @@ -0,0 +1,875 @@ +//! Minimal DHCPv6 (RFC 8415) packet codec plus a managed-range allocator. +//! +//! Wire format (RFC 8415 §7): `msg-type` (1 octet) + `transaction-id` +//! (3 octets) followed by options to the end of the datagram. Unlike DHCPv4 +//! there is **no END byte and no PAD**: options are `code` (u16 BE) + +//! `len` (u16 BE) + `data`, parsed strictly — any truncation or malformed +//! option rejects the whole datagram with [`DecodeError`] (there is no +//! v4-style swallow-and-desync quirk here by design). +//! +//! Identity is the client DUID (opaque bytes, option 1); address state is +//! tracked per IA_NA (option 3, IAID + IA Address sub-options). [`V6Pool`] +//! manages one contiguous range `[start, start + count)` with round-robin +//! allocation mirroring the v4 example, except infinite (`None` expiry) +//! leases are **not** stealable (this intentionally differs from the v4 +//! `available()` quirk — see its docs). + +use std::collections::HashMap; +use std::net::Ipv6Addr; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Message types (RFC 8415 §7.3) +// --------------------------------------------------------------------------- + +/// DHCPv6 message types with their RFC 8415 §7.3 numeric values. +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum MsgType { + Solicit = 1, + Advertise = 2, + Request = 3, + Confirm = 4, + Renew = 5, + Rebind = 6, + Reply = 7, + Release = 8, + Decline = 9, + Reconfigure = 10, + InformationRequest = 11, + RelayForward = 12, + RelayReply = 13, +} + +impl MsgType { + pub fn from(val: u8) -> Result { + match val { + 1 => Ok(MsgType::Solicit), + 2 => Ok(MsgType::Advertise), + 3 => Ok(MsgType::Request), + 4 => Ok(MsgType::Confirm), + 5 => Ok(MsgType::Renew), + 6 => Ok(MsgType::Rebind), + 7 => Ok(MsgType::Reply), + 8 => Ok(MsgType::Release), + 9 => Ok(MsgType::Decline), + 10 => Ok(MsgType::Reconfigure), + 11 => Ok(MsgType::InformationRequest), + 12 => Ok(MsgType::RelayForward), + 13 => Ok(MsgType::RelayReply), + _ => Err(DecodeError::BadMessageType(val)), + } + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Reasons a DHCPv6 datagram (or option) cannot be decoded. +#[derive(Debug, Clone, PartialEq)] +pub enum DecodeError { + /// Fewer bytes than the header / declared length requires. + Truncated, + /// Unknown message type byte. + BadMessageType(u8), + /// Text option is not valid UTF-8. + BadUtf8, + /// Well-framed but internally inconsistent (short IA_NA, short IAADDR, + /// DNS length not a multiple of 16, short status, trailing < 4 bytes). + Malformed, +} + +/// Reasons a [`Packet`] cannot be encoded. +#[derive(Debug, Clone, PartialEq)] +pub enum EncodeError { + /// A single option payload exceeds the 16-bit length field (65535). + OptionTooLarge, +} + +// --------------------------------------------------------------------------- +// Options (RFC 8415 §24 and friends) +// --------------------------------------------------------------------------- + +pub const OPT_CLIENTID: u16 = 1; +pub const OPT_SERVERID: u16 = 2; +pub const OPT_IA_NA: u16 = 3; +pub const OPT_IA_TA: u16 = 4; +pub const OPT_IAADDR: u16 = 5; +pub const OPT_ORO: u16 = 6; +pub const OPT_PREFERENCE: u16 = 7; +pub const OPT_ELAPSED_TIME: u16 = 8; +pub const OPT_RELAY_MSG: u16 = 9; +pub const OPT_AUTH: u16 = 11; +pub const OPT_UNICAST: u16 = 12; +pub const OPT_STATUS_CODE: u16 = 13; +pub const OPT_RAPID_COMMIT: u16 = 14; +pub const OPT_USER_CLASS: u16 = 15; +pub const OPT_VENDOR_CLASS: u16 = 16; +pub const OPT_VENDOR_OPTS: u16 = 17; +pub const OPT_INTERFACE_ID: u16 = 18; +pub const OPT_RECONF_MSG: u16 = 19; +pub const OPT_RECONF_ACCEPT: u16 = 20; +pub const OPT_DNS_SERVERS: u16 = 23; +pub const OPT_DOMAIN_LIST: u16 = 24; +pub const OPT_IA_PD: u16 = 25; +pub const OPT_IAPREFIX: u16 = 26; + +/// RFC 8415 status codes (§21.13): 0 Success, 1 UnspecFail, 2 NoAddrsAvail, +/// 3 NoBinding, 4 UseMulticast, 5 NoPrefixAvail. +pub const STATUS_SUCCESS: u16 = 0; +pub const STATUS_NO_ADDRS_AVAIL: u16 = 2; + +/// An IA Address sub-option body (RFC 8415 §21.6): address plus its +/// preferred and valid lifetimes in seconds. +#[derive(Debug, Clone, PartialEq)] +pub struct IaAddr { + pub addr: Ipv6Addr, + pub preferred: u32, + pub valid: u32, +} + +/// An IA_NA option body (RFC 8415 §21.4): IAID + T1/T2 plus address bindings. +#[derive(Debug, Clone, PartialEq)] +pub struct IaNa { + pub iaid: u32, + pub t1: u32, + pub t2: u32, + pub addrs: Vec, +} + +/// Raw form of any option that has no typed variant here. +#[derive(Debug, Clone, PartialEq)] +pub struct RawDhcpv6Option { + pub code: u16, + pub data: Vec, +} + +/// A decoded DHCPv6 option. Anything without a typed variant is preserved +/// transparently as [`Dhcpv6Option::Unrecognized`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Dhcpv6Option { + /// Option 1: client DUID (opaque bytes). + ClientId(Vec), + /// Option 2: server DUID (opaque bytes). + ServerId(Vec), + /// Option 3: identity association for non-temporary addresses. + IaNa(IaNa), + /// Option 23 (RFC 3646): recursive name servers. + DnsServers(Vec), + /// Option 13: status code + human-readable message. + StatusCode(u16, String), + Unrecognized(RawDhcpv6Option), +} + +impl Dhcpv6Option { + #[inline] + pub fn wire_len(&self) -> usize { + 4 + match self { + Self::ClientId(d) | Self::ServerId(d) => d.len(), + Self::Unrecognized(r) => r.data.len(), + Self::DnsServers(a) => a.len() * 16, + Self::StatusCode(_, m) => 2 + m.len(), + Self::IaNa(ia) => 12 + ia.addrs.len() * 28, + } + } + + #[inline] + fn encode_into(&self, out: &mut Vec) -> Result<(), EncodeError> { + let payload = self.wire_len() - 4; + if payload > 0xFFFF { + return Err(EncodeError::OptionTooLarge); + } + out.extend_from_slice(&self.code().to_be_bytes()); + out.extend_from_slice(&(payload as u16).to_be_bytes()); + match self { + Self::ClientId(d) | Self::ServerId(d) => out.extend_from_slice(d), + Self::Unrecognized(r) => out.extend_from_slice(&r.data), + Self::DnsServers(a) => { + for ip in a { + out.extend_from_slice(&ip.octets()); + } + } + Self::StatusCode(c, m) => { + out.extend_from_slice(&c.to_be_bytes()); + out.extend_from_slice(m.as_bytes()); + } + Self::IaNa(ia) => { + out.extend_from_slice(&ia.iaid.to_be_bytes()); + out.extend_from_slice(&ia.t1.to_be_bytes()); + out.extend_from_slice(&ia.t2.to_be_bytes()); + for a in &ia.addrs { + let mut sub = [0u8; 28]; + sub[0..2].copy_from_slice(&OPT_IAADDR.to_be_bytes()); + sub[2..4].copy_from_slice(&24u16.to_be_bytes()); + sub[4..20].copy_from_slice(&a.addr.octets()); + sub[20..24].copy_from_slice(&a.preferred.to_be_bytes()); + sub[24..28].copy_from_slice(&a.valid.to_be_bytes()); + out.extend_from_slice(&sub); + } + } + } + Ok(()) + } + + pub fn to_raw(&self) -> RawDhcpv6Option { + match self { + Self::ClientId(duid) => RawDhcpv6Option { + code: OPT_CLIENTID, + data: duid.clone(), + }, + Self::ServerId(duid) => RawDhcpv6Option { + code: OPT_SERVERID, + data: duid.clone(), + }, + Self::IaNa(ia) => { + let mut data = Vec::with_capacity(12 + ia.addrs.len() * 28); + data.extend_from_slice(&ia.iaid.to_be_bytes()); + data.extend_from_slice(&ia.t1.to_be_bytes()); + data.extend_from_slice(&ia.t2.to_be_bytes()); + for a in &ia.addrs { + // Fixed 24-byte IAADDR body written directly (no per-addr + // sub-Vec alloc); trailing sub-sub-options are never + // emitted here by construction. + data.extend_from_slice(&OPT_IAADDR.to_be_bytes()); + data.extend_from_slice(&24u16.to_be_bytes()); + data.extend_from_slice(&a.addr.octets()); + data.extend_from_slice(&a.preferred.to_be_bytes()); + data.extend_from_slice(&a.valid.to_be_bytes()); + } + RawDhcpv6Option { + code: OPT_IA_NA, + data, + } + } + Self::DnsServers(addrs) => { + let mut data = Vec::with_capacity(addrs.len() * 16); + for a in addrs { + data.extend_from_slice(&a.octets()); + } + RawDhcpv6Option { + code: OPT_DNS_SERVERS, + data, + } + } + Self::StatusCode(code, msg) => { + let mut data = Vec::with_capacity(2 + msg.len()); + data.extend_from_slice(&code.to_be_bytes()); + data.extend_from_slice(msg.as_bytes()); + RawDhcpv6Option { + code: OPT_STATUS_CODE, + data, + } + } + Self::Unrecognized(raw) => raw.clone(), + } + } + + #[inline] + pub fn code(&self) -> u16 { + match self { + Self::ClientId(_) => OPT_CLIENTID, + Self::ServerId(_) => OPT_SERVERID, + Self::IaNa(_) => OPT_IA_NA, + Self::DnsServers(_) => OPT_DNS_SERVERS, + Self::StatusCode(_, _) => OPT_STATUS_CODE, + Self::Unrecognized(x) => x.code, + } + } +} + +/// Human-readable title for known option codes, if any. +pub fn title(code: u16) -> Option<&'static str> { + Some(match code { + OPT_CLIENTID => "Client Identifier", + OPT_SERVERID => "Server Identifier", + OPT_IA_NA => "Identity Association for Non-temporary Addresses", + OPT_IA_TA => "Identity Association for Temporary Addresses", + OPT_IAADDR => "IA Address", + OPT_ORO => "Option Request", + OPT_PREFERENCE => "Preference", + OPT_ELAPSED_TIME => "Elapsed Time", + OPT_RELAY_MSG => "Relay Message", + OPT_AUTH => "Authentication", + OPT_UNICAST => "Server Unicast", + OPT_STATUS_CODE => "Status Code", + OPT_RAPID_COMMIT => "Rapid Commit", + OPT_USER_CLASS => "User Class", + OPT_VENDOR_CLASS => "Vendor Class", + OPT_VENDOR_OPTS => "Vendor-specific Information", + OPT_INTERFACE_ID => "Interface-Id", + OPT_RECONF_MSG => "Reconfigure Message", + OPT_RECONF_ACCEPT => "Reconfigure Accept", + OPT_DNS_SERVERS => "DNS Recursive Name Server", + OPT_DOMAIN_LIST => "Domain Search List", + OPT_IA_PD => "Identity Association for Prefix Delegation", + OPT_IAPREFIX => "IA Prefix", + _ => return None, + }) +} + +// --------------------------------------------------------------------------- +// decode helpers +// --------------------------------------------------------------------------- + +#[inline] +fn be_u16(input: &[u8]) -> Result<(&[u8], u16), DecodeError> { + if input.len() < 2 { + return Err(DecodeError::Truncated); + } + Ok(( + &input[2..], + u16::from_be_bytes([input[0], input[1]]), + )) +} + +#[inline] +fn be_u32(input: &[u8]) -> Result<(&[u8], u32), DecodeError> { + if input.len() < 4 { + return Err(DecodeError::Truncated); + } + Ok(( + &input[4..], + u32::from_be_bytes([input[0], input[1], input[2], input[3]]), + )) +} + +#[inline] +fn take<'a>(input: &'a [u8], n: usize) -> Result<(&'a [u8], &'a [u8]), DecodeError> { + if input.len() < n { + return Err(DecodeError::Truncated); + } + Ok((&input[n..], &input[..n])) +} + +#[inline] +fn decode_iana(data: &[u8]) -> Result { + if data.len() < 12 { + return Err(DecodeError::Malformed); + } + let iaid = u32::from_be_bytes([data[0], data[1], data[2], data[3]]); + let t1 = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); + let t2 = u32::from_be_bytes([data[8], data[9], data[10], data[11]]); + let mut rest = &data[12..]; + let mut addrs = Vec::with_capacity(rest.len() / 28); + while !rest.is_empty() { + if rest.len() < 4 { + return Err(DecodeError::Malformed); + } + let slen = u16::from_be_bytes([rest[2], rest[3]]) as usize; + if rest.len() - 4 < slen { + return Err(DecodeError::Malformed); + } + let code = u16::from_be_bytes([rest[0], rest[1]]); + let body = &rest[4..4 + slen]; + rest = &rest[4 + slen..]; + if code == OPT_IAADDR { + if body.len() < 24 { + return Err(DecodeError::Malformed); + } + let mut octets = [0u8; 16]; + octets.copy_from_slice(&body[..16]); + addrs.push(IaAddr { + addr: Ipv6Addr::from(octets), + preferred: u32::from_be_bytes([body[16], body[17], body[18], body[19]]), + valid: u32::from_be_bytes([body[20], body[21], body[22], body[23]]), + }); + // any sub-sub-options past byte 24 are ignored + } + // unknown sub-options are skipped by length + } + Ok(IaNa { iaid, t1, t2, addrs }) +} + +/// Decode one option; returns the remainder plus the option. +#[inline] +pub fn decode_option(input: &[u8]) -> Result<(&[u8], Dhcpv6Option), DecodeError> { + if input.len() < 4 { + return Err(DecodeError::Truncated); + } + let code = u16::from_be_bytes([input[0], input[1]]); + let len = u16::from_be_bytes([input[2], input[3]]) as usize; + if input.len() - 4 < len { + return Err(DecodeError::Truncated); + } + let data = &input[4..4 + len]; + let input = &input[4 + len..]; + let option = match code { + OPT_CLIENTID => Dhcpv6Option::ClientId(data.to_vec()), + OPT_SERVERID => Dhcpv6Option::ServerId(data.to_vec()), + OPT_IA_NA => Dhcpv6Option::IaNa(decode_iana(data)?), + OPT_DNS_SERVERS => { + if data.len() % 16 != 0 { + return Err(DecodeError::Malformed); + } + let mut addrs = Vec::with_capacity(data.len() / 16); + for chunk in data.chunks_exact(16) { + let mut octets = [0u8; 16]; + octets.copy_from_slice(chunk); + addrs.push(Ipv6Addr::from(octets)); + } + Dhcpv6Option::DnsServers(addrs) + } + OPT_STATUS_CODE => { + if data.len() < 2 { + return Err(DecodeError::Malformed); + } + let code = u16::from_be_bytes([data[0], data[1]]); + let msg = std::str::from_utf8(&data[2..]).map_err(|_| DecodeError::BadUtf8)?; + Dhcpv6Option::StatusCode(code, msg.to_string()) + } + _ => Dhcpv6Option::Unrecognized(RawDhcpv6Option { + code, + data: data.to_vec(), + }), + }; + Ok((input, option)) +} + +// --------------------------------------------------------------------------- +// Packet +// --------------------------------------------------------------------------- + +/// A DHCPv6 datagram: message type + 24-bit transaction id + options. +#[derive(Debug, Clone, PartialEq)] +pub struct Packet { + pub msg_type: MsgType, + pub transaction_id: u32, + pub options: Vec, +} + +impl Packet { + pub fn from(input: &[u8]) -> Result { + if input.len() < 4 { + return Err(DecodeError::Truncated); + } + let msg_type = MsgType::from(input[0])?; + let transaction_id = + ((input[1] as u32) << 16) | ((input[2] as u32) << 8) | (input[3] as u32); + let mut options = Vec::with_capacity(input.len().saturating_sub(4) / 8); + let mut rest = &input[4..]; + while !rest.is_empty() { + let (new_rest, option) = decode_option(rest)?; + rest = new_rest; + options.push(option); + } + Ok(Packet { + msg_type, + transaction_id, + options, + }) + } + + /// First option with the given code, if any. + #[inline] + pub fn option(&self, code: u16) -> Option<&Dhcpv6Option> { + for o in &self.options { + if o.code() == code { + return Some(o); + } + } + None + } + + /// First IA_NA option, if any (handlers key address state off this). + #[inline] + pub fn first_iana(&self) -> Option<&IaNa> { + for o in &self.options { + if let Dhcpv6Option::IaNa(ia) = o { + return Some(ia); + } + } + None + } + + /// Single-pass ClientId + IA_NA harvest (first-wins each); saves the + /// 2-3 linear scans handlers otherwise do per packet. + #[inline] + pub fn client_iana(&self) -> (Option<&[u8]>, Option<&IaNa>) { + let (mut c, mut ia) = (None, None); + for o in &self.options { + match o { + Dhcpv6Option::ClientId(d) if c.is_none() => c = Some(d.as_slice()), + Dhcpv6Option::IaNa(a) if ia.is_none() => ia = Some(a), + _ => {} + } + if c.is_some() && ia.is_some() { + break; + } + } + (c, ia) + } + + /// Encode to wire bytes. Only the low 24 bits of `transaction_id` are + /// emitted (the field is 3 octets); a single option payload over 65535 + /// bytes is rejected with [`EncodeError::OptionTooLarge`]. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = Vec::with_capacity(4 + self.options.iter().map(|o| o.wire_len()).sum::()); + out.push(self.msg_type as u8); + let tid = self.transaction_id & 0xFF_FF_FF; + out.push(((tid >> 16) & 0xFF) as u8); + out.push(((tid >> 8) & 0xFF) as u8); + out.push((tid & 0xFF) as u8); + for option in &self.options { + option.encode_into(&mut out)?; + } + Ok(out) + } +} + +/// True when the packet carries OUR Server ID (exact DUID byte match), i.e. +/// the client selected this server. Anything else (missing, wrong type, +/// different bytes) is false. +#[inline] +pub fn is_for_server(our_duid: &[u8], pkt: &Packet) -> bool { + match pkt.option(OPT_SERVERID) { + Some(Dhcpv6Option::ServerId(d)) => d.as_slice() == our_duid, + _ => false, + } +} + +/// Renewal/rebinding defaults for a lease of `lease_secs` seconds, mirroring +/// the RFC 2131 client-side rule the v4 tests use (T1 = 1/2, T2 = 7/8): +/// T1 = secs/2, T2 = secs - secs/8 (overflow-free). +#[inline] +pub fn default_t1_t2(lease_secs: u32) -> (u32, u32) { + (lease_secs / 2, lease_secs - lease_secs / 8) +} + +// --------------------------------------------------------------------------- +// Managed range allocator ("managed dhcpv6 range") +// --------------------------------------------------------------------------- + +/// Allocator for one contiguous managed IPv6 range +/// `[start, start + count)`, keyed by client DUID bytes. +/// +/// Semantics mirror the v4 example (`discover` prefers the current lease, +/// otherwise first-free round-robin; `request` prefers current, else inserts +/// a timed lease; `release` drops it) with one deliberate difference: +/// infinite (`None` expiry) leases are **not** stealable here — unlike the +/// v4 `available()` quirk, `None` means permanently reserved. +/// +/// In-range addresses live in a dense slot array indexed by `addr - start` +/// (O(1) loads, no hashing on the miss scan); out-of-range reservations +/// (e.g. `leases6` entries outside the pool) spill to `overflow`. Huge pools +/// (over `MAX_SLOTS`) fall back to hash-only storage. +/// +/// Discovery scans linearly like the v4 example, so keep `count` modest +/// (tens of thousands at most); absurd counts make each Solicitation walk +/// the whole range. +pub struct V6Pool { + start: u128, + end: Option, + count: u64, + last: u64, + lease_duration: Duration, + use_slots: bool, + slots: Vec, Option)>>, + filled: usize, + overflow: HashMap, Option)>, + by_duid: HashMap, Ipv6Addr>, +} + +/// Upper bound for slot-array allocation (100k slots ≈ 5MB); larger pools +/// use hash-only storage with identical semantics. +const MAX_SLOTS: u64 = 100_000; + +impl V6Pool { + pub fn new(start: Ipv6Addr, count: u64, lease_duration: Duration) -> V6Pool { + let start_num = u128::from(start); + let use_slots = count <= MAX_SLOTS && count <= usize::MAX as u64; + let slots = if use_slots { + let mut s = Vec::new(); + s.resize_with(count as usize, || None); + s + } else { + Vec::new() + }; + V6Pool { + start: start_num, + end: start_num.checked_add(count as u128), + count, + last: 0, + lease_duration, + use_slots, + slots, + filled: 0, + overflow: HashMap::new(), + by_duid: HashMap::with_capacity(count.min(4096) as usize), + } + } + + pub fn start_addr(&self) -> Ipv6Addr { + Ipv6Addr::from(self.start) + } + + pub fn count(&self) -> u64 { + self.count + } + + /// Number of leases currently held. + pub fn len(&self) -> usize { + self.filled + self.overflow.len() + } + + pub fn is_empty(&self) -> bool { + self.filled == 0 && self.overflow.is_empty() + } + + fn end(&self) -> Option { + self.end + } + + fn in_range(&self, addr: &Ipv6Addr) -> bool { + let pos: u128 = (*addr).into(); + pos >= self.start && self.end.map_or(false, |end| pos < end) + } + + /// Slot index for in-range addresses. `None` for out-of-range, + /// overflowed pools (`end` is `None`), and hash-only huge pools. + #[inline] + fn idx(&self, addr: &Ipv6Addr) -> Option { + if !self.use_slots || self.end.is_none() { + return None; + } + let off = u128::from(*addr).checked_sub(self.start)?; + if off < self.count as u128 { + Some(off as usize) + } else { + None + } + } + + /// Unified lookup (slot or overflow) for verification/`get` paths. + #[inline] + fn lookup(&self, addr: &Ipv6Addr) -> Option<&(Vec, Option)> { + match self.idx(addr) { + Some(i) => self.slots[i].as_ref(), + None => self.overflow.get(addr), + } + } + + #[inline] + fn slot_available(&self, i: usize, duid: &[u8], now: Instant) -> bool { + match &self.slots[i] { + Some((d, expiry)) => { + d.as_slice() == duid || expiry.map_or(false, |exp| now > exp) + } + None => true, + } + } + + /// Insert a reservation directly (used for `leases6` file entries). + /// `None` expiry means a permanent (infinite) reservation. + /// Duplicate DUIDs for different IPs are preserved (like the original: + /// one DUID may hold several IPs; `current_lease` returns one of them), + /// while `by_duid` stays as an O(1) hint for the common 1:1 case. + /// Duplicate IPs last-wins. + pub fn insert(&mut self, addr: Ipv6Addr, duid: Vec, expiry: Option) { + if let Some(i) = self.idx(&addr) { + if let Some((old_duid, _)) = &self.slots[i] { + if *old_duid != duid && self.by_duid.get(old_duid) == Some(&addr) { + self.by_duid.remove(old_duid); + } + } + if self.slots[i].is_none() { + self.filled += 1; + } + self.slots[i] = Some((duid.clone(), expiry)); + self.by_duid.insert(duid, addr); + } else if !self.use_slots && self.in_range(&addr) { + // Huge hash-only pool: in-range entries live in overflow. + if let Some((old_duid, _)) = self.overflow.get(&addr) { + if *old_duid != duid && self.by_duid.get(old_duid) == Some(&addr) { + let old = old_duid.clone(); + self.by_duid.remove(&old); + } + } + self.by_duid.insert(duid.clone(), addr); + self.overflow.insert(addr, (duid, expiry)); + } else { + // Out-of-range reservation spillover. + if let Some((old_duid, _)) = self.overflow.get(&addr) { + if *old_duid != duid && self.by_duid.get(old_duid) == Some(&addr) { + let old = old_duid.clone(); + self.by_duid.remove(&old); + } + } + self.by_duid.insert(duid.clone(), addr); + self.overflow.insert(addr, (duid, expiry)); + } + } + + pub fn get(&self, addr: &Ipv6Addr) -> Option<&(Vec, Option)> { + self.lookup(addr) + } + + #[inline] + fn available_at(&self, duid: &[u8], addr: &Ipv6Addr, now: Instant) -> bool { + if let Some(i) = self.idx(addr) { + return self.slot_available(i, duid, now); + } + if !self.use_slots && self.in_range(addr) { + // Huge hash-only pool: consult overflow with the range gate. + return match self.overflow.get(addr) { + Some((d, expiry)) => { + d.as_slice() == duid || expiry.map_or(false, |exp| now > exp) + } + None => true, + }; + } + // Out-of-range is never available (verbatim). + false + } + + pub fn available(&self, duid: &[u8], addr: &Ipv6Addr) -> bool { + self.available_at(duid, addr, Instant::now()) + } + + #[inline] + pub fn current_lease(&self, duid: &[u8]) -> Option { + if self.is_empty() { + return None; + } + // Fast O(1) hint; verified because duplicate-DUID inserts and + // IP-takeover inserts can leave it stale. Falls back to a full scan + // (slots + overflow) so semantics stay identical. + if let Some(ip) = self.by_duid.get(duid).copied() { + if let Some((d, _)) = self.lookup(&ip) { + if d.as_slice() == duid { + return Some(ip); + } + } + } + if self.use_slots { + for (i, s) in self.slots.iter().enumerate() { + if let Some((d, _)) = s { + if d.as_slice() == duid { + return Some(Ipv6Addr::from(self.start + i as u128)); + } + } + } + } + for (ip, (d, _)) in &self.overflow { + if d.as_slice() == duid { + return Some(*ip); + } + } + // Huge hash-only pools keep in-range entries in overflow (scanned + // above); nothing else to scan. + None + } + + fn candidate(&self, offset: u64) -> Option { + self.start.checked_add(offset as u128).map(Ipv6Addr::from) + } + + /// Offer an address: current lease first, else first-free round-robin. + /// Returns `None` when the pool is exhausted (or empty); never panics, + /// even if `start + count` overflows (candidates past the end are + /// skipped as unavailable). + pub fn discover(&mut self, duid: &[u8]) -> Option { + if let Some(ip) = self.current_lease(duid) { + return Some(ip); + } + if self.count == 0 { + return None; + } + if !self.use_slots { + // Huge hash-only pool: original candidate path. + let now = Instant::now(); + let end = self.end; + for _ in 0..self.count { + self.last = (self.last + 1) % self.count; + if let Some(pos) = self.start.checked_add(self.last as u128) { + if end.map_or(false, |e| pos < e) { + let cand = Ipv6Addr::from(pos); + if self.available_at(duid, &cand, now) { + return Some(cand); + } + } + } + } + return None; + } + let now = Instant::now(); + let end = self.end; + for _ in 0..self.count { + self.last = (self.last + 1) % self.count; + let i = self.last as usize; + // Overflowed candidates are skipped (never panics), mirroring + // the old `candidate()` semantics. + if let Some(pos) = self.start.checked_add(self.last as u128) { + if end.map_or(false, |e| pos < e) { + if self.slot_available(i, duid, now) { + return Some(Ipv6Addr::from(pos)); + } + } + } + } + None + } + + /// Commit a lease: current lease wins unchanged (no + /// expiry refresh, mirroring v4); otherwise insert a timed lease if free. + pub fn request(&mut self, duid: &[u8], addr: Ipv6Addr) -> Result { + if let Some(ip) = self.current_lease(duid) { + return Ok(ip); + } + let now = Instant::now(); + if !self.available_at(duid, &addr, now) { + return Err("Requested address not available"); + } + let expiry = now + self.lease_duration; + if let Some(i) = self.idx(&addr) { + if self.slots[i].is_none() { + self.filled += 1; + } + self.slots[i] = Some((duid.to_vec(), Some(expiry))); + self.by_duid.insert(duid.to_vec(), addr); + } else { + self.by_duid.insert(duid.to_vec(), addr); + self.overflow.insert(addr, (duid.to_vec(), Some(expiry))); + } + Ok(addr) + } + + /// Drop the caller's current lease, if any. + pub fn release(&mut self, duid: &[u8]) -> bool { + if let Some(ip) = self.current_lease(duid) { + if let Some(i) = self.idx(&ip) { + self.slots[i] = None; + self.filled -= 1; + } else { + self.overflow.remove(&ip); + } + if self.by_duid.get(duid) == Some(&ip) { + self.by_duid.remove(duid); + // Re-point the hint at a surviving duplicate, if any. + if self.use_slots { + for (j, s) in self.slots.iter().enumerate() { + if let Some((d, _)) = s { + if d.as_slice() == duid { + self.by_duid.insert( + duid.to_vec(), + Ipv6Addr::from(self.start + j as u128), + ); + break; + } + } + } + } + if self.by_duid.get(duid).is_none() { + for (other_ip, (d, _)) in &self.overflow { + if d.as_slice() == duid { + self.by_duid.insert(duid.to_vec(), *other_ip); + break; + } + } + } + } + true + } else { + false + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..02d2165 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod config; +pub mod dhcpv6; +pub mod options; +pub mod packet; +pub mod server; + +#[cfg(test)] +mod tests { + #[test] + fn it_works() {} +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9f501c8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1168 @@ +//! RustyDHCP server binary: DHCPv4 + DHCPv6 service with request monitor. +//! +//! Single binary, no arguments: configuration always comes from `./dhcp.conf` +//! (built-in defaults when unreadable). Every DHCP Request is both served +//! (leases, Offer/Ack/Nak) and logged monitor-style: +//! `timestamp\tmac\tip\thostname\tOnline` (UTC, `-` when no host name). + +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; +use std::time::{Duration, Instant}; + +use dhcp4r::{config, dhcpv6, options, packet, server}; + +// Special value for infinite (permanent file-reserved) leases. +const INFINITE_LEASE: Option = None; + +fn main() { + // Fixed configuration path (no arguments). Without a readable file the + // server falls back to the built-in defaults, which match the old + // hard-coded constants exactly. + let cfg_path = "dhcp.conf".to_string(); + let config = match config::Config::load(&cfg_path) { + Ok(c) => { + println!("Loaded {} ({} v4 leases, dhcpv6 {})", cfg_path, c.lease_num, if c.enable_dhcpv6 { + "on" + } else { + "off" + }); + c + } + Err(e) => { + eprintln!("{}; using built-in defaults", e); + config::Config::defaults() + } + }; + { + // u64 math: validation guarantees ip_start + lease_num <= u32::MAX + 1, + // so ip_start + (lease_num - 1) always fits u32 here. + let v4_last = if config.lease_num == 0 { + "(empty pool)".to_string() + } else { + Ipv4Addr::from( + (config.ip_start_num() as u64 + config.lease_num as u64 - 1) as u32, + ) + .to_string() + }; + println!( + "IPv4 pool {} .. {} (server {}, broadcast {}; rapid_commit {}, ntp {})", + config.ip_start, + v4_last, + config.server_ip, + config + .broadcasts() + .iter() + .map(|b| b.to_string()) + .collect::>() + .join(","), + onoff(config.enable_rapid_commit), + onoff(config.enable_ntp), + ); + if config.enable_dhcpv6 { + println!( + "IPv6 pool {} +{} (server DUID {})", + config.ipv6_start, + config.ipv6_lease_num, + config + .server_duid + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":"), + ); + } + println!("listening on {}", config.listen_addr); + } + + if config.enable_dhcpv6 { + let v6 = config.clone(); + std::thread::spawn(move || { + let _ = serve_v6(&v6); + }); + } + + let socket = UdpSocket::bind(config.listen_addr).unwrap(); + socket.set_broadcast(true).unwrap(); + + // Ipv4Addr -> (MAC address, lease expiry, client host name) mapping. + // The host name comes from DHCP option 12 of the client's packets and + // is `None` until the client announces one (leases-file entries start + // unnamed too; the file format stays `mac,ip`). + // In-range leases live in a dense slot array (O(1) indexed probes, no + // hashing on the miss scan); out-of-range file entries spill to overflow. + let use_slots = config.lease_num <= 100_000; + let mut slots: Vec, Option)>> = if use_slots { + let mut s = Vec::new(); + s.resize_with(config.lease_num as usize, || None); + s + } else { + Vec::new() + }; + let mut filled: usize = 0; + let mut overflow: HashMap, Option)> = + HashMap::new(); + let mut by_mac: HashMap<[u8; 6], Ipv4Addr> = + HashMap::with_capacity(config.lease_num.min(1024) as usize); + let slot_idx = |ip: &Ipv4Addr, start: u32, num: u32| -> Option { + if !use_slots { + return None; + } + let off = u32::from(*ip).checked_sub(start)?; + if (off as u64) < num as u64 { + Some(off as usize) + } else { + None + } + }; + // Read and populate leases from the file + if let Ok(file) = File::open(&config.leases_file) { + let reader = BufReader::new(file); + for line in reader.lines() { + if let Ok(line) = line { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() == 2 { + let mac_parts: Vec = parts[0] + .split(':') + .filter_map(|part| u8::from_str_radix(part, 16).ok()) + .collect(); + + if mac_parts.len() == 6 { + let mut mac = [0u8; 6]; + mac.copy_from_slice(&mac_parts); + + let ip = parts[1].trim().parse::().unwrap(); + // Duplicate IPs last-wins (locked); duplicate MACs + // preserved with `by_mac` as O(1) hint. + if let Some(i) = slot_idx(&ip, config.ip_start_num(), config.lease_num) { + if let Some((old_mac, _, _)) = &slots[i] { + if *old_mac != mac && by_mac.get(old_mac) == Some(&ip) { + let old = *old_mac; + by_mac.remove(&old); + } + } + if slots[i].is_none() { + filled += 1; + } + slots[i] = Some((mac, INFINITE_LEASE, None)); + by_mac.insert(mac, ip); + } else { + if let Some((old_mac, _, _)) = overflow.get(&ip) { + if *old_mac != mac && by_mac.get(old_mac) == Some(&ip) { + let old = *old_mac; + by_mac.remove(&old); + } + } + // Huge hash-only pools keep in-range entries here too. + if !use_slots { + let start = config.ip_start_num(); + let num = config.lease_num; + let pos: u32 = ip.into(); + let in_range = pos + .checked_sub(start) + .map_or(false, |d| (d as u64) < num as u64); + if !in_range { + overflow.insert(ip, (mac, INFINITE_LEASE, None)); + by_mac.insert(mac, ip); + continue; + } + } + overflow.insert(ip, (mac, INFINITE_LEASE, None)); + by_mac.insert(mac, ip); + } + } + } + } + } + } else { + eprintln!("Failed to open leases file. Continuing..."); + //return; + } + + let cached_offer = build_offer_options( + config.subnet_mask, + config.router_ip, + &config.dns_ips, + &config.ntp_ips, + config.enable_ntp, + config.lease_duration_secs, + ); + let ms = MyServer { + use_slots, + slots, + filled, + overflow, + by_mac, + last_lease: 0, + lease_duration: Duration::new(config.lease_duration_secs as u64, 0), + ip_start_num: config.ip_start_num(), + lease_num: config.lease_num, + enable_rapid_commit: config.enable_rapid_commit, + broadcasts: config.broadcasts(), + cached_offer, + }; + + server::Server::serve_with_broadcasts( + socket, + config.server_ip, + config.broadcasts(), + ms, + ); +} + +/// Option list for Offer/Ack replies, mirroring the documented order: +/// lease time, subnet, router, DNS, then NTP (option 42) if enabled. +/// Built once at startup and cloned per reply (instead of rebuilt). +fn build_offer_options( + subnet_mask: Ipv4Addr, + router_ip: Ipv4Addr, + dns_ips: &[Ipv4Addr], + ntp_ips: &[Ipv4Addr], + enable_ntp: bool, + lease_secs: u32, +) -> Vec { + let mut opts = Vec::with_capacity(4 + (enable_ntp && !ntp_ips.is_empty()) as usize); + opts.push(options::DhcpOption::IpAddressLeaseTime(lease_secs)); + opts.push(options::DhcpOption::SubnetMask(subnet_mask)); + opts.push(options::DhcpOption::Router(vec![router_ip])); + opts.push(options::DhcpOption::DomainNameServer(dns_ips.to_vec())); + if enable_ntp && !ntp_ips.is_empty() { + let mut data = Vec::with_capacity(ntp_ips.len() * 4); + for ip in ntp_ips { + data.extend_from_slice(&ip.octets()); + } + opts.push(options::DhcpOption::Unrecognized( + options::RawDhcpOption { + code: options::NETWORK_TIME_PROTOCOL_SERVERS, + data, + }, + )); + } + opts +} + +struct MyServer { + // In-range leases: dense slot array indexed by `ip - ip_start_num`. + // Out-of-range file reservations spill to `overflow`. `by_mac` is an + // O(1) hint with fallback scan (duplicate MACs preserved). + use_slots: bool, + slots: Vec, Option)>>, + filled: usize, + overflow: HashMap, Option)>, + // Reverse index MAC -> IP for O(1) current_lease (1:1 invariant). + by_mac: HashMap<[u8; 6], Ipv4Addr>, + last_lease: u32, + lease_duration: Duration, + ip_start_num: u32, + lease_num: u32, + enable_rapid_commit: bool, + /// Broadcast destinations, primary first (mirrors `Server` fan-out). + broadcasts: Vec, + cached_offer: Vec, +} + +/// Client-announced host name (DHCP option 12), if the packet carries one. +/// Borrowed: clone only on insert/remember write paths. +fn hostname_of(in_packet: &packet::Packet) -> Option<&str> { + match in_packet.option(options::HOST_NAME) { + Some(options::DhcpOption::HostName(name)) => Some(name.as_str()), + _ => None, + } +} + +/// Current UTC time as `YYYY-MM-DDTHH:MM:SS` (std only, no timezone database). +fn utc_now_iso() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, m, d) = ymd_from_days((secs / 86400) as i64); + let t = secs % 86400; + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + y, + m, + d, + t / 3600, + (t % 3600) / 60, + t % 60 + ) +} + +/// Days since 1970-01-01 -> (year, month, day), Howard Hinnant's algorithm. +fn ymd_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12] + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Formats byte array machine address into hex pairs separated by colons. +/// Array must be at least one byte long. +fn chaddr(a: &[u8]) -> String { + a[1..].iter().fold(format!("{:02x}", a[0]), |acc, &b| { + format!("{}:{:02x}", acc, &b) + }) +} + +/// "on"/"off" flag rendering for startup diagnostics. +fn onoff(b: bool) -> &'static str { + if b { + "on" + } else { + "off" + } +} + +impl server::Handler for MyServer { + fn handle_request(&mut self, server: &server::Server, in_packet: packet::Packet) { + let hostname = hostname_of(&in_packet); + match in_packet.message_type() { + Ok(options::MessageType::Discover) => { + // Rapid Commit (RFC 4039, option 80): when enabled and asked + // for, commit immediately with an Ack (2-message exchange) + // instead of offering. Selection is Discover-style (current + // lease, else round-robin); Requested IP / ciaddr do NOT + // steer it. Pool exhausted behaves like Discover: silence. + let mac_s = chaddr(&in_packet.chaddr); + let rapid = self.enable_rapid_commit + && in_packet.option(options::RAPID_COMMIT).is_some(); + let opt_s = in_packet + .options + .iter() + .map(|o| o.code().to_string()) + .collect::>() + .join(","); + let prl_s = match in_packet.option(options::PARAMETER_REQUEST_LIST) { + Some(options::DhcpOption::ParameterRequestList(p)) => p + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(","), + _ => "-".to_string(), + }; + println!( + "v4 DISCOVER mac={} xid={} rapid={} bcast={} src={} opts=[{}] prl=[{}]", + mac_s, + in_packet.xid, + rapid, + in_packet.broadcast, + server.peer(), + opt_s, + prl_s + ); + if rapid { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.remember_hostname(&ip, &hostname); + println!( + "v4 DISCOVER mac={} xid={} -> ACK {} to {} (rapid commit, existing lease)", + mac_s, + in_packet.xid, + ip, + self.dsts_s(server, in_packet.broadcast, ip) + ); + self.reply(server, options::MessageType::Ack, in_packet, &ip); + return; + } + let now = Instant::now(); + let mut committed: Option = None; + for _ in 0..self.lease_num { + self.last_lease = (self.last_lease + 1) % self.lease_num; + let cand: Ipv4Addr = + (self.ip_start_num + self.last_lease).into(); + if self.available_at(&in_packet.chaddr, &cand, now) { + let expiry = now + self.lease_duration; + self.insert_lease(cand, in_packet.chaddr, Some(expiry), hostname); + committed = Some(cand); + break; + } + } + match committed { + Some(ip) => { + println!( + "v4 DISCOVER mac={} xid={} -> ACK {} to {} (rapid commit, new lease)", + mac_s, + in_packet.xid, + ip, + self.dsts_s(server, in_packet.broadcast, ip) + ); + self.reply(server, options::MessageType::Ack, in_packet, &ip); + } + None => println!( + "v4 DISCOVER mac={} xid={} -> no reply (rapid commit, pool exhausted {}/{} used)", + mac_s, + in_packet.xid, + self.filled + self.overflow.len(), + self.lease_num + ), + } + return; + } + // Otherwise prefer existing (including expired if available) + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.remember_hostname(&ip, &hostname); + println!( + "v4 DISCOVER mac={} xid={} -> OFFER {} to {} (existing lease)", + mac_s, + in_packet.xid, + ip, + self.dsts_s(server, in_packet.broadcast, ip) + ); + self.reply(server, options::MessageType::Offer, in_packet, &ip); + return; + } + // Otherwise choose a free ip if available + let now = Instant::now(); + let mut offered: Option = None; + for _ in 0..self.lease_num { + self.last_lease = (self.last_lease + 1) % self.lease_num; + let cand: Ipv4Addr = (self.ip_start_num + self.last_lease).into(); + if self.available_at(&in_packet.chaddr, &cand, now) { + offered = Some(cand); + break; + } + } + match offered { + Some(ip) => { + println!( + "v4 DISCOVER mac={} xid={} -> OFFER {} to {} (new lease)", + mac_s, + in_packet.xid, + ip, + self.dsts_s(server, in_packet.broadcast, ip) + ); + self.reply( + server, + options::MessageType::Offer, + in_packet, + &ip, + ); + } + None => println!( + "v4 DISCOVER mac={} xid={} -> no reply (pool exhausted {}/{} used)", + mac_s, + in_packet.xid, + self.filled + self.overflow.len(), + self.lease_num + ), + } + } + + Ok(options::MessageType::Request) => { + // Ignore requests to alternative DHCP server + let for_us = server.for_this_server(&in_packet); + // fall through when false (preserves old behavior) + + let req_ip = match in_packet.option(options::REQUESTED_IP_ADDRESS) { + Some(options::DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + let sid = match in_packet.option(options::SERVER_IDENTIFIER) { + Some(options::DhcpOption::ServerIdentifier(ip)) => ip.to_string(), + _ => "-".to_string(), + }; + let mac_s = chaddr(&in_packet.chaddr); + let opt_s = in_packet + .options + .iter() + .map(|o| o.code().to_string()) + .collect::>() + .join(","); + println!( + "v4 REQUEST mac={} xid={} req_ip={} ciaddr={} server_id={} bcast={} src={} opts=[{}]", + mac_s, + in_packet.xid, + req_ip, + in_packet.ciaddr, + sid, + in_packet.broadcast, + server.peer(), + opt_s + ); + if !for_us { + println!( + "v4 REQUEST mac={} xid={} note: server_id mismatch, serving anyway", + mac_s, in_packet.xid + ); + } + // Monitor log (old monitor binary behavior): every Request. + println!( + "{}\t{}\t{}\t{}\tOnline", + utc_now_iso(), + mac_s, + req_ip, + hostname.unwrap_or("-") + ); + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.remember_hostname(&ip, &hostname); + println!( + "v4 REQUEST mac={} xid={} -> ACK {} to {} (existing lease)", + mac_s, + in_packet.xid, + ip, + self.dsts_s(server, in_packet.broadcast, ip) + ); + self.reply(server, options::MessageType::Ack, in_packet, &ip); + return; + } + let now = Instant::now(); + if !self.available_at(&in_packet.chaddr, &req_ip, now) { + let in_pool = u32::from(req_ip) + .checked_sub(self.ip_start_num) + .map_or(false, |d| (d as u64) < self.lease_num as u64); + println!( + "v4 REQUEST mac={} xid={} -> NAK {} to {} ({})", + mac_s, + in_packet.xid, + req_ip, + self.dsts_s(server, in_packet.broadcast, Ipv4Addr::UNSPECIFIED), + if in_pool { + "requested IP in use by another client" + } else { + "requested IP outside pool range" + } + ); + self.nak(server, in_packet, "Requested IP not available"); + return; + } + let expiry = now + self.lease_duration; + self.insert_lease(req_ip, in_packet.chaddr, Some(expiry), hostname); + println!( + "v4 REQUEST mac={} xid={} -> ACK {} to {} (new lease)", + mac_s, + in_packet.xid, + req_ip, + self.dsts_s(server, in_packet.broadcast, req_ip) + ); + self.reply(server, options::MessageType::Ack, in_packet, &req_ip); + } + + Ok(options::MessageType::Release) | Ok(options::MessageType::Decline) => { + let kind = + match in_packet.message_type() { + Ok(options::MessageType::Release) => "RELEASE", + _ => "DECLINE", + }; + let mac_s = chaddr(&in_packet.chaddr); + // Ignore requests to alternative DHCP server + if !server.for_this_server(&in_packet) { + println!( + "v4 {} mac={} xid={} src={} ignored (server_id mismatch)", + kind, + mac_s, + in_packet.xid, + server.peer() + ); + return; + } + match self.current_lease(&in_packet.chaddr) { + Some(ip) => { + self.remove_lease(&in_packet.chaddr); + println!( + "v4 {} mac={} xid={} src={} freed {}", + kind, + mac_s, + in_packet.xid, + server.peer(), + ip + ); + } + None => println!( + "v4 {} mac={} xid={} src={} no lease held", + kind, + mac_s, + in_packet.xid, + server.peer() + ), + } + } + + // Anything else (e.g. INFORM, which this server does not implement) + // is ignored — but logged, so silent clients can be diagnosed. + other => match other { + Ok(t) => println!( + "v4 {:?} mac={} xid={} src={} ignored (unsupported message type)", + t, + chaddr(&in_packet.chaddr), + in_packet.xid, + server.peer() + ), + Err(e) => println!( + "v4 ? mac={} xid={} src={} ignored ({})", + chaddr(&in_packet.chaddr), + in_packet.xid, + server.peer(), + e + ), + }, + } + } +} + +impl MyServer { + /// Where [`server::Server::send`] would deliver a reply (mirrors its + /// routing exactly, for diagnostics). + fn dsts_for( + &self, + server: &server::Server, + broadcast: bool, + yiaddr: Ipv4Addr, + ) -> Vec { + server::Server::route_dests(server.peer(), broadcast, yiaddr, &self.broadcasts) + } + + /// `dsts_for` rendered for one-line logs. + fn dsts_s(&self, server: &server::Server, broadcast: bool, yiaddr: Ipv4Addr) -> String { + self.dsts_for(server, broadcast, yiaddr) + .iter() + .map(|d| d.to_string()) + .collect::>() + .join(",") + } + + #[inline] + fn idx(&self, addr: &Ipv4Addr) -> Option { + if !self.use_slots { + return None; + } + let off = u32::from(*addr).checked_sub(self.ip_start_num)?; + if (off as u64) < self.lease_num as u64 { + Some(off as usize) + } else { + None + } + } + + #[inline] + fn lookup(&self, addr: &Ipv4Addr) -> Option<&([u8; 6], Option, Option)> { + match self.idx(addr) { + Some(i) => self.slots[i].as_ref(), + None => self.overflow.get(addr), + } + } + + fn is_empty(&self) -> bool { + self.filled == 0 && self.overflow.is_empty() + } + + fn available_at(&self, chaddr: &[u8; 6], addr: &Ipv4Addr, now: Instant) -> bool { + if let Some(i) = self.idx(addr) { + return match &self.slots[i] { + Some((mac, expiry, _)) => { + *mac == *chaddr || expiry.map_or(true, |exp| now > exp) + } + None => true, + }; + } + if !self.use_slots { + let pos: u32 = (*addr).into(); + let in_range = pos + .checked_sub(self.ip_start_num) + .map_or(false, |d| (d as u64) < self.lease_num as u64); + if in_range { + return match self.overflow.get(addr) { + Some((mac, expiry, _)) => { + *mac == *chaddr || expiry.map_or(true, |exp| now > exp) + } + None => true, + }; + } + } + false + } + #[inline] + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + if self.is_empty() { + return None; + } + if let Some(ip) = self.by_mac.get(chaddr).copied() { + if let Some((m, _, _)) = self.lookup(&ip) { + if *m == *chaddr { + return Some(ip); + } + } + } + if self.use_slots { + for (i, s) in self.slots.iter().enumerate() { + if let Some((m, _, _)) = s { + if *m == *chaddr { + return Some(Ipv4Addr::from(self.ip_start_num + i as u32)); + } + } + } + } + for (ip, (m, _, _)) in &self.overflow { + if *m == *chaddr { + return Some(*ip); + } + } + None + } + /// Insert (or steal) a lease. Duplicate MACs for different IPs are + /// preserved (like the original); `by_mac` is an O(1) hint for the + /// common 1:1 case. Duplicate IPs last-wins. + fn insert_lease( + &mut self, + ip: Ipv4Addr, + mac: [u8; 6], + expiry: Option, + hostname: Option<&str>, + ) { + let entry = (mac, expiry, hostname.map(|s| s.to_owned())); + if let Some(i) = self.idx(&ip) { + if let Some((old_mac, _, _)) = &self.slots[i] { + if *old_mac != mac && self.by_mac.get(old_mac) == Some(&ip) { + let old = *old_mac; + self.by_mac.remove(&old); + } + } + if self.slots[i].is_none() { + self.filled += 1; + } + self.slots[i] = Some(entry); + self.by_mac.insert(mac, ip); + } else { + if let Some((old_mac, _, _)) = self.overflow.get(&ip) { + if *old_mac != mac && self.by_mac.get(old_mac) == Some(&ip) { + let old = *old_mac; + self.by_mac.remove(&old); + } + } + self.overflow.insert(ip, entry); + self.by_mac.insert(mac, ip); + } + } + fn remove_lease(&mut self, chaddr: &[u8; 6]) { + if let Some(ip) = self.current_lease(chaddr) { + if let Some(i) = self.idx(&ip) { + self.slots[i] = None; + self.filled -= 1; + } else { + self.overflow.remove(&ip); + } + if self.by_mac.get(chaddr) == Some(&ip) { + self.by_mac.remove(chaddr); + let mut repaired = false; + if self.use_slots { + for (j, s) in self.slots.iter().enumerate() { + if let Some((m, _, _)) = s { + if *m == *chaddr { + self.by_mac.insert( + *chaddr, + Ipv4Addr::from(self.ip_start_num + j as u32), + ); + repaired = true; + break; + } + } + } + } + if !repaired { + for (other_ip, (m, _, _)) in &self.overflow { + if *m == *chaddr { + self.by_mac.insert(*chaddr, *other_ip); + break; + } + } + } + } + } + } + /// Refresh the stored host name when the client announces one. A packet + /// without option 12 leaves the stored name untouched, so renewals that + /// omit it don't flap the recorded name. + fn remember_hostname(&mut self, ip: &Ipv4Addr, hostname: &Option<&str>) { + let entry = match self.idx(ip) { + Some(i) => self.slots[i].as_mut().map(|e| &mut e.2), + None => self.overflow.get_mut(ip).map(|e| &mut e.2), + }; + if let (Some(name), Some(slot)) = (hostname, entry) { + *slot = Some((*name).to_owned()); + } + } + + /// Option list for Offer/Ack replies, mirroring the documented order: + /// lease time, subnet, router, DNS, then NTP (option 42) if enabled. + /// Cloned from the startup-built cache (no per-reply rebuild). + fn offer_options(&self) -> Vec { + self.cached_offer.clone() + } + + fn reply( + &self, + s: &server::Server, + msg_type: options::MessageType, + req_packet: packet::Packet, + offer_ip: &Ipv4Addr, + ) { + let opts = self.offer_options(); + // Log delivery failures (e.g. unroutable renewing client): they are + // otherwise silent because the handshake simply stalls. + if let Err(e) = s.reply(msg_type, opts, *offer_ip, req_packet) { + eprintln!("reply {:?} to {} failed: {}", msg_type, offer_ip, e); + } + } + + fn nak(&self, s: &server::Server, req_packet: packet::Packet, message: &str) { + if let Err(e) = s.reply( + options::MessageType::Nak, + vec![options::DhcpOption::Message(message.to_string())], + Ipv4Addr::new(0, 0, 0, 0), + req_packet, + ) { + eprintln!("reply Nak failed: {}", e); + } + } +} + +// --------------------------------------------------------------------------- +// DHCPv6 managed range (RFC 8415), driven by the same dhcp.conf +// --------------------------------------------------------------------------- + +/// Run the DHCPv6 service: Solicit->Advertise, Request/Renew/Rebind->Reply, +/// Release/Decline frees. With `enable_rapid_commit`, Solicit carrying +/// option 14 commits immediately with a Reply instead. Packets without a +/// Client ID or IA_NA, and unknown message types, are ignored. Returns only +/// on socket error like `serve`. +fn serve_v6(config: &config::Config) -> std::io::Error { + let socket = UdpSocket::bind(config.listen_addr_v6).unwrap(); + + let mut pool = dhcpv6::V6Pool::new( + config.ipv6_start, + config.ipv6_lease_num, + Duration::new(config.ipv6_lease_duration_secs as u64, 0), + ); + // v6 reservations file: `duid-hex,ipv6` per line; malformed lines are + // skipped (unlike the v4 loader, a bad line never panics the daemon). + if let Ok(file) = File::open(&config.leases_file_v6) { + for line in BufReader::new(file).lines().flatten() { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() == 2 { + if let (Ok(duid), Ok(ip)) = ( + config::parse_duid_hex(parts[0].trim()), + parts[1].trim().parse::(), + ) { + if !duid.is_empty() { + pool.insert(ip, duid, None); + } + } + } + } + } else { + eprintln!("Failed to open v6 leases file. Continuing..."); + } + + let mut in_buf: [u8; 1500] = [0; 1500]; + loop { + let (len, src) = match socket.recv_from(&mut in_buf) { + Err(e) => return e, + Ok((l, src)) => (l, src), + }; + let in_packet = match dhcpv6::Packet::from(&in_buf[..len]) { + Ok(p) => p, + // Malformed datagrams never reach the handler, so log why here. + Err(_) => { + eprintln!("v6 ignoring malformed datagram ({} bytes)", len); + continue; + } + }; + // Every message below needs the client DUID; without it there is no + // identity to lease to. Borrowed (no per-packet clone; the reply + // path clones once when building ClientId). + let duid: &[u8] = match in_packet.option(dhcpv6::OPT_CLIENTID) { + Some(dhcpv6::Dhcpv6Option::ClientId(d)) => d.as_slice(), + _ => { + println!( + "v6 ? tid={:06x} ignored (no client ID)", + in_packet.transaction_id + ); + continue; + } + }; + let duid_s = if duid.is_empty() { + // Empty ClientIds decode fine but carry no identity; never panic. + "-".to_string() + } else { + chaddr(duid) + }; + let iaid = in_packet.first_iana().map(|ia| ia.iaid).unwrap_or(0); + match in_packet.msg_type { + dhcpv6::MsgType::Solicit => { + let rapid = config.enable_rapid_commit + && in_packet.option(dhcpv6::OPT_RAPID_COMMIT).is_some(); + println!( + "v6 SOLICIT duid={} tid={:06x} iaid={} rapid={}", + duid_s, in_packet.transaction_id, iaid, rapid + ); + // Rapid Commit (RFC 8415 option 14): when enabled and asked + // for, commit immediately with a Reply (2-message exchange) + // instead of advertising. Exhaustion replies NoAddrsAvail, + // like the Request path below. + if rapid { + match pool.discover(&duid) { + Some(offered) => match pool.request(&duid, offered) { + Ok(ip) => { + println!( + "v6 SOLICIT duid={} tid={:06x} -> REPLY {} (rapid commit)", + duid_s, in_packet.transaction_id, ip + ); + let _ = send_v6( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Reply, + ip, + &config.ipv6_dns, + config.ipv6_lease_duration_secs, + ); + } + Err(_) => { + println!( + "v6 SOLICIT duid={} tid={:06x} -> REPLY nodata (NoAddrsAvail, pool {}/{} used)", + duid_s, + in_packet.transaction_id, + pool.len(), + pool.count() + ); + let _ = send_v6_nodata( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Reply, + ); + } + }, + None => { + println!( + "v6 SOLICIT duid={} tid={:06x} -> REPLY nodata (NoAddrsAvail, pool {}/{} used)", + duid_s, + in_packet.transaction_id, + pool.len(), + pool.count() + ); + let _ = send_v6_nodata( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Reply, + ); + } + } + continue; + } + if let Some(ip) = pool.discover(&duid) { + println!( + "v6 SOLICIT duid={} tid={:06x} -> ADVERTISE {}", + duid_s, in_packet.transaction_id, ip + ); + let _ = send_v6( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Advertise, + ip, + &config.ipv6_dns, + config.ipv6_lease_duration_secs, + ); + } else { + println!( + "v6 SOLICIT duid={} tid={:06x} -> ADVERTISE nodata (NoAddrsAvail, pool {}/{} used)", + duid_s, + in_packet.transaction_id, + pool.len(), + pool.count() + ); + let _ = send_v6_nodata( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Advertise, + ); + } + } + dhcpv6::MsgType::Request + | dhcpv6::MsgType::Renew + | dhcpv6::MsgType::Rebind => { + let name = format!("{:?}", in_packet.msg_type); + let wanted = in_packet + .first_iana() + .and_then(|ia| ia.addrs.first().map(|a| a.addr)); + match wanted { + None => println!( + "v6 {} duid={} tid={:06x} ignored (no IA_NA)", + name.to_uppercase(), + duid_s, + in_packet.transaction_id + ), + Some(addr) => match pool.request(&duid, addr) { + Ok(ip) => { + println!( + "v6 {} duid={} tid={:06x} want={} -> REPLY {}", + name.to_uppercase(), + duid_s, + in_packet.transaction_id, + addr, + ip + ); + let _ = send_v6( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Reply, + ip, + &config.ipv6_dns, + config.ipv6_lease_duration_secs, + ); + } + Err(_) => { + println!( + "v6 {} duid={} tid={:06x} want={} -> REPLY nodata (requested address not available)", + name.to_uppercase(), + duid_s, + in_packet.transaction_id, + addr + ); + let _ = send_v6_nodata( + &socket, + src, + &config.server_duid, + &duid, + &in_packet, + dhcpv6::MsgType::Reply, + ); + } + }, + } + } + dhcpv6::MsgType::Release | dhcpv6::MsgType::Decline => { + let name = format!("{:?}", in_packet.msg_type); + if dhcpv6::is_for_server(&config.server_duid, &in_packet) { + if pool.release(&duid) { + println!( + "v6 {} duid={} tid={:06x} freed lease", + name.to_uppercase(), + duid_s, + in_packet.transaction_id + ); + } else { + println!( + "v6 {} duid={} tid={:06x} no lease held", + name.to_uppercase(), + duid_s, + in_packet.transaction_id + ); + } + } else { + println!( + "v6 {} duid={} tid={:06x} ignored (server_id mismatch)", + name.to_uppercase(), + duid_s, + in_packet.transaction_id + ); + } + } + other => { + println!( + "v6 {:?} tid={:06x} ignored (unsupported message type)", + other, in_packet.transaction_id + ); + } + } + } +} + +/// Successful Advertise/Reply for `ip`, echoing the request IAID and lease +/// lifetimes. +#[allow(clippy::too_many_arguments)] +fn send_v6( + socket: &UdpSocket, + dst: std::net::SocketAddr, + server_duid: &[u8], + client_duid: &[u8], + req: &dhcpv6::Packet, + msg_type: dhcpv6::MsgType, + ip: Ipv6Addr, + dns: &[Ipv6Addr], + lease_secs: u32, +) -> std::io::Result { + let iaid = req.first_iana().map(|ia| ia.iaid).unwrap_or(0); + let (t1, t2) = dhcpv6::default_t1_t2(lease_secs); + let mut options = vec![ + dhcpv6::Dhcpv6Option::ServerId(server_duid.to_vec()), + dhcpv6::Dhcpv6Option::ClientId(client_duid.to_vec()), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid, + t1, + t2, + addrs: vec![dhcpv6::IaAddr { + addr: ip, + preferred: lease_secs, + valid: lease_secs, + }], + }), + ]; + if !dns.is_empty() { + options.push(dhcpv6::Dhcpv6Option::DnsServers(dns.to_vec())); + } + let out = dhcpv6::Packet { + msg_type, + transaction_id: req.transaction_id, + options, + } + .encode() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}", e)))?; + socket.send_to(&out, dst) +} + +/// Failure Advertise/Reply: no addresses, top-level NoAddrsAvail status. +/// (Minimal simplification: RFC 8415 places the status inside the IA_NA.) +fn send_v6_nodata( + socket: &UdpSocket, + dst: std::net::SocketAddr, + server_duid: &[u8], + client_duid: &[u8], + req: &dhcpv6::Packet, + msg_type: dhcpv6::MsgType, +) -> std::io::Result { + let iaid = req.first_iana().map(|ia| ia.iaid).unwrap_or(0); + let out = dhcpv6::Packet { + msg_type, + transaction_id: req.transaction_id, + options: vec![ + dhcpv6::Dhcpv6Option::ServerId(server_duid.to_vec()), + dhcpv6::Dhcpv6Option::ClientId(client_duid.to_vec()), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid, + t1: 0, + t2: 0, + addrs: vec![], + }), + dhcpv6::Dhcpv6Option::StatusCode( + dhcpv6::STATUS_NO_ADDRS_AVAIL, + "NoAddrsAvail".to_string(), + ), + ], + } + .encode() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}", e)))?; + socket.send_to(&out, dst) +} diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..fd0a602 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,444 @@ +///use num_traits::FromPrimitive; +use std::net::Ipv4Addr; + +#[derive(PartialEq, Clone, Debug)] +pub struct RawDhcpOption { + pub code: u8, + pub data: Vec, +} + +#[derive(PartialEq, Clone, Debug)] +pub enum DhcpOption { + DhcpMessageType(MessageType), + ServerIdentifier(Ipv4Addr), + ParameterRequestList(Vec), + RequestedIpAddress(Ipv4Addr), + HostName(String), + Router(Vec), + DomainNameServer(Vec), + IpAddressLeaseTime(u32), + SubnetMask(Ipv4Addr), + Message(String), + Unrecognized(RawDhcpOption), +} + +impl DhcpOption { + /// Wire payload length without allocating (mirrors `to_raw` arm-for-arm). + #[inline] + pub fn data_len(&self) -> usize { + match self { + Self::DhcpMessageType(_) => 1, + Self::ServerIdentifier(_) + | Self::RequestedIpAddress(_) + | Self::SubnetMask(_) => 4, + Self::ParameterRequestList(p) => p.len(), + Self::HostName(s) | Self::Message(s) => s.len(), + Self::Router(a) | Self::DomainNameServer(a) => a.len() * 4, + Self::IpAddressLeaseTime(_) => 4, + Self::Unrecognized(r) => r.data.len(), + } + } + + /// Write payload bytes into `out` (must be `>= data_len()`); returns bytes + /// written. Allocation-free counterpart of `to_raw`. + #[inline] + pub fn write_data(&self, out: &mut [u8]) -> usize { + match self { + Self::DhcpMessageType(m) => { + out[0] = *m as u8; + 1 + } + Self::ServerIdentifier(a) | Self::RequestedIpAddress(a) | Self::SubnetMask(a) => { + out[..4].copy_from_slice(&a.octets()); + 4 + } + Self::ParameterRequestList(p) => { + out[..p.len()].copy_from_slice(p); + p.len() + } + Self::HostName(s) | Self::Message(s) => { + out[..s.len()].copy_from_slice(s.as_bytes()); + s.len() + } + Self::Router(addrs) | Self::DomainNameServer(addrs) => { + let mut n = 0; + for a in addrs { + out[n..n + 4].copy_from_slice(&a.octets()); + n += 4; + } + n + } + Self::IpAddressLeaseTime(s) => { + out[..4].copy_from_slice(&s.to_be_bytes()); + 4 + } + Self::Unrecognized(r) => { + out[..r.data.len()].copy_from_slice(&r.data); + r.data.len() + } + } + } + + /// Cold path (tests/bench only): the hot encode path uses + /// [`DhcpOption::data_len`] + [`DhcpOption::write_data`] instead. + /// Kept out-of-line to protect the hot loop's I-cache. + #[inline(never)] + pub fn to_raw(&self) -> RawDhcpOption { + match self { + Self::DhcpMessageType(mtype) => RawDhcpOption { + code: DHCP_MESSAGE_TYPE, + data: vec![*mtype as u8], + }, + Self::ServerIdentifier(addr) => RawDhcpOption { + code: SERVER_IDENTIFIER, + data: addr.octets().to_vec(), + }, + Self::ParameterRequestList(prl) => RawDhcpOption { + code: PARAMETER_REQUEST_LIST, + data: prl.clone(), + }, + Self::RequestedIpAddress(addr) => RawDhcpOption { + code: REQUESTED_IP_ADDRESS, + data: addr.octets().to_vec(), + }, + Self::HostName(name) => RawDhcpOption { + code: HOST_NAME, + data: name.as_bytes().to_vec(), + }, + Self::Router(addrs) => RawDhcpOption { + code: ROUTER, + data: { + let mut v = Vec::with_capacity(addrs.len() * 4); + for a in addrs { + v.extend_from_slice(&a.octets()); + } + v + }, + }, + Self::DomainNameServer(addrs) => RawDhcpOption { + code: DOMAIN_NAME_SERVER, + data: { + let mut v = Vec::with_capacity(addrs.len() * 4); + for a in addrs { + v.extend_from_slice(&a.octets()); + } + v + }, + }, + Self::IpAddressLeaseTime(secs) => RawDhcpOption { + code: IP_ADDRESS_LEASE_TIME, + data: secs.to_be_bytes().to_vec(), + }, + Self::SubnetMask(mask) => RawDhcpOption { + code: SUBNET_MASK, + data: mask.octets().to_vec(), + }, + Self::Message(msg) => RawDhcpOption { + code: MESSAGE, + data: msg.as_bytes().to_vec(), + }, + Self::Unrecognized(raw) => raw.clone(), + } + } + + #[inline] + pub fn code(&self) -> u8 { + match self { + Self::DhcpMessageType(_) => DHCP_MESSAGE_TYPE, + Self::ServerIdentifier(_) => SERVER_IDENTIFIER, + Self::ParameterRequestList(_) => PARAMETER_REQUEST_LIST, + Self::RequestedIpAddress(_) => REQUESTED_IP_ADDRESS, + Self::HostName(_) => HOST_NAME, + Self::Router(_) => ROUTER, + Self::DomainNameServer(_) => DOMAIN_NAME_SERVER, + Self::IpAddressLeaseTime(_) => IP_ADDRESS_LEASE_TIME, + Self::SubnetMask(_) => SUBNET_MASK, + Self::Message(_) => MESSAGE, + Self::Unrecognized(x) => x.code, + } + } +} + +// DHCP Options; +pub const SUBNET_MASK: u8 = 1; +pub const TIME_OFFSET: u8 = 2; +pub const ROUTER: u8 = 3; +pub const TIME_SERVER: u8 = 4; +pub const NAME_SERVER: u8 = 5; +pub const DOMAIN_NAME_SERVER: u8 = 6; +pub const LOG_SERVER: u8 = 7; +pub const COOKIE_SERVER: u8 = 8; +pub const LPR_SERVER: u8 = 9; +pub const IMPRESS_SERVER: u8 = 10; +pub const RESOURCE_LOCATION_SERVER: u8 = 11; +pub const HOST_NAME: u8 = 12; +pub const BOOT_FILE_SIZE: u8 = 13; +pub const MERIT_DUMP_FILE: u8 = 14; +pub const DOMAIN_NAME: u8 = 15; +pub const SWAP_SERVER: u8 = 16; +pub const ROOT_PATH: u8 = 17; +pub const EXTENSIONS_PATH: u8 = 18; + +// IP LAYER PARAMETERS PER HOST; +pub const IP_FORWARDING_ENABLE_DISABLE: u8 = 19; +pub const NON_LOCAL_SOURCE_ROUTING_ENABLE_DISABLE: u8 = 20; +pub const POLICY_FILTER: u8 = 21; +pub const MAXIMUM_DATAGRAM_REASSEMBLY_SIZE: u8 = 22; +pub const DEFAULT_IP_TIME_TO_LIVE: u8 = 23; +pub const PATH_MTU_AGING_TIMEOUT: u8 = 24; +pub const PATH_MTU_PLATEAU_TABLE: u8 = 25; + +// IP LAYER PARAMETERS PER INTERFACE; +pub const INTERFACE_MTU: u8 = 26; +pub const ALL_SUBNETS_ARE_LOCAL: u8 = 27; +pub const BROADCAST_ADDRESS: u8 = 28; +pub const PERFORM_MASK_DISCOVERY: u8 = 29; +pub const MASK_SUPPLIER: u8 = 30; +pub const PERFORM_ROUTER_DISCOVERY: u8 = 31; +pub const ROUTER_SOLICITATION_ADDRESS: u8 = 32; +pub const STATIC_ROUTE: u8 = 33; + +// LINK LAYER PARAMETERS PER INTERFACE; +pub const TRAILER_ENCAPSULATION: u8 = 34; +pub const ARP_CACHE_TIMEOUT: u8 = 35; +pub const ETHERNET_ENCAPSULATION: u8 = 36; + +// TCP PARAMETERS; +pub const TCP_DEFAULT_TTL: u8 = 37; +pub const TCP_KEEPALIVE_INTERVAL: u8 = 38; +pub const TCP_KEEPALIVE_GARBAGE: u8 = 39; + +// APPLICATION AND SERVICE PARAMETERS; +pub const NETWORK_INFORMATION_SERVICE_DOMAIN: u8 = 40; +pub const NETWORK_INFORMATION_SERVERS: u8 = 41; +pub const NETWORK_TIME_PROTOCOL_SERVERS: u8 = 42; +pub const VENDOR_SPECIFIC_INFORMATION: u8 = 43; +pub const NETBIOS_OVER_TCPIP_NAME_SERVER: u8 = 44; +pub const NETBIOS_OVER_TCPIP_DATAGRAM_DISTRIBUTION_SERVER: u8 = 45; +pub const NETBIOS_OVER_TCPIP_NODE_TYPE: u8 = 46; +pub const NETBIOS_OVER_TCPIP_SCOPE: u8 = 47; +pub const XWINDOW_SYSTEM_FONT_SERVER: u8 = 48; +pub const XWINDOW_SYSTEM_DISPLAY_MANAGER: u8 = 49; +pub const NETWORK_INFORMATION_SERVICEPLUS_DOMAIN: u8 = 64; +pub const NETWORK_INFORMATION_SERVICEPLUS_SERVERS: u8 = 65; +pub const MOBILE_IP_HOME_AGENT: u8 = 68; +pub const SIMPLE_MAIL_TRANSPORT_PROTOCOL: u8 = 69; +pub const POST_OFFICE_PROTOCOL_SERVER: u8 = 70; +pub const NETWORK_NEWS_TRANSPORT_PROTOCOL: u8 = 71; +pub const DEFAULT_WORLD_WIDE_WEB_SERVER: u8 = 72; +pub const DEFAULT_FINGER_SERVER: u8 = 73; +pub const DEFAULT_INTERNET_RELAY_CHAT_SERVER: u8 = 74; +pub const STREETTALK_SERVER: u8 = 75; +pub const STREETTALK_DIRECTORY_ASSISTANCE: u8 = 76; + +pub const RELAY_AGENT_INFORMATION: u8 = 82; + +// DHCP EXTENSIONS +pub const REQUESTED_IP_ADDRESS: u8 = 50; +pub const IP_ADDRESS_LEASE_TIME: u8 = 51; +pub const OVERLOAD: u8 = 52; +pub const DHCP_MESSAGE_TYPE: u8 = 53; +pub const SERVER_IDENTIFIER: u8 = 54; +pub const PARAMETER_REQUEST_LIST: u8 = 55; +pub const MESSAGE: u8 = 56; +pub const MAXIMUM_DHCP_MESSAGE_SIZE: u8 = 57; +pub const RENEWAL_TIME_VALUE: u8 = 58; +pub const REBINDING_TIME_VALUE: u8 = 59; +pub const VENDOR_CLASS_IDENTIFIER: u8 = 60; +pub const CLIENT_IDENTIFIER: u8 = 61; + +pub const TFTP_SERVER_NAME: u8 = 66; +pub const BOOTFILE_NAME: u8 = 67; + +pub const USER_CLASS: u8 = 77; + +/// Rapid Commit (RFC 4039): empty option by which a client asks the server +/// to skip Offer/Advertise and answer Discover/Solicit immediately. There is +/// deliberately no typed `DhcpOption` variant for it (like NTP option 42): +/// it decodes as `Unrecognized` and handlers detect it by code presence. +pub const RAPID_COMMIT: u8 = 80; + +pub const CLIENT_ARCHITECTURE: u8 = 93; + +pub const TZ_POSIX_STRING: u8 = 100; +pub const TZ_DATABASE_STRING: u8 = 101; + +pub const CLASSLESS_ROUTE_FORMAT: u8 = 121; + +/// Returns title of DHCP Option code, if known. +pub fn title(code: u8) -> Option<&'static str> { + Some(match code { + SUBNET_MASK => "Subnet Mask", + + TIME_OFFSET => "Time Offset", + ROUTER => "Router", + TIME_SERVER => "Time Server", + NAME_SERVER => "Name Server", + DOMAIN_NAME_SERVER => "Domain Name Server", + LOG_SERVER => "Log Server", + COOKIE_SERVER => "Cookie Server", + LPR_SERVER => "LPR Server", + IMPRESS_SERVER => "Impress Server", + RESOURCE_LOCATION_SERVER => "Resource Location Server", + HOST_NAME => "Host Name", + BOOT_FILE_SIZE => "Boot File Size", + MERIT_DUMP_FILE => "Merit Dump File", + DOMAIN_NAME => "Domain Name", + SWAP_SERVER => "Swap Server", + ROOT_PATH => "Root Path", + EXTENSIONS_PATH => "Extensions Path", + + // IP LAYER PARAMETERS PER HOST", + IP_FORWARDING_ENABLE_DISABLE => "IP Forwarding Enable/Disable", + NON_LOCAL_SOURCE_ROUTING_ENABLE_DISABLE => "Non-Local Source Routing Enable/Disable", + POLICY_FILTER => "Policy Filter", + MAXIMUM_DATAGRAM_REASSEMBLY_SIZE => "Maximum Datagram Reassembly Size", + DEFAULT_IP_TIME_TO_LIVE => "Default IP Time-to-live", + PATH_MTU_AGING_TIMEOUT => "Path MTU Aging Timeout", + PATH_MTU_PLATEAU_TABLE => "Path MTU Plateau Table", + + // IP LAYER PARAMETERS PER INTERFACE", + INTERFACE_MTU => "Interface MTU", + ALL_SUBNETS_ARE_LOCAL => "All Subnets are Local", + BROADCAST_ADDRESS => "Broadcast Address", + PERFORM_MASK_DISCOVERY => "Perform Mask Discovery", + MASK_SUPPLIER => "Mask Supplier", + PERFORM_ROUTER_DISCOVERY => "Perform Router Discovery", + ROUTER_SOLICITATION_ADDRESS => "Router Solicitation Address", + STATIC_ROUTE => "Static Route", + + // LINK LAYER PARAMETERS PER INTERFACE", + TRAILER_ENCAPSULATION => "Trailer Encapsulation", + ARP_CACHE_TIMEOUT => "ARP Cache Timeout", + ETHERNET_ENCAPSULATION => "Ethernet Encapsulation", + + // TCP PARAMETERS", + TCP_DEFAULT_TTL => "TCP Default TTL", + TCP_KEEPALIVE_INTERVAL => "TCP Keepalive Interval", + TCP_KEEPALIVE_GARBAGE => "TCP Keepalive Garbage", + + // APPLICATION AND SERVICE PARAMETERS", + NETWORK_INFORMATION_SERVICE_DOMAIN => "Network Information Service Domain", + NETWORK_INFORMATION_SERVERS => "Network Information Servers", + NETWORK_TIME_PROTOCOL_SERVERS => "Network Time Protocol Servers", + VENDOR_SPECIFIC_INFORMATION => "Vendor Specific Information", + NETBIOS_OVER_TCPIP_NAME_SERVER => "NetBIOS over TCP/IP Name Server", + NETBIOS_OVER_TCPIP_DATAGRAM_DISTRIBUTION_SERVER => { + "NetBIOS over TCP/IP Datagram Distribution Server" + } + NETBIOS_OVER_TCPIP_NODE_TYPE => "NetBIOS over TCP/IP Node Type", + NETBIOS_OVER_TCPIP_SCOPE => "NetBIOS over TCP/IP Scope", + XWINDOW_SYSTEM_FONT_SERVER => "X Window System Font Server", + XWINDOW_SYSTEM_DISPLAY_MANAGER => "X Window System Display Manager", + NETWORK_INFORMATION_SERVICEPLUS_DOMAIN => "Network Information Service+ Domain", + NETWORK_INFORMATION_SERVICEPLUS_SERVERS => "Network Information Service+ Servers", + MOBILE_IP_HOME_AGENT => "Mobile IP Home Agent", + SIMPLE_MAIL_TRANSPORT_PROTOCOL => "Simple Mail Transport Protocol (SMTP) Server", + POST_OFFICE_PROTOCOL_SERVER => "Post Office Protocol (POP3) Server", + NETWORK_NEWS_TRANSPORT_PROTOCOL => "Network News Transport Protocol (NNTP) Server", + DEFAULT_WORLD_WIDE_WEB_SERVER => "Default World Wide Web (WWW) Server", + DEFAULT_FINGER_SERVER => "Default Finger Server", + DEFAULT_INTERNET_RELAY_CHAT_SERVER => "Default Internet Relay Chat (IRC) Server", + STREETTALK_SERVER => "StreetTalk Server", + STREETTALK_DIRECTORY_ASSISTANCE => "StreetTalk Directory Assistance (STDA) Server", + + RELAY_AGENT_INFORMATION => "Relay Agent Information", + + // DHCP EXTENSIONS + REQUESTED_IP_ADDRESS => "Requested IP Address", + IP_ADDRESS_LEASE_TIME => "IP Address Lease Time", + OVERLOAD => "Overload", + DHCP_MESSAGE_TYPE => "DHCP Message Type", + SERVER_IDENTIFIER => "Server Identifier", + PARAMETER_REQUEST_LIST => "Parameter Request List", + MESSAGE => "Message", + MAXIMUM_DHCP_MESSAGE_SIZE => "Maximum DHCP Message Size", + RENEWAL_TIME_VALUE => "Renewal (T1) Time Value", + REBINDING_TIME_VALUE => "Rebinding (T2) Time Value", + VENDOR_CLASS_IDENTIFIER => "Vendor class identifier", + CLIENT_IDENTIFIER => "Client-identifier", + + // Find below + TFTP_SERVER_NAME => "TFTP server name", + BOOTFILE_NAME => "Bootfile name", + + USER_CLASS => "User Class", + + RAPID_COMMIT => "Rapid Commit", + + CLIENT_ARCHITECTURE => "Client Architecture", + + TZ_POSIX_STRING => "TZ-POSIX String", + TZ_DATABASE_STRING => "TZ-Database String", + CLASSLESS_ROUTE_FORMAT => "Classless Route Format", + + _ => return None, + }) +} + +/// +/// DHCP Message Type. +/// +/// # Standards +/// +/// The semantics of the various DHCP message types are described in RFC 2131 (see Table 2). +/// Their numeric values are described in Section 9.6 of RFC 2132, which begins: +/// +/// > This option is used to convey the type of the DHCP message. The code for this option is 53, +/// > and its length is 1. +/// +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum MessageType { + /// Client broadcast to locate available servers. + Discover = 1, + + /// Server to client in response to DHCPDISCOVER with offer of configuration parameters. + Offer = 2, + + /// Client message to servers either (a) requesting offered parameters from one server and + /// implicitly declining offers from all others, (b) confirming correctness of previously + /// allocated address after, e.g., system reboot, or (c) extending the lease on a particular + /// network address. + Request = 3, + + /// Client to server indicating network address is already in use. + Decline = 4, + + /// Server to client with configuration parameters, including committed network address. + Ack = 5, + + /// Server to client indicating client's notion of network address is incorrect (e.g., client + /// has moved to new subnet) or client's lease as expired. + Nak = 6, + + /// Client to server relinquishing network address and cancelling remaining lease. + Release = 7, + + /// Client to server, asking only for local configuration parameters; client already has + /// externally configured network address. + Inform = 8, +} + +impl MessageType { + /// Allocation-free check for hot paths; `from` stays as the + /// String-returning wrapper for API compatibility. + #[inline] + pub fn from_option(val: u8) -> Option { + match val { + 1 => Some(MessageType::Discover), + 2 => Some(MessageType::Offer), + 3 => Some(MessageType::Request), + 4 => Some(MessageType::Decline), + 5 => Some(MessageType::Ack), + 6 => Some(MessageType::Nak), + 7 => Some(MessageType::Release), + 8 => Some(MessageType::Inform), + _ => None, + } + } + + #[inline(never)] + pub fn from(val: u8) -> Result { + Self::from_option(val).ok_or_else(|| format!("Invalid DHCP Message Type: {:?}", val)) + } +} diff --git a/src/packet.rs b/src/packet.rs new file mode 100644 index 0000000..9948dc6 --- /dev/null +++ b/src/packet.rs @@ -0,0 +1,270 @@ +use crate::options::*; + +use std::net::Ipv4Addr; + +pub enum CustomErr { + NomError((I, ErrorKind)), + NonUtf8String, + UnrecognizedMessageType, + InvalidHlen, +} + +pub enum ErrorKind { + Tag, + MapRes, + ManyTill, + Eof, + Custom(u32), +} + +type IResult = Result<(I, O), CustomErr>; + +/// DHCP Packet Structure +#[derive(Debug)] +pub struct Packet { + pub reply: bool, // false = request, true = reply + pub hops: u8, + pub xid: u32, // Random identifier + pub secs: u16, + pub broadcast: bool, + pub ciaddr: Ipv4Addr, + pub yiaddr: Ipv4Addr, + pub siaddr: Ipv4Addr, + pub giaddr: Ipv4Addr, + pub chaddr: [u8; 6], + pub options: Vec, +} + +#[inline(always)] +fn ipv4_of(data: &[u8]) -> Result> { + if data.len() < 4 { + return Err(CustomErr::InvalidHlen); + } + Ok(Ipv4Addr::new(data[0], data[1], data[2], data[3])) +} + +pub fn decode_option(input: &[u8]) -> IResult<&[u8], DhcpOption> { + if input.is_empty() { + return Err(CustomErr::InvalidHlen); + } + let code = input[0]; + assert!(code != END); + let len = *input.get(1).ok_or(CustomErr::InvalidHlen)? as usize; + if input.len() - 2 < len { + return Err(CustomErr::InvalidHlen); + } + let data = &input[2..2 + len]; + let input = &input[2 + len..]; + let option = match code { + // Frequency-ordered: 53 on every packet, 55 on ~90%, then 54/50. + DHCP_MESSAGE_TYPE => { + // Preserve quirk: empty data -> InvalidHlen, bad value -> + // UnrecognizedMessageType, trailing bytes ignored. + if data.is_empty() { + return Err(CustomErr::InvalidHlen); + } + match MessageType::from_option(data[0]) { + Some(x) => DhcpOption::DhcpMessageType(x), + None => return Err(CustomErr::UnrecognizedMessageType), + } + } + PARAMETER_REQUEST_LIST => DhcpOption::ParameterRequestList(data.to_vec()), + SERVER_IDENTIFIER => DhcpOption::ServerIdentifier(ipv4_of(data)?), + REQUESTED_IP_ADDRESS => DhcpOption::RequestedIpAddress(ipv4_of(data)?), + HOST_NAME => DhcpOption::HostName(match std::str::from_utf8(data) { + Ok(s) => s.to_string(), + Err(_) => return Err(CustomErr::NonUtf8String), + }), + // Locked quirk: Router/DNS ALWAYS fail with InvalidHlen because the + // old custom_many0(decode_ipv4) expected NomError termination but + // decode_ipv4 yields InvalidHlen. Preserve without the overhead. + ROUTER | DOMAIN_NAME_SERVER => return Err(CustomErr::InvalidHlen), + IP_ADDRESS_LEASE_TIME => { + if data.len() < 4 { + return Err(CustomErr::InvalidHlen); + } + DhcpOption::IpAddressLeaseTime(u32::from_be_bytes([ + data[0], data[1], data[2], data[3], + ])) + } + SUBNET_MASK => DhcpOption::SubnetMask(ipv4_of(data)?), + MESSAGE => DhcpOption::Message(match std::str::from_utf8(data) { + Ok(s) => s.to_string(), + Err(_) => return Err(CustomErr::NonUtf8String), + }), + _ => DhcpOption::Unrecognized(RawDhcpOption { + code, + data: data.to_vec(), + }), + }; + Ok((input, option)) +} + +/// Parses Packet from byte array +fn decode(input: &[u8]) -> IResult<&[u8], Packet> { + if input.len() < 236 { + return Err(CustomErr::InvalidHlen); + } + let (hdr, mut rest) = input.split_at(236); + // Header fields at fixed offsets (mirrors the closure version exactly). + let reply = match hdr[0] { + BOOT_REPLY => true, + BOOT_REQUEST => false, + _ => false, + }; + let hlen = hdr[2]; + if hlen != 6 { + return Err(CustomErr::InvalidHlen); + } + let hops = hdr[3]; + let xid = u32::from_be_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]); + let secs = u16::from_be_bytes([hdr[8], hdr[9]]); + let flags = u16::from_be_bytes([hdr[10], hdr[11]]); + let ciaddr = Ipv4Addr::new(hdr[12], hdr[13], hdr[14], hdr[15]); + let yiaddr = Ipv4Addr::new(hdr[16], hdr[17], hdr[18], hdr[19]); + let siaddr = Ipv4Addr::new(hdr[20], hdr[21], hdr[22], hdr[23]); + let giaddr = Ipv4Addr::new(hdr[24], hdr[25], hdr[26], hdr[27]); + + let chaddr = [hdr[28], hdr[29], hdr[30], hdr[31], hdr[32], hdr[33]]; + + // Cookie check preserves NomError(Tag) kind. + if rest.len() < 4 || rest[0..4] != COOKIE { + return Err(CustomErr::NomError((rest, ErrorKind::Tag))); + } + rest = &rest[4..]; + + // Live traffic carries 3-6 options (Discover 3, Request 6); 7-8-opt + // packets pay one cheap 6->12 growth instead of every decode wasting + // two slots (~64B) of malloc+memset. + let mut options = Vec::with_capacity(6); + while let Ok((new_rest, option)) = decode_option(rest) { + rest = new_rest; + options.push(option); + if rest.first().copied() == Some(END) { + break; + } + } + + let input = rest.split_at(1).1; // Skip the END tag byte + + Ok(( + input, + Packet { + reply, + hops, + secs, + broadcast: flags & 128 == 128, + ciaddr, + yiaddr, + siaddr, + giaddr, + options, + chaddr, + xid, + }, + )) +} + +impl Packet { + pub fn from(input: &[u8]) -> Result> { + Ok(decode(input)?.1) + } + + /// Extracts requested option payload from packet if available + #[inline] + pub fn option(&self, code: u8) -> Option<&DhcpOption> { + for o in &self.options { + if o.code() == code { + return Some(o); + } + } + None + } + + /// Convenience function for extracting a packet's message type. + /// Single pass (no second scan via option()), preserving the + /// Unrecognized(53) wrong-enum quirk. + #[inline] + pub fn message_type(&self) -> Result { + for o in &self.options { + match o { + DhcpOption::DhcpMessageType(m) => return Ok(*m), + DhcpOption::Unrecognized(r) if r.code == DHCP_MESSAGE_TYPE => { + return Err(format![ + "Got wrong enum code {} for DHCP_MESSAGE_TYPE", + r.code + ]) + } + _ => continue, + } + } + Err("Packet does not have MessageType option".to_string()) + } + pub fn encode<'a>(&'a self, p: &'a mut [u8]) -> &[u8] { + let broadcast_flag = if self.broadcast { 128 } else { 0 }; + let mut length = 240; + + p[..12].copy_from_slice(&[ + if self.reply { BOOT_REPLY } else { BOOT_REQUEST }, + 1, + 6, + self.hops, + ((self.xid >> 24) & 0xFF) as u8, + ((self.xid >> 16) & 0xFF) as u8, + ((self.xid >> 8) & 0xFF) as u8, + (self.xid & 0xFF) as u8, + (self.secs >> 8) as u8, + (self.secs & 255) as u8, + broadcast_flag, + 0, + ]); + + p[12..16].copy_from_slice(&self.ciaddr.octets()); + p[16..20].copy_from_slice(&self.yiaddr.octets()); + p[20..24].copy_from_slice(&self.siaddr.octets()); + p[24..28].copy_from_slice(&self.giaddr.octets()); + p[28..34].copy_from_slice(&self.chaddr); + p[34..236].fill(0); + p[236..240].copy_from_slice(&COOKIE); + + for option in &self.options { + let option_len = option.data_len(); + if length + 2 + option_len >= WIRE_MAX { + break; + } + if let Some(dest) = p.get_mut(length..length + 2 + option_len) { + dest[0] = option.code(); + dest[1] = option_len as u8; + option.write_data(&mut dest[2..]); + } + length += 2 + option_len; + } + + if let Some(end_segment) = p.get_mut(length..length + 1) { + end_segment[0] = END; + } + length += 1; + + if let Some(pad_segment) = p.get_mut(length..WIRE_MAX) { + pad_segment.fill(PAD); + } + + &p[..length] + } +} + +/// Maximum encoded packet size: the BOOTP minimum (RFC 951). The fixed +/// header + cookie occupy 240 bytes, leaving 59 bytes for options + END — +/// enough for a complete standard Offer (message type, server id, lease, +/// mask, router, DNS, NTP ≈ 43 bytes). Smaller ceilings amputate mandatory +/// options (e.g. Server Identifier) off real replies. +/// Callers must supply a buffer of at least this size. +pub const WIRE_MAX: usize = 300; + +const COOKIE: [u8; 4] = [99, 130, 83, 99]; + +const BOOT_REQUEST: u8 = 1; // From Client; +const BOOT_REPLY: u8 = 2; // From Server; + +const END: u8 = 255; +const PAD: u8 = 0; diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..563f6e6 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,305 @@ +//! This is a convenience module that simplifies the writing of a DHCP server service. + +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; + +use crate::options; +use crate::options::{DhcpOption, MessageType}; +use crate::packet::*; + +pub struct Server { + socket: UdpSocket, + src: SocketAddr, + server_ip: Ipv4Addr, + /// Broadcast destinations, primary first. Unicast replies always go + /// once to the peer; broadcast-routed replies go to each address. + broadcasts: Vec, +} + +pub trait Handler { + fn handle_request(&mut self, server: &Server, in_packet: Packet); +} + +/// Short reason for a rejected datagram (diagnostics; no allocation). +fn describe_decode_error(e: &CustomErr<&[u8]>) -> &'static str { + match e { + CustomErr::NomError(_) => "bad magic cookie", + CustomErr::NonUtf8String => "non-UTF8 string option", + CustomErr::UnrecognizedMessageType => "unrecognized message type", + CustomErr::InvalidHlen => "truncated header or option", + } +} + +pub fn filter_options_by_req(opts: &mut Vec, req_params: &[u8]) { + let n = opts.len(); + if n == 0 { + return; + } + // Fast path: cache option codes once so the O(n*m) scan compares u8s + // instead of re-running the `code()` match per probe. Swap sequence and + // truncation are bit-identical to the original (including duplicate-code + // quirks) because `codes` is kept in lockstep with `opts`. + if n > 64 { + return filter_options_by_req_slow(opts, req_params); + } + let mut codes = [0u8; 64]; + for (i, o) in opts.iter().enumerate() { + codes[i] = o.code(); + } + let mut pos = 0usize; + for r in req_params.iter() { + let mut found = None; + for i in pos..n { + if codes[i] == *r { + found = Some(i); + break; + } + } + if let Some(i) = found { + if i != pos { + opts.swap(i, pos); + codes.swap(i, pos); + } + pos += 1; + } + } + const H: [u8; 6] = [ + options::DHCP_MESSAGE_TYPE, + options::SERVER_IDENTIFIER, + options::SUBNET_MASK, + options::IP_ADDRESS_LEASE_TIME, + options::DOMAIN_NAME_SERVER, + options::ROUTER, + ]; + for r in H.iter() { + let mut found = None; + for i in pos..n { + if codes[i] == *r { + found = Some(i); + break; + } + } + if let Some(i) = found { + if i != pos { + opts.swap(i, pos); + codes.swap(i, pos); + } + pos += 1; + } + } + opts.truncate(pos); +} + +fn filter_options_by_req_slow(opts: &mut Vec, req_params: &[u8]) { + let mut pos = 0; + let h = &[ + options::DHCP_MESSAGE_TYPE, + options::SERVER_IDENTIFIER, + options::SUBNET_MASK, + options::IP_ADDRESS_LEASE_TIME, + options::DOMAIN_NAME_SERVER, + options::ROUTER, + ] as &[u8]; + + // Process options from req_params + for r in req_params.iter() { + let mut found = false; + for (i, o) in opts[pos..].iter().enumerate() { + if o.code() == *r { + found = true; + if pos + i != pos { + opts.swap(pos + i, pos); + } + pos += 1; + break; + } + } + if !found { + // Option not found, continue searching + } + } + + // Process options from h + for r in h.iter() { + let mut found = false; + for (i, o) in opts[pos..].iter().enumerate() { + if o.code() == *r { + found = true; + if pos + i != pos { + opts.swap(pos + i, pos); + } + pos += 1; + break; + } + } + if !found { + // Option not found, continue searching + } + } + + // Truncate the options list if necessary + opts.truncate(pos); +} + +impl Server { + pub fn serve( + udp_soc: UdpSocket, + server_ip: Ipv4Addr, + broadcast_ip: Ipv4Addr, + handler: H, + ) -> std::io::Error { + Self::serve_with_broadcasts(udp_soc, server_ip, vec![broadcast_ip], handler) + } + + /// Serve with several broadcast destinations: broadcast-routed replies + /// are sent to each address (unicast replies still go once to the peer). + /// An empty list falls back to the limited broadcast address. + pub fn serve_with_broadcasts( + udp_soc: UdpSocket, + server_ip: Ipv4Addr, + broadcast_ips: Vec, + mut handler: H, + ) -> std::io::Error { + let broadcasts = if broadcast_ips.is_empty() { + vec![Ipv4Addr::BROADCAST] + } else { + broadcast_ips + }; + let mut in_buf: [u8; 1500] = [0; 1500]; + let mut s = Server { + socket: udp_soc, + server_ip, + broadcasts, + src: SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0), + }; + loop { + match s.socket.recv_from(&mut in_buf) { + Err(e) => return e, + Ok((l, src)) => match Packet::from(&in_buf[..l]) { + Ok(p) => { + s.src = src; + + handler.handle_request(&s, p); + } + Err(e) => { + // Diagnose silent handshake failures: undecodable + // datagrams never reach the handler, so log why here. + eprintln!( + "ignoring malformed DHCP packet ({} bytes): {}", + l, + describe_decode_error(&e) + ); + } + }, + } + } + } + + /// Constructs and sends a reply packet back to the client. + /// additional_options should not include DHCP_MESSAGE_TYPE nor SERVER_IDENTIFIER as these + /// are added automatically. + pub fn reply( + &self, + msg_type: MessageType, + additional_options: Vec, + offer_ip: Ipv4Addr, + req_packet: Packet, + ) -> std::io::Result { + let ciaddr = match msg_type { + MessageType::Nak => Ipv4Addr::new(0, 0, 0, 0), + _ => req_packet.ciaddr, + }; + + let mut opts: Vec = Vec::with_capacity(additional_options.len() + 2); + opts.push(DhcpOption::DhcpMessageType(msg_type)); + opts.push(DhcpOption::ServerIdentifier(self.server_ip)); + opts.extend(additional_options); + + if let Some(DhcpOption::ParameterRequestList(prl)) = + req_packet.option(options::PARAMETER_REQUEST_LIST) + { + filter_options_by_req(&mut opts, prl); + } + + self.send(Packet { + reply: true, + hops: 0, + xid: req_packet.xid, + secs: 0, + broadcast: req_packet.broadcast, + ciaddr, + yiaddr: offer_ip, + siaddr: Ipv4Addr::new(0, 0, 0, 0), + giaddr: req_packet.giaddr, + chaddr: req_packet.chaddr, + options: opts, + }) + } + + /// Checks the packet see if it was intended for this DHCP server (as opposed to some other also on the network). + pub fn for_this_server(&self, packet: &Packet) -> bool { + match packet.option(options::SERVER_IDENTIFIER) { + Some(DhcpOption::ServerIdentifier(x)) => x.to_bits() == self.server_ip.to_bits(), + _ => false, + } + } + + /// Peer address of the last-received datagram (diagnostics: combined + /// with the packet's broadcast flag this determines reply routing). + pub fn peer(&self) -> SocketAddr { + self.src + } + + /// Reply delivery plan (pure, unit-testable). + /// + /// Unicast back to the peer when it has a usable source address. + /// Otherwise broadcast — plus, with the broadcast flag clear and a + /// committed/offered address present, a unicast copy to `yiaddr` + /// (RFC 2131 §4.1: a clear flag advertises unicast capability). + /// That copy is what sleeping broadcast-deaf clients accept, and what + /// dnsmasq-style servers send; broadcast copies still go out alongside. + pub fn route_dests( + peer: SocketAddr, + broadcast: bool, + yiaddr: Ipv4Addr, + broadcasts: &[Ipv4Addr], + ) -> Vec { + if broadcast || peer.ip().is_unspecified() { + let mut out = Vec::with_capacity(broadcasts.len() + 1); + for b in broadcasts { + let mut dst = peer; + dst.set_ip(std::net::IpAddr::V4(*b)); + out.push(dst); + } + if !broadcast && peer.ip().is_unspecified() && !yiaddr.is_unspecified() { + out.push(SocketAddr::new(std::net::IpAddr::V4(yiaddr), peer.port())); + } + out + } else { + vec![peer] + } + } + + /// Encodes and sends a DHCP packet back to the client. Delivery follows + /// [`Server::route_dests`]: unicast replies go once to the peer (zero + /// extra allocation, as before); broadcast-routed replies are encoded + /// once and fanned out. Returns the last send's byte count. + pub fn send(&self, p: Packet) -> std::io::Result { + let addr = self.src; + if !p.broadcast && !addr.ip().is_unspecified() { + // Fast path: sourced peer, no broadcast — byte-identical to the + // historical behavior (bench-sensitive: no allocation here). + let mut out_buf = [0u8; crate::packet::WIRE_MAX]; + return self.socket.send_to(p.encode(&mut out_buf), addr); + } + // WIRE_MAX is the DHCP wire ceiling (see packet::Packet::encode): + // 240-byte header+cookie, options, END, PAD-to-WIRE_MAX. Bytes + // beyond are never read. Encoded once, fanned out to each address. + let mut out_buf = [0u8; crate::packet::WIRE_MAX]; + let wire = p.encode(&mut out_buf); + let mut sent = 0; + for dst in Self::route_dests(addr, p.broadcast, p.yiaddr, &self.broadcasts) { + sent = self.socket.send_to(wire, dst)?; + } + Ok(sent) + } +} diff --git a/tests/test_config.rs b/tests/test_config.rs new file mode 100644 index 0000000..d196347 --- /dev/null +++ b/tests/test_config.rs @@ -0,0 +1,447 @@ +//! Tests for `dhcp.conf` parsing (`dhcp4r::config`). +//! +//! Locks that every setting is specifiable in the file, that the shipped +//! `dhcp.conf` parses to the documented values, and that malformed input +//! fails loudly (key + line info) instead of silently misconfiguring the +//! server. Also locks that `Config::defaults()` is byte-for-byte the old +//! hard-coded constants, so a missing file preserves legacy behavior. + +use dhcp4r::config::{parse_duid_hex, Config}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +fn defaults() -> Config { + Config::defaults() +} + +#[test] +fn defaults_match_legacy_hardcoded_values() { + let c = defaults(); + assert_eq!(c.server_ip, Ipv4Addr::new(192, 168, 2, 1)); + assert_eq!(c.ip_start, Ipv4Addr::new(192, 168, 2, 2)); + assert_eq!(c.lease_num, 252); + assert_eq!(c.subnet_mask, Ipv4Addr::new(255, 255, 255, 0)); + assert_eq!(c.router_ip, Ipv4Addr::new(192, 168, 2, 1)); + assert_eq!(c.dns_ips, vec![Ipv4Addr::new(8, 8, 8, 8)]); + assert_eq!(c.broadcast_ip, Ipv4Addr::new(192, 168, 2, 255)); + assert_eq!(c.lease_duration_secs, 86400); + assert_eq!(c.leases_file, "leases"); + assert_eq!(c.listen_addr, SocketAddr::from(([0, 0, 0, 0], 67))); + // v6 off by default: missing file behaves exactly like the old server + assert!(!c.enable_dhcpv6); + // rapid commit off by default: legacy 4-message behavior preserved + assert!(!c.enable_rapid_commit); + assert_eq!( + c.server_duid, + vec![0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc] + ); + assert_eq!(c.ipv6_start, Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100)); + assert_eq!(c.ipv6_lease_num, 1000); + assert_eq!( + c.ipv6_dns, + vec![Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)] + ); + assert_eq!(c.ipv6_lease_duration_secs, 86400); + assert_eq!(c.leases_file_v6, "leases6"); + assert_eq!( + c.listen_addr_v6, + SocketAddr::from((Ipv6Addr::UNSPECIFIED, 547)) + ); +} + +#[test] +fn start_num_helpers() { + let c = defaults(); + assert_eq!(c.ip_start_num(), 0xC0A8_0202); + assert_eq!(c.ip_start_num(), 3232236034); + assert_eq!( + c.ipv6_start_num(), + u128::from(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100)) + ); +} + +#[test] +fn parse_full_sample_sets_every_field() { + let text = "\ +server_ip = 10.0.0.1 +ip_start = 10.0.1.10 +lease_num = 100 +subnet_mask = 255.255.0.0 +router_ip = 10.0.0.254 +dns_ips = 1.1.1.1, 1.0.0.1 +broadcast_ip = 10.0.255.255 +lease_duration_secs = 3600 +leases_file = custom.leases +listen_addr = 127.0.0.1:6767 +enable_dhcpv6 = yes +server_duid = 00:01:00:01:02:03:04:05:06:07 +ipv6_start = 2001:db8::100 +ipv6_lease_num = 500 +ipv6_dns = 2001:db8::53, 2001:db8::54 +ipv6_lease_duration_secs = 7200 +leases_file_v6 = custom6.leases +listen_addr_v6 = [::1]:5547 +"; + let c = Config::parse(text).unwrap(); + assert_eq!(c.server_ip, Ipv4Addr::new(10, 0, 0, 1)); + assert_eq!(c.ip_start, Ipv4Addr::new(10, 0, 1, 10)); + assert_eq!(c.lease_num, 100); + assert_eq!(c.subnet_mask, Ipv4Addr::new(255, 255, 0, 0)); + assert_eq!(c.router_ip, Ipv4Addr::new(10, 0, 0, 254)); + assert_eq!(c.dns_ips, vec![Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(1, 0, 0, 1)]); + assert_eq!(c.broadcast_ip, Ipv4Addr::new(10, 0, 255, 255)); + assert_eq!(c.lease_duration_secs, 3600); + assert_eq!(c.leases_file, "custom.leases"); + assert_eq!(c.listen_addr, "127.0.0.1:6767".parse::().unwrap()); + assert!(c.enable_dhcpv6); + assert_eq!( + c.server_duid, + vec![0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07] + ); + assert_eq!(c.ipv6_start, "2001:db8::100".parse::().unwrap()); + assert_eq!(c.ipv6_lease_num, 500); + assert_eq!( + c.ipv6_dns, + vec![ + "2001:db8::53".parse::().unwrap(), + "2001:db8::54".parse::().unwrap(), + ] + ); + assert_eq!(c.ipv6_lease_duration_secs, 7200); + assert_eq!(c.leases_file_v6, "custom6.leases"); + assert_eq!(c.listen_addr_v6, "[::1]:5547".parse::().unwrap()); +} + +#[test] +fn comments_blanks_and_whitespace_tolerated() { + let text = "# leading comment\n; semicolon comment\n\n \nserver_ip=10.1.2.3\n\tlease_num\t=\t7\n"; + let c = Config::parse(text).unwrap(); + assert_eq!(c.server_ip, Ipv4Addr::new(10, 1, 2, 3)); + assert_eq!(c.lease_num, 7); + // everything else stayed default + assert_eq!(c.subnet_mask, defaults().subnet_mask); +} + +#[test] +fn keys_case_insensitive_and_duplicate_last_wins() { + let c = Config::parse("SERVER_IP = 10.0.0.9\nServer_Ip = 10.0.0.10\n").unwrap(); + assert_eq!(c.server_ip, Ipv4Addr::new(10, 0, 0, 10)); +} + +#[test] +fn unknown_keys_ignored_for_forward_compat() { + let c = Config::parse("future_knob = 123\nserver_ip = 10.9.9.9\n").unwrap(); + assert_eq!(c.server_ip, Ipv4Addr::new(10, 9, 9, 9)); +} + +#[test] +fn trailing_hash_comment_stripped_glued_hash_kept() { + let c = Config::parse("lease_num = 5 # five leases\n").unwrap(); + assert_eq!(c.lease_num, 5); + // no whitespace before '#': kept verbatim -> invalid IPv4 -> error naming key + let err = Config::parse("server_ip = 1.2.3.4#x\n").unwrap_err(); + assert!(err.contains("server_ip"), "unexpected: {}", err); +} + +#[test] +fn bool_variants_accepted() { + for truthy in ["true", "True", "TRUE", "yes", "YES", "on", "ON", "1"] { + let c = Config::parse(&format!("enable_dhcpv6 = {}\n", truthy)).unwrap(); + assert!(c.enable_dhcpv6, "{}", truthy); + } + for falsy in ["false", "False", "FALSE", "no", "NO", "off", "OFF", "0"] { + let c = Config::parse(&format!("enable_dhcpv6 = {}\n", falsy)).unwrap(); + assert!(!c.enable_dhcpv6, "{}", falsy); + } + for bad in ["2", "maybe", "onoff", ""] { + let text = if bad.is_empty() { + "enable_dhcpv6 =\n".to_string() + } else { + format!("enable_dhcpv6 = {}\n", bad) + }; + let err = Config::parse(&text).unwrap_err(); + assert!(err.contains("enable_dhcpv6"), "unexpected: {}", err); + } +} + +#[test] +fn malformed_lines_error_with_line_numbers() { + let err = Config::parse("server_ip 10.0.0.1\n").unwrap_err(); + assert!(err.contains('1'), "unexpected: {}", err); + let err = Config::parse("server_ip = 10.0.0.1\n=oops\n").unwrap_err(); + assert!(err.contains('2'), "unexpected: {}", err); + let err = Config::parse(" = 10.0.0.1\n").unwrap_err(); + assert!(err.contains("empty key"), "unexpected: {}", err); + let err = Config::parse("lease_num =\n").unwrap_err(); + assert!(err.contains("empty value"), "unexpected: {}", err); +} + +#[test] +fn invalid_values_name_the_key() { + let cases = [ + ("lease_num = abc\n", "lease_num"), + ("lease_num = 4294967296\n", "lease_num"), // 2^32 + ("server_ip = 999.1.1.1\n", "server_ip"), + ("dns_ips = 8.8.8.8, bogus\n", "dns_ips"), + ("dns_ips = 8.8.8.8,\n", "dns_ips"), // trailing comma + ("ipv6_start = not-an-ip\n", "ipv6_start"), + ("ipv6_dns = ::1, nope\n", "ipv6_dns"), + ("ipv6_lease_num = -5\n", "ipv6_lease_num"), + ("listen_addr = 0.0.0.0:notaport\n", "listen_addr"), + ("listen_addr_v6 = [::]:99999\n", "listen_addr_v6"), + ("server_duid = 00:03:0\n", "server_duid"), // odd digits + ("server_duid = 00:zz\n", "server_duid"), // non-hex + ("lease_duration_secs = 1.5\n", "lease_duration_secs"), + ]; + for (text, key) in cases { + let err = Config::parse(text).unwrap_err(); + assert!(err.contains(key), "{} should name {:?}: {}", text.trim(), key, err); + } +} + +#[test] +fn duid_length_limits() { + // empty and >128 bytes rejected + assert!(Config::parse("server_duid = \n").is_err()); + let too_long = "aa:".repeat(129) + "bb"; + assert!(Config::parse(&format!("server_duid = {}\n", too_long)).is_err()); + // exactly 128 bytes accepted + let max = vec!["aa"; 128].join(":"); + let c = Config::parse(&format!("server_duid = {}\n", max)).unwrap(); + assert_eq!(c.server_duid.len(), 128); + // single byte accepted + let c = Config::parse("server_duid = ff\n").unwrap(); + assert_eq!(c.server_duid, vec![0xff]); +} + +#[test] +fn pool_overflow_rejected() { + // 255.255.255.255 + 2 as an exclusive end exceeds u32 space + let err = Config::parse("ip_start = 255.255.255.255\nlease_num = 2\n").unwrap_err(); + assert!(err.contains("overflows"), "unexpected: {}", err); + // 255.255.255.255 + 1 is exactly the end (exclusive) — allowed + assert!(Config::parse("ip_start = 255.255.255.255\nlease_num = 1\n").is_ok()); + // 255.255.255.255 + 0 is exactly the end (exclusive) — allowed + assert!(Config::parse("ip_start = 255.255.255.255\nlease_num = 0\n").is_ok()); + // v6: max addr + 1 overflows u128 + let err = Config::parse( + "ipv6_start = ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\nipv6_lease_num = 1\n", + ) + .unwrap_err(); + assert!(err.contains("overflows"), "unexpected: {}", err); +} + +#[test] +fn zero_lease_num_allowed() { + let c = Config::parse("lease_num = 0\nipv6_lease_num = 0\n").unwrap(); + assert_eq!(c.lease_num, 0); + assert_eq!(c.ipv6_lease_num, 0); +} + +#[test] +fn load_missing_file_errors() { + let err = Config::load("definitely-not-a-real-dhcp-conf-12345.conf").unwrap_err(); + assert!(err.contains("cannot read"), "unexpected: {}", err); +} + +#[test] +fn shipped_dhcp_conf_parses_to_documented_values() { + // Runs with CWD = package root under `cargo test`, where dhcp.conf ships. + let c = Config::load("dhcp.conf").unwrap(); + let d = defaults(); + // v4 section mirrors the legacy constants + assert_eq!(c.server_ip, d.server_ip); + assert_eq!(c.ip_start, d.ip_start); + assert_eq!(c.lease_num, d.lease_num); + assert_eq!(c.subnet_mask, d.subnet_mask); + assert_eq!(c.router_ip, d.router_ip); + assert_eq!(c.dns_ips, d.dns_ips); + // NTP section is enabled in the shipped file (differs from defaults) + assert!(c.enable_ntp); + assert_eq!(c.ntp_ips, vec![Ipv4Addr::new(192, 168, 2, 1)]); + assert_eq!(c.broadcast_ip, d.broadcast_ip); + assert_eq!(c.lease_duration_secs, d.lease_duration_secs); + assert_eq!(c.leases_file, "leases"); + assert_eq!(c.listen_addr, d.listen_addr); + // v6 section is the managed range from the README table + assert!(c.enable_dhcpv6); + assert_eq!(c.server_duid, d.server_duid); + assert_eq!(c.ipv6_start, d.ipv6_start); + assert_eq!(c.ipv6_lease_num, d.ipv6_lease_num); + assert_eq!(c.ipv6_dns, d.ipv6_dns); + assert_eq!(c.ipv6_lease_duration_secs, d.ipv6_lease_duration_secs); + assert_eq!(c.leases_file_v6, "leases6"); + assert_eq!(c.listen_addr_v6, d.listen_addr_v6); + // Rapid Commit section is enabled in the shipped file too. + assert!(c.enable_rapid_commit); + // Rapid Commit section is enabled in the shipped file too. + assert!(c.enable_rapid_commit); + // full-struct equality: file must not drift from any default silently + let mut expected = d; + expected.enable_dhcpv6 = true; + expected.enable_ntp = true; + expected.ntp_ips = vec![Ipv4Addr::new(192, 168, 2, 1)]; + expected.enable_rapid_commit = true; + assert_eq!(c, expected); +} + +#[test] +fn parse_duid_hex_forms() { + assert_eq!( + parse_duid_hex("00:03:00:01:02:00:5e:aa:bb:cc").unwrap(), + vec![0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc] + ); + assert_eq!( + parse_duid_hex("00-03-00-01").unwrap(), + vec![0x00, 0x03, 0x00, 0x01] + ); + assert_eq!(parse_duid_hex("00030001").unwrap(), vec![0x00, 0x03, 0x00, 0x01]); + assert_eq!(parse_duid_hex("AA BB").unwrap(), vec![0xaa, 0xbb]); + assert!(parse_duid_hex("").is_err()); + assert!(parse_duid_hex("0").is_err()); + assert!(parse_duid_hex("zz").is_err()); +} + +// --------------------------------------------------------------------------- +// coverage gaps: socket forms, line endings, values with spaces, negatives +// --------------------------------------------------------------------------- + +#[test] +fn ipv6_bracketless_socket_fails() { + // SocketAddr requires brackets around IPv6 hosts with ports. + let err = Config::parse("listen_addr_v6 = ::1:547\n").unwrap_err(); + assert!(err.contains("listen_addr_v6"), "unexpected: {}", err); +} + +#[test] +fn crlf_line_endings_ok() { + let c = Config::parse("server_ip = 10.0.0.1\r\nlease_num = 7\r\n").unwrap(); + assert_eq!(c.server_ip, Ipv4Addr::new(10, 0, 0, 1)); + assert_eq!(c.lease_num, 7); +} + +#[test] +fn values_with_spaces_preserved() { + let c = Config::parse("leases_file = my leases.conf\nleases_file_v6 = v6 leases.conf\n").unwrap(); + assert_eq!(c.leases_file, "my leases.conf"); + assert_eq!(c.leases_file_v6, "v6 leases.conf"); +} + +#[test] +fn negative_numbers_rejected() { + for (text, key) in [ + ("lease_num = -1\n", "lease_num"), + ("lease_duration_secs = -60\n", "lease_duration_secs"), + ("ipv6_lease_num = -1\n", "ipv6_lease_num"), + ] { + let err = Config::parse(text).unwrap_err(); + assert!(err.contains(key), "{} should name {:?}: {}", text.trim(), key, err); + } +} + +#[test] +fn rapid_commit_defaults_disabled() { + assert!(!defaults().enable_rapid_commit); +} + +#[test] +fn rapid_commit_parses_flag() { + for truthy in ["true", "yes", "on", "1", "True"] { + let c = Config::parse(&format!("enable_rapid_commit = {}\n", truthy)).unwrap(); + assert!(c.enable_rapid_commit, "{}", truthy); + } + for falsy in ["false", "no", "off", "0", "False"] { + let c = Config::parse(&format!("enable_rapid_commit = {}\n", falsy)).unwrap(); + assert!(!c.enable_rapid_commit, "{}", falsy); + } + let err = Config::parse("enable_rapid_commit = eventually\n").unwrap_err(); + assert!(err.contains("enable_rapid_commit"), "unexpected: {}", err); + assert_eq!( + Config::parse("ENABLE_RAPID_COMMIT = YES\n").map(|c| c.enable_rapid_commit), + Ok(true), + "keys are case-insensitive" + ); +} + +// --------------------------------------------------------------------------- +// NTP settings (option 42) +// --------------------------------------------------------------------------- + +#[test] +fn ntp_defaults_disabled_with_empty_list() { + // Legacy behavior: the old hardcoded server never sent option 42. + let c = defaults(); + assert!(!c.enable_ntp); + assert!(c.ntp_ips.is_empty()); +} + +#[test] +fn ntp_parses_flag_and_server_list() { + let c = Config::parse("enable_ntp = true\nntp_ips = 192.168.2.1, 192.168.2.2\n").unwrap(); + assert!(c.enable_ntp); + assert_eq!(c.ntp_ips, vec![Ipv4Addr::new(192, 168, 2, 1), Ipv4Addr::new(192, 168, 2, 2)]); + let c = Config::parse("ENABLE_NTP = Yes\nNTP_IPS = 10.0.0.5\n").unwrap(); + assert!(c.enable_ntp); + assert_eq!(c.ntp_ips, vec![Ipv4Addr::new(10, 0, 0, 5)]); + let c = Config::parse("enable_ntp = off\n").unwrap(); + assert!(!c.enable_ntp); +} + +#[test] +fn ntp_invalid_values_name_the_key() { + let err = Config::parse("enable_ntp = sometimes\n").unwrap_err(); + assert!(err.contains("enable_ntp"), "unexpected: {}", err); + let err = Config::parse("ntp_ips = 8.8.8.8, bogus\n").unwrap_err(); + assert!(err.contains("ntp_ips"), "unexpected: {}", err); + let err = Config::parse("ntp_ips = 8.8.8.8,\n").unwrap_err(); + assert!(err.contains("ntp_ips"), "unexpected: {}", err); +} + +#[test] +fn broadcast_ip_comma_list_and_extra_key() { + // Single value keeps legacy shape: primary set, no extras. + let c = Config::parse("broadcast_ip = 10.0.255.255\n").unwrap(); + assert_eq!(c.broadcast_ip, Ipv4Addr::new(10, 0, 255, 255)); + assert!(c.extra_broadcast_ips.is_empty()); + assert_eq!(c.broadcasts(), vec![Ipv4Addr::new(10, 0, 255, 255)]); + // Comma form from the issue: first stays primary, rest are extras. + let c = + Config::parse("broadcast_ip = 192.168.2.255, 255.255.255.255\n").unwrap(); + assert_eq!(c.broadcast_ip, Ipv4Addr::new(192, 168, 2, 255)); + assert_eq!( + c.extra_broadcast_ips, + vec![Ipv4Addr::new(255, 255, 255, 255)] + ); + assert_eq!( + c.broadcasts(), + vec![ + Ipv4Addr::new(192, 168, 2, 255), + Ipv4Addr::new(255, 255, 255, 255) + ] + ); + // Dedicated key appends; duplicates collapse in broadcasts(). + let c = Config::parse( + "broadcast_ip = 192.168.2.255\nextra_broadcast_ips = 255.255.255.255, 192.168.2.255\n", + ) + .unwrap(); + assert_eq!( + c.broadcasts(), + vec![ + Ipv4Addr::new(192, 168, 2, 255), + Ipv4Addr::new(255, 255, 255, 255) + ] + ); + // Invalid entries fail loudly naming the key (no silent misroute). + // (Note: 255.255.255.0 is a *valid* address and parses fine.) + let err = Config::parse("broadcast_ip = 192.168.2.255, bogus\n").unwrap_err(); + assert!(err.contains("broadcast_ip"), "unexpected: {}", err); + let err = Config::parse("extra_broadcast_ips = bogus\n").unwrap_err(); + assert!(err.contains("extra_broadcast_ips"), "unexpected: {}", err); + // Defaults carry no extras (legacy single-destination behavior). + assert!(defaults().extra_broadcast_ips.is_empty()); + assert_eq!( + defaults().broadcasts(), + vec![Ipv4Addr::new(192, 168, 2, 255)] + ); +} + + diff --git a/tests/test_dhcp_rfc.rs b/tests/test_dhcp_rfc.rs new file mode 100644 index 0000000..6d83a20 --- /dev/null +++ b/tests/test_dhcp_rfc.rs @@ -0,0 +1,831 @@ +//! DHCP standards conformance regression suite. +//! +//! Locks behavior required by RFC 2131 (DHCP) and RFC 2132 (options) so that +//! refactors cannot silently break on-the-wire interoperability. +//! +//! Where the implementation intentionally deviates from (or is stricter than) +//! the RFC, the test name ends in `_quirk` / `_deviation` and the RFC +//! expectation is quoted in the doc comment. All such tests assert the +//! *current* behavior and pass against it. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +#[allow(dead_code)] +fn opt_raw(code: u8, data: &[u8]) -> Vec { + let mut v = vec![code, data.len() as u8]; + v.extend_from_slice(data); + v +} + +/// RFC 2131 §2 / RFC 951 §3 fixed BOOTP header: 236 bytes before options. +#[allow(clippy::too_many_arguments)] +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; // htype = Ethernet per RFC 2131 §2 / RFC 1700 + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); // RFC 1533 magic cookie + v.extend_from_slice(&opts_after_cookie); + v +} + +fn client_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), + yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), + giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr, + options: opts, + } +} + +#[allow(dead_code)] +fn recv(sock: &UdpSocket, buf: &mut [u8]) -> (usize, std::net::SocketAddr) { + sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + sock.recv_from(buf).expect("timeout waiting for reply") +} + +// =========================================================================== +// RFC 2131 §2 — BOOTP/DHCP fixed header +// =========================================================================== + +/// RFC 2131 §2: `op` 1 = BOOTREQUEST (client), 2 = BOOTREPLY (server). +#[test] +fn rfc2131_op_bootrequest_is_decode_request_encode_reply() { + let req = unwrap_packet(Packet::from(&make_raw( + 1, 6, 0, 0x11111111, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 1, 255], + ))); + assert!(!req.reply); + + let rep = unwrap_packet(Packet::from(&make_raw( + 2, 6, 0, 0x11111111, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 2, 255], + ))); + assert!(rep.reply); + + // encode path mirrors the same mapping + let mut buf = [0u8; 1500]; + assert_eq!(client_packet(1, [0; 6], vec![]).encode(&mut buf)[0], 1); + let server_pkt = Packet { + reply: true, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], options: vec![], + }; + assert_eq!(server_pkt.encode(&mut buf)[0], 2); +} + +/// RFC 2131 §2: `htype` 1 = 10Mb Ethernet, `hlen` 6 for Ethernet. +/// Decoder enforces `hlen == 6`; `htype` is accepted as-is (relay/ethernet). +#[test] +fn rfc2131_htype_hlen_ethernet() { + // hlen != 6 is rejected (Ethernet chaddr is 6 bytes) + for bad in [0u8, 5, 7, 16, 255] { + let raw = make_raw( + 1, bad, 0, 1, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 1, 255], + ); + assert!( + matches!(Packet::from(&raw), Err(CustomErr::InvalidHlen)), + "hlen {} must be rejected", + bad + ); + } + // encoder always emits htype=1, hlen=6 + let mut buf = [0u8; 1500]; + let enc = client_packet(1, [9; 6], vec![]).encode(&mut buf).to_vec(); + assert_eq!(enc[1], 1); + assert_eq!(enc[2], 6); +} + +/// RFC 2131 §2: fixed header is 236 bytes (op..file), then magic cookie. +/// `chaddr` is 16 bytes on the wire (6 used + 10 pad); sname/file are zero. +#[test] +fn rfc2131_fixed_header_layout_and_zero_padding() { + let p = Packet { + reply: false, hops: 0, xid: 0xAABBCCDD, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(1, 2, 3, 4), yiaddr: Ipv4Addr::new(5, 6, 7, 8), + siaddr: Ipv4Addr::new(9, 10, 11, 12), giaddr: Ipv4Addr::new(13, 14, 15, 16), + chaddr: [10, 20, 30, 40, 50, 60], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let mut buf = [0xFFu8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + // BOOTP fields at RFC offsets + assert_eq!(&enc[4..8], &0xAABBCCDDu32.to_be_bytes()); // xid + assert_eq!(&enc[12..16], &[1, 2, 3, 4]); // ciaddr + assert_eq!(&enc[16..20], &[5, 6, 7, 8]); // yiaddr + assert_eq!(&enc[20..24], &[9, 10, 11, 12]); // siaddr + assert_eq!(&enc[24..28], &[13, 14, 15, 16]); // giaddr + assert_eq!(&enc[28..34], &[10, 20, 30, 40, 50, 60]); // chaddr[0..6] + assert!(enc[34..236].iter().all(|&b| b == 0)); // chaddr pad + sname + file + assert_eq!(&enc[236..240], &[99, 130, 83, 99]); // cookie + assert!(enc.len() >= 241 && enc.len() <= 300); +} + +/// RFC 1533 §2 / RFC 2131 §3: magic cookie 99,130,83,99 selects DHCP options. +/// Anything else is not a DHCP packet. +#[test] +fn rfc1533_magic_cookie_required() { + let mut raw = make_raw( + 1, 6, 0, 1, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 1, 255], + ); + raw[236..240].copy_from_slice(&[99, 130, 83, 99]); + assert!(Packet::from(&raw).is_ok(), "good cookie must decode"); + raw[236..240].copy_from_slice(&[0, 0, 0, 0]); + assert!( + matches!(Packet::from(&raw), Err(CustomErr::NomError(_))), + "bad cookie must be rejected" + ); +} + +/// RFC 2131 §2: minimum DHCP message handling. Implementation pads the send +/// buffer with zeros out to 300 bytes (BOOTP minimum on the wire). +#[test] +fn rfc2131_minimum_message_zero_padding() { + let p = client_packet(1, [0; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let mut buf = [0xFFu8; 1500]; + let len = p.encode(&mut buf).len(); + assert_eq!(len, 244); + assert!(buf[len..300].iter().all(|&b| b == 0)); + // every encoded datagram fits in one 1500-byte Ethernet MTU datagram + assert!(len <= 1500); +} + +// =========================================================================== +// RFC 2132 §2 — option TLV wire format and lengths +// =========================================================================== + +/// RFC 2132 §2: options are `code, len, data` except PAD (0) and END (255), +/// which carry neither length nor data. Every encoded option must appear as +/// exactly `2 + data.len()` bytes followed eventually by a single END byte. +#[test] +fn rfc2132_option_tlv_then_end() { + let opts = vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), // 53, len 1 + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), // 1, len 4 + ]; + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], options: opts, + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(&enc[240..243], &[53, 1, 1]); + assert_eq!(&enc[243..249], &[1, 4, 255, 255, 255, 0]); + assert_eq!(enc[249], 255); // END + assert_eq!(enc.len(), 250); +} + +/// RFC 2132 §9.6: DHCP Message Type (53) has length 1. +#[test] +fn rfc2132_opt53_message_type_len_1() { + for mt in [ + MessageType::Discover, MessageType::Offer, MessageType::Request, + MessageType::Decline, MessageType::Ack, MessageType::Nak, + MessageType::Release, MessageType::Inform, + ] { + let raw = DhcpOption::DhcpMessageType(mt).to_raw(); + assert_eq!(raw.code, 53); + assert_eq!(raw.data.len(), 1); + assert_eq!(raw.data[0], mt as u8); + } + // len != 1 with trailing bytes: decoder uses first data byte (locked) + match decode_option(&[53, 2, 1, 2]) { + Ok((_, DhcpOption::DhcpMessageType(t))) => assert_eq!(t, MessageType::Discover), + _ => panic!("53 len 2 should use first byte"), + } +} + +/// RFC 2132 §9.4/§9.7/§2.3/§2.4: IPv4 options carry 4 bytes per address. +/// Server Identifier (54), Requested IP (50), Subnet Mask (1) are exactly 4; +/// Lease Time (51) is a 4-byte unsigned seconds count, big-endian. +#[test] +fn rfc2132_ipv4_and_lease_time_lengths() { + assert_eq!(DhcpOption::ServerIdentifier(Ipv4Addr::new(10, 0, 0, 1)).to_raw().data.len(), 4); + assert_eq!(DhcpOption::RequestedIpAddress(Ipv4Addr::new(10, 0, 0, 5)).to_raw().data.len(), 4); + assert_eq!(DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)).to_raw().data.len(), 4); + // 86400 = one day; MAX = infinite per RFC 2132 §9.10 discussion + assert_eq!(DhcpOption::IpAddressLeaseTime(86400).to_raw().data, vec![0, 1, 81, 128]); + assert_eq!(DhcpOption::IpAddressLeaseTime(u32::MAX).to_raw().data, vec![255, 255, 255, 255]); + assert_eq!(DhcpOption::IpAddressLeaseTime(0).to_raw().data, vec![0, 0, 0, 0]); + // short address payloads are rejected + assert!(matches!(decode_option(&[54, 3, 1, 2, 3]), Err(CustomErr::InvalidHlen))); + assert!(matches!(decode_option(&[51, 2, 0, 1]), Err(CustomErr::InvalidHlen))); +} + +/// RFC 2132 §9.2/§9.3: Router (3) and Domain Name Server (6) are sums of +/// 4-byte IPv4 addresses, so total data length is a multiple of 4. +/// The encoder preserves this invariant. +#[test] +fn rfc2132_router_dns_encoding_is_multiple_of_4() { + assert!(DhcpOption::Router(vec![]).to_raw().data.is_empty()); + assert_eq!(DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1)]).to_raw().data.len(), 4); + assert_eq!( + DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(10, 0, 0, 1)]) + .to_raw().data.len(), + 8 + ); + assert_eq!( + DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8), Ipv4Addr::new(8, 8, 4, 4)]) + .to_raw().data.len(), + 8 + ); +} + +/// RFC 2132 §9.8: Parameter Request List (55) is N bytes of option codes. +/// Empty list is legal. +#[test] +fn rfc2132_opt55_parameter_request_list() { + match decode_option(&[55, 3, 1, 3, 6]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert_eq!(v, vec![1, 3, 6]), + _ => panic!("PRL decode"), + } + match decode_option(&[55, 0]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert!(v.is_empty()), + _ => panic!("empty PRL"), + } +} + +/// RFC 2132 §9.1/§7: Host Name (12) and Message (56) are variable-length text. +/// Decoder requires valid UTF-8 (stricter than RFC's opaque text). +#[test] +fn rfc2132_text_options_utf8() { + match decode_option(&[12, 3, 65, 66, 67]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, "ABC"), + _ => panic!("hostname"), + } + match decode_option(&[12, 0]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, ""), + _ => panic!("empty hostname"), + } + assert!(matches!(decode_option(&[12, 2, 0xFF, 0xFE]), Err(CustomErr::NonUtf8String))); + assert!(matches!(decode_option(&[56, 2, 0xFF, 0xFE]), Err(CustomErr::NonUtf8String))); +} + +/// RFC 2132 §2: unknown codes must be preserved transparently (code + data). +#[test] +fn rfc2132_unrecognized_options_preserved() { + match decode_option(&[99, 2, 1, 2]) { + Ok((rest, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 99); + assert_eq!(r.data, vec![1, 2]); + assert!(rest.is_empty()); + } + _ => panic!("unrecognized"), + } + let inner = RawDhcpOption { code: 200, data: vec![1, 2, 3] }; + assert_eq!(DhcpOption::Unrecognized(inner.clone()).to_raw(), inner); +} + +/// RFC 2132 §2 deviation: PAD (0) is defined as a single 0x00 byte with no +/// length, but this implementation parses it as a normal TLV code. Locked so +/// a future "fix" cannot silently change decoding of captures containing 0. +#[test] +fn rfc2132_pad_singleton_deviation_quirk() { + match decode_option(&[0, 1, 2]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 0); + assert_eq!(r.data, vec![2]); + } + _ => panic!("PAD code 0 must decode as Unrecognized TLV"), + } +} + +// =========================================================================== +// RFC 2132 §9.6 + RFC 2131 Table 2 — message types and DORA roles +// =========================================================================== + +/// RFC 2132 §9.6 numeric values 1..8. +#[test] +fn rfc2132_msgtype_values_1_to_8() { + assert_eq!(MessageType::Discover as u8, 1); + assert_eq!(MessageType::Offer as u8, 2); + assert_eq!(MessageType::Request as u8, 3); + assert_eq!(MessageType::Decline as u8, 4); + assert_eq!(MessageType::Ack as u8, 5); + assert_eq!(MessageType::Nak as u8, 6); + assert_eq!(MessageType::Release as u8, 7); + assert_eq!(MessageType::Inform as u8, 8); + for v in 1u8..=8 { + assert!(MessageType::from(v).is_ok()); + } + for v in [0u8, 9, 255] { + assert!(MessageType::from(v).is_err()); + } +} + +/// RFC 2131 Table 2 roles: client sends Discover/Request/Decline/Release/ +/// Inform; server sends Offer/Ack/Nak. Every packet carries option 53, and +/// `message_type()` reports it (or a precise error when absent/mistyped). +#[test] +fn rfc2131_table2_every_packet_carries_opt53() { + for mt in [ + MessageType::Discover, MessageType::Offer, MessageType::Request, + MessageType::Decline, MessageType::Ack, MessageType::Nak, + MessageType::Release, MessageType::Inform, + ] { + let p = Packet { + reply: matches!(mt, MessageType::Offer | MessageType::Ack | MessageType::Nak), + hops: 0, xid: 0x42, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], options: vec![DhcpOption::DhcpMessageType(mt)], + }; + assert_eq!(p.message_type(), Ok(mt)); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(unwrap_packet(Packet::from(&enc)).message_type(), Ok(mt)); + } + let bare = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], options: vec![], + }; + assert!(bare.message_type().is_err()); +} + +// =========================================================================== +// RFC 2131 §3.1 — client field rules per state +// =========================================================================== + +/// RFC 2131 §3.1 Table 1 (DHCPDISCOVER): ciaddr MUST be 0, yiaddr/siaddr 0, +/// chaddr = client hardware address, xid = random transaction id. +#[test] +fn rfc2131_discover_zero_addresses_and_chaddr() { + let xid = 0x39A12B4Cu32; + let ch = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + let raw = make_raw( + 1, 6, 0, xid, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ch, + vec![53, 1, 1, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.ciaddr, Ipv4Addr::UNSPECIFIED); + assert_eq!(p.yiaddr, Ipv4Addr::UNSPECIFIED); + assert_eq!(p.siaddr, Ipv4Addr::UNSPECIFIED); + assert_eq!(p.chaddr, ch); + assert_eq!(p.xid, xid); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); +} + +/// RFC 2131 §3.1/§4.3.2: DHCPREQUEST address selection — INIT-REBOOT carries +/// Requested IP (50) and no ciaddr; RENEWING/REBINDING carries ciaddr. +/// Library rule (mirrored from src/main.rs): RequestedIp else ciaddr. +fn rfc2131_requested_or_ciaddr(p: &Packet) -> Ipv4Addr { + match p.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => p.ciaddr, + } +} + +#[test] +fn rfc2131_request_address_selection() { + // INIT-REBOOT: Requested IP wins over ciaddr + let init = Packet { + reply: false, hops: 0, xid: 2, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [1; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 100)), + ], + }; + assert_eq!(rfc2131_requested_or_ciaddr(&init), Ipv4Addr::new(192, 168, 1, 100)); + // RENEWING: no Requested IP, ciaddr identifies the lease + let renew = Packet { + reply: false, hops: 0, xid: 3, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 50), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [1; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + assert_eq!(rfc2131_requested_or_ciaddr(&renew), Ipv4Addr::new(192, 168, 1, 50)); +} + +/// RFC 2131 §§3.1, 4.3.3, 4.4.4: DECLINE (address in use), RELEASE +/// (relinquish), INFORM (already-addressed client asks only for config). +#[test] +fn rfc2131_decline_release_inform_roles() { + for mt in [MessageType::Decline, MessageType::Release, MessageType::Inform] { + let p = Packet { + reply: false, hops: 0, xid: 9, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 10), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [5; 6], options: vec![DhcpOption::DhcpMessageType(mt)], + }; + assert_eq!(p.message_type(), Ok(mt)); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.message_type(), Ok(mt)); + assert_eq!(q.ciaddr, Ipv4Addr::new(192, 168, 1, 10)); + } +} + +// =========================================================================== +// RFC 2131 §4.3 — server reply rules (via Server::reply over loopback) +// =========================================================================== + +fn reply_once( + msg: MessageType, + additional: Vec, + offer: Ipv4Addr, + req: Packet, +) -> Packet { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H { + msg: MessageType, + add: Option>, + offer: Ipv4Addr, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let add = self.add.take().unwrap(); + let _ = s.reply(self.msg, add, self.offer, p); + } + } + let h = H { msg, add: Some(additional), offer }; + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), h, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("no reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +/// RFC 2131 §4.3: server replies are BOOTREPLY with echoed xid/chaddr/giaddr, +/// hops/secs reset to 0, yiaddr = offered address, Server Identifier present. +#[test] +fn rfc2131_server_reply_echo_and_fixed_fields() { + let req = Packet { + reply: false, hops: 4, xid: 0xDEADBEEF, secs: 55, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::new(9, 9, 9, 9), giaddr: Ipv4Addr::new(192, 168, 1, 254), + chaddr: [7, 7, 7, 7, 7, 7], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let rep = reply_once( + MessageType::Offer, + vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))], + Ipv4Addr::new(192, 168, 1, 100), + req, + ); + assert!(rep.reply, "server must send BOOTREPLY"); + assert_eq!(rep.xid, 0xDEADBEEF, "xid MUST be echoed"); + assert_eq!(rep.chaddr, [7, 7, 7, 7, 7, 7], "chaddr MUST be echoed"); + assert_eq!(rep.giaddr, Ipv4Addr::new(192, 168, 1, 254), "giaddr MUST be echoed for relays"); + assert_eq!(rep.hops, 0, "server resets hops"); + assert_eq!(rep.secs, 0, "server resets secs"); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 1, 100)); + // siaddr 0.0.0.0 = "next server not specified" (standards-allowed) + assert_eq!(rep.siaddr, Ipv4Addr::UNSPECIFIED); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!( + rep.option(SERVER_IDENTIFIER), + Some(&DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1))), + "Offer MUST carry Server Identifier" + ); +} + +/// RFC 2131 §4.3.1: DHCPNAK signals the client to restart; ciaddr MUST NOT +/// carry a stale client address (implementation zeroes it; yiaddr stays the +/// caller-supplied offer value). +#[test] +fn rfc2131_nak_clears_ciaddr() { + let req = Packet { + reply: false, hops: 0, xid: 0x77, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 99), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [1, 1, 1, 1, 1, 1], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + let rep = reply_once(MessageType::Nak, vec![], Ipv4Addr::UNSPECIFIED, req); + assert_eq!(rep.message_type(), Ok(MessageType::Nak)); + assert_eq!(rep.ciaddr, Ipv4Addr::UNSPECIFIED); +} + +/// RFC 2131 §4.3: non-NAK replies echo ciaddr (RENEWING client identity). +#[test] +fn rfc2131_non_nak_echoes_ciaddr() { + for mt in [MessageType::Offer, MessageType::Ack] { + let req = Packet { + reply: false, hops: 0, xid: 0x78, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 44), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [2, 2, 2, 2, 2, 2], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let rep = reply_once(mt, vec![], Ipv4Addr::new(192, 168, 1, 45), req); + assert_eq!(rep.ciaddr, Ipv4Addr::new(192, 168, 1, 44), "{:?}", mt); + } +} + +/// RFC 2131 §4.3.1 Table 3: DORA uses Discover→Offer→Request→Ack with the +/// same xid/chaddr throughout; the ACK commits yiaddr. +#[test] +fn rfc2131_dora_field_continuity() { + let ch = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]; + let xid = 0x1234ABCDu32; + let mk_req = |mt: MessageType| Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: ch, + options: vec![ + DhcpOption::DhcpMessageType(mt), + DhcpOption::ParameterRequestList(vec![SUBNET_MASK, ROUTER]), + ], + }; + let offer = reply_once( + MessageType::Offer, + vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))], + Ipv4Addr::new(192, 168, 1, 70), + mk_req(MessageType::Discover), + ); + assert_eq!(offer.message_type(), Ok(MessageType::Offer)); + let offered = offer.yiaddr; + + let ack = reply_once( + MessageType::Ack, + vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))], + offered, + mk_req(MessageType::Request), + ); + assert_eq!(ack.message_type(), Ok(MessageType::Ack)); + assert_eq!(ack.yiaddr, offered, "ACK MUST commit the offered address"); + assert_eq!(ack.xid, xid); + assert_eq!(ack.chaddr, ch); +} + +// =========================================================================== +// RFC 2131 §4.1 + §3.3 — broadcast, unicast, relays, server selection +// =========================================================================== + +/// RFC 2131 §4.1: the BROADCAST flag is the high-order bit of `flags` +/// (0x8000). This implementation tests `flags & 128` (low-byte 0x80) instead; +/// `[0,128]` decodes broadcast while the on-the-wire RFC value `[128,0]` +/// decodes as unicast. Locked as a deviation so captures stay parseable. +#[test] +fn rfc2131_broadcast_flag_deviation_quirk() { + let bcast = unwrap_packet(Packet::from(&make_raw( + 1, 6, 0, 1, 0, [0, 128], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 1, 255], + ))); + assert!(bcast.broadcast); + let rfc_wire = unwrap_packet(Packet::from(&make_raw( + 1, 6, 0, 1, 0, [128, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 1, 255], + ))); + assert!(!rfc_wire.broadcast, "RFC 0x8000 currently decodes as unicast"); +} + +/// RFC 2131 §4.1: server echoes the broadcast flag; relay `giaddr` is echoed +/// so the reply returns via the same relay. +/// +/// NOTE: a `Packet { broadcast: true }` struct encodes to flags `[128,0]`, +/// which decodes back to `false` (see deviation test above). The server only +/// ever sees the decoded value, so this test sends the decodable `[0,128]` +/// wire form directly instead of round-tripping a struct. +#[test] +fn rfc2131_reply_echoes_broadcast_and_giaddr() { + let raw_req = make_raw( + 1, 6, 0, 0x99, 0, [0, 128], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [10, 0, 0, 254], + [3, 3, 3, 3, 3, 3], + vec![53, 1, 1, 255], + ); + // sanity: the wire form the server will see decodes to broadcast=true + assert!(unwrap_packet(Packet::from(&raw_req)).broadcast); + + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + assert!(p.broadcast, "server must see broadcast=true from [0,128]"); + let _ = s.reply( + MessageType::Offer, + vec![], + Ipv4Addr::new(10, 0, 1, 5), + p, + ); + } + } + std::thread::spawn(move || { + // broadcast_ip is loopback so the broadcast reply still reaches the + // test client (same pattern as test_server.rs broadcast test). + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(127, 0, 0, 1), H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client.send_to(&raw_req, srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut rbuf = [0u8; 1500]; + let (n, _) = client.recv_from(&mut rbuf).expect("no reply"); + // server echoed broadcast=true, so the reply wire flags are [128,0] + assert_eq!(&rbuf[10..12], &[128, 0], "reply wire must carry broadcast flag"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + // decoding [128,0] yields false (see deviation test); the wire bytes are + // authoritative, not the decoded struct field + assert!(!rep.broadcast, "decoded [128,0] is false per flags & 128 quirk"); + assert_eq!(rep.giaddr, Ipv4Addr::new(10, 0, 0, 254)); +} + +/// RFC 2131 §3.3: relay agents bump `hops` and set `giaddr`; server replies +/// with `hops` reset to 0 (this implementation) while echoing `giaddr`. +#[test] +fn rfc2131_relay_hops_reset_giaddr_echoed() { + let req = Packet { + reply: false, hops: 3, xid: 0x55, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::new(10, 1, 1, 254), + chaddr: [4, 4, 4, 4, 4, 4], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(10, 1, 2, 7), req); + assert_eq!(rep.hops, 0); + assert_eq!(rep.giaddr, Ipv4Addr::new(10, 1, 2, 7).with_octets([10, 1, 1, 254])); +} + +/// RFC 2131 §4.3: clients select among offers via Server Identifier (54); +/// `for_this_server` is true only for an exact identifier match. +#[test] +fn rfc2131_server_identifier_selection() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 1, 1); + let (tx, rx) = std::sync::mpsc::channel::(); + struct H { + tx: std::sync::mpsc::Sender, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = self.tx.send(s.for_this_server(&p)); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(192, 168, 1, 255), H { tx }); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + for (id, expect) in [ + (Ipv4Addr::new(192, 168, 1, 1), true), + (Ipv4Addr::new(192, 168, 1, 2), false), + ] { + let p = client_packet(0x60, [6; 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(id), + ]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), expect); + } + // no identifier at all: not for this server + let p = client_packet(0x61, [6; 6], vec![DhcpOption::DhcpMessageType(MessageType::Request)]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!(!rx.recv_timeout(Duration::from_secs(2)).unwrap()); +} + +// =========================================================================== +// RFC 2131 §3.6 / RFC 2132 §9.8 — Parameter Request List negotiation +// =========================================================================== + +/// RFC 2131 §3.6: the client lists desired options in PRL (55); the server +/// MUST include DHCP Message Type (53) + Server Identifier (54) regardless, +/// then the requested subset of its configured options. +#[test] +fn rfc2131_prl_negotiation_keeps_required_plus_requested() { + // server configured with Subnet + Lease + Message; client asks only [1] + let req = Packet { + reply: false, hops: 0, xid: 0x70, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [8; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::ParameterRequestList(vec![SUBNET_MASK]), + ], + }; + let rep = reply_once( + MessageType::Ack, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(3600), + DhcpOption::Message("not requested".to_string()), + ], + Ipv4Addr::new(192, 168, 1, 80), + req, + ); + let got: Vec = rep.options.iter().map(|o| o.code()).collect(); + assert_eq!(got, vec![1, 53, 54, 51], "PRL [1] + required 53/54 + remaining default 51"); + assert!(rep.option(MESSAGE).is_none(), "unrequested Message MUST be filtered"); +} + +/// RFC 2131 §3.6: with no PRL the server returns its full configured set. +#[test] +fn rfc2131_no_prl_returns_full_configured_set() { + let req = client_packet(0x71, [9; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let rep = reply_once( + MessageType::Offer, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(7200), + ], + Ipv4Addr::new(192, 168, 1, 81), + req, + ); + let got: Vec = rep.options.iter().map(|o| o.code()).collect(); + assert_eq!(got, vec![53, 54, 1, 51]); +} + +// --------------------------------------------------------------------------- +// small helper trait to build the relay-echo assertion without typos +// --------------------------------------------------------------------------- + +trait WithOctets { + fn with_octets(self, o: [u8; 4]) -> Ipv4Addr; +} +impl WithOctets for Ipv4Addr { + fn with_octets(self, o: [u8; 4]) -> Ipv4Addr { + let _ = self; + Ipv4Addr::from(o) + } +} diff --git a/tests/test_dhcp_standards.rs b/tests/test_dhcp_standards.rs new file mode 100644 index 0000000..4f8b8fb --- /dev/null +++ b/tests/test_dhcp_standards.rs @@ -0,0 +1,713 @@ +//! DHCP standards accuracy suite, part 2. +//! +//! Covers RFC 2131 / RFC 2132 / RFC 951 areas NOT covered by +//! `test_dhcp_rfc.rs`, so refactors cannot silently break accuracy: +//! +//! - full option support matrix (typed vs transparent passthrough) +//! - client packet-shape rules per state (Table 1: SELECTING / INIT-REBOOT / +//! RENEWING / INFORM / DECLINE / RELEASE) +//! - relay (`giaddr`/`hops`) destination handling and `secs` retransmission +//! - lease-time edge values incl. infinite, plus T1/T2 passthrough +//! - `sname`/`file` precise split and Overload limitation +//! - BOOTP minimum-size / header-size edge cases +//! +//! Naming: `std_*` = asserts RFC-required behavior; `*_quirk` documents a +//! current deviation and asserts present behavior so it cannot change silently. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +#[allow(clippy::too_many_arguments)] +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + sname_fill: u8, + file_fill: u8, + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + // chaddr pad (10) + sname (64) + file (128) with distinct fills so tests + // can prove each region is ignored/zeroed independently. + for b in v[34..44].iter_mut() { + *b = 0; // chaddr pad must stay zero in our builder + } + for b in v[44..108].iter_mut() { + *b = sname_fill; + } + for b in v[108..236].iter_mut() { + *b = file_fill; + } + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +fn simple_raw(opts: Vec) -> Vec { + make_raw( + 1, 6, 0, 0x01020304, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], 0, 0, opts, + ) +} + +fn client_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, options: opts, + } +} + +fn reply_once(msg: MessageType, additional: Vec, offer: Ipv4Addr, req: Packet) -> Packet { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H { + msg: MessageType, + add: Option>, + offer: Ipv4Addr, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let add = self.add.take().unwrap(); + let _ = s.reply(self.msg, add, self.offer, p); + } + } + let h = H { msg, add: Some(additional), offer }; + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), h, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("no reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +// =========================================================================== +// A. RFC 2132 option support matrix — typed vs transparent passthrough +// =========================================================================== + +/// RFC 2132: options 52 (Overload), 57 (Max Message Size), 58/59 (T1/T2), +/// 60 (Vendor Class), 61 (Client Identifier) have no typed variant here and +/// MUST survive as transparent `Unrecognized` (code + data preserved). +#[test] +fn std_unsupported_rfc_options_passthrough() { + let cases: Vec<(u8, Vec)> = vec![ + (52, vec![1]), // Overload: file + (57, vec![0x02, 0x40]), // Max DHCP message size 576 + (58, 600u32.to_be_bytes().to_vec()), // T1 renewal + (59, 1200u32.to_be_bytes().to_vec()), // T2 rebinding + (60, b"test-client".to_vec()), // Vendor class + (61, vec![1, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), // Client id (htype+chaddr) + (66, b"tftp".to_vec()), // TFTP server name + (67, b"pxelinux.0".to_vec()), // Bootfile + (77, b"user".to_vec()), // User class + (82, vec![1, 2, 3]), // Relay agent info + (93, vec![0, 0]), // Client architecture + (100, b"EST5".to_vec()), // TZ POSIX + (101, b"Europe/Berlin".to_vec()), // TZ database + (121, vec![24, 192, 168, 1, 192, 168, 1, 1]), // Classless route + ]; + for (code, data) in &cases { + match decode_option(&[vec![*code, data.len() as u8], data.clone()].concat()) { + Ok((rest, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, *code); + assert_eq!(&r.data, data); + assert!(rest.is_empty()); + } + _ => panic!("code {} must decode as Unrecognized", code), + } + // to_raw preserves code+data byte-for-byte + let raw = DhcpOption::Unrecognized(RawDhcpOption { code: *code, data: data.clone() }).to_raw(); + assert_eq!(raw.code, *code); + assert_eq!(&raw.data, data); + } + // and they round-trip through a full Packet (no poison like Router/DNS). + // keep it small: first 4 need 3+4+6+6=19 bytes (240+19+1=260 fits). + let small: Vec = cases[..4] + .iter() + .map(|(c, d)| DhcpOption::Unrecognized(RawDhcpOption { code: *c, data: d.clone() })) + .collect(); + let small_len = small.len(); + let p = Packet { + reply: false, hops: 0, xid: 0x77, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], options: small, + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.options.len(), small_len); + for (got, (code, data)) in q.options.iter().zip(cases[..4].iter()) { + match got { + DhcpOption::Unrecognized(r) => { + assert_eq!(r.code, *code); + assert_eq!(&r.data, data); + } + _ => panic!("code {} must stay Unrecognized after round-trip", code), + } + } +} + +/// RFC 2132 §9.3/§9.10 vs current decoder: Router (3) / DNS (6) encoder output +/// is standards-shaped (N*4 bytes) but `decode_option` always fails, so a +/// Packet carrying them loses them. Locked here as a known non-conformance so +/// a refactor cannot silently change half the behavior. +#[test] +fn std_router_dns_encoder_shaped_decoder_drops_quirk() { + let r = DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(10, 0, 0, 1)]).to_raw(); + assert_eq!(r.code, 3); + assert_eq!(r.data.len(), 8, "RFC: 2 addrs = 8 bytes"); + let d = DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]).to_raw(); + assert_eq!(d.code, 6); + assert_eq!(d.data.len(), 4); + // decoder side drops them + assert!(matches!(decode_option(&[3, 4, 192, 168, 1, 1]), Err(CustomErr::InvalidHlen))); + assert!(matches!(decode_option(&[6, 4, 8, 8, 8, 8]), Err(CustomErr::InvalidHlen))); +} + +/// RFC 2131 §3.5 + RFC 2132 §9.10: Client Identifier (61) SHOULD be used for +/// identity when present; this stack only keys on `chaddr`. Lock that a +/// client-id option is preserved on the wire but never consulted. +#[test] +fn std_client_identifier_ignored_for_identity_quirk() { + // wire: client-id present alongside chaddr + let p = Packet { + reply: false, hops: 0, xid: 0x88, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xAA; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::Unrecognized(RawDhcpOption { code: 61, data: vec![1, 2, 3, 4] }), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + // preserved transparently ... + assert!(q.options.iter().any(|o| o.code() == 61)); + // ... but identity is still chaddr (option() lookup is by code; there is + // no typed ClientId branch, so callers can only see the raw bytes) + assert_eq!(q.chaddr, [0xAA; 6]); + assert!(matches!(q.option(61), Some(DhcpOption::Unrecognized(_)))); +} + +// =========================================================================== +// B. RFC 2131 Table 1 / §3.1 client packet-shape rules per state +// =========================================================================== + +/// RFC 2131 Table 1, DHCPDISCOVER: Server Identifier MUST NOT, Requested IP +/// MUST NOT appear. Library does not enforce (correct: enforcement is the +/// app's job) but it MUST be able to express the compliant shape. +#[test] +fn std_discover_shape_no_serverid_no_requestedip() { + let p = client_packet(0xA1, [1; 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::ParameterRequestList(vec![1, 3, 6]), + ]); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); + assert!(p.option(SERVER_IDENTIFIER).is_none()); + assert!(p.option(REQUESTED_IP_ADDRESS).is_none()); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert!(q.option(SERVER_IDENTIFIER).is_none()); + assert!(q.option(REQUESTED_IP_ADDRESS).is_none()); +} + +/// RFC 2131 Table 1, REQUEST SELECTING (after Offer): MUST carry Server ID + +/// Requested IP, MUST NOT carry ciaddr. +#[test] +fn std_request_selecting_shape() { + let p = Packet { + reply: false, hops: 0, xid: 0xA2, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [2; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70)), + ], + }; + assert_eq!(p.ciaddr, Ipv4Addr::UNSPECIFIED); + assert!(p.option(SERVER_IDENTIFIER).is_some()); + assert!(p.option(REQUESTED_IP_ADDRESS).is_some()); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.option(SERVER_IDENTIFIER), Some(&DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)))); +} + +/// RFC 2131 Table 1, REQUEST INIT-REBOOT: Requested IP present, Server ID +/// absent, ciaddr zero (client has no address yet). +#[test] +fn std_request_init_reboot_shape() { + let p = Packet { + reply: false, hops: 0, xid: 0xA3, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [3; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 80)), + ], + }; + assert!(p.option(SERVER_IDENTIFIER).is_none()); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert!(q.option(SERVER_IDENTIFIER).is_none()); + assert_eq!( + q.option(REQUESTED_IP_ADDRESS), + Some(&DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 80))) + ); +} + +/// RFC 2131 Table 1, REQUEST RENEWING (T1 unicast): ciaddr set, NO Server ID, +/// NO Requested IP. +#[test] +fn std_request_renewing_shape() { + let p = Packet { + reply: false, hops: 0, xid: 0xA4, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 50), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [4; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + assert_eq!(p.ciaddr, Ipv4Addr::new(192, 168, 1, 50)); + assert!(p.option(SERVER_IDENTIFIER).is_none()); + assert!(p.option(REQUESTED_IP_ADDRESS).is_none()); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.ciaddr, Ipv4Addr::new(192, 168, 1, 50)); +} + +/// RFC 2131 Table 1, DHCPINFORM: client already has an address (ciaddr set), +/// asks only for config; MUST NOT carry Requested IP. +#[test] +fn std_inform_shape_ciaddr_no_requestedip() { + let p = Packet { + reply: false, hops: 0, xid: 0xA5, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 60), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [5; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Inform), + DhcpOption::ParameterRequestList(vec![1, 15]), + ], + }; + assert_eq!(p.message_type(), Ok(MessageType::Inform)); + assert!(p.option(REQUESTED_IP_ADDRESS).is_none()); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.message_type(), Ok(MessageType::Inform)); +} + +/// RFC 2131 Table 1, DECLINE: Server ID + Requested IP; RELEASE: Server ID + +/// ciaddr. Both must survive encode/decode. +#[test] +fn std_decline_release_shapes() { + let decline = Packet { + reply: false, hops: 0, xid: 0xA6, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [6; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Decline), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70)), + ], + }; + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&decline.encode(&mut buf).to_vec())); + assert_eq!(q.message_type(), Ok(MessageType::Decline)); + + let release = Packet { + reply: false, hops: 0, xid: 0xA7, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 70), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [6; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + ], + }; + let q = unwrap_packet(Packet::from(&release.encode(&mut buf).to_vec())); + assert_eq!(q.message_type(), Ok(MessageType::Release)); + assert_eq!(q.ciaddr, Ipv4Addr::new(192, 168, 1, 70)); +} + +// =========================================================================== +// C. Relay, secs, hops accuracy +// =========================================================================== + +/// RFC 2131 §3.3: relay sets `giaddr` to its own address and bumps `hops`. +/// Decoder MUST preserve both; server reply resets `hops` and echoes `giaddr`. +#[test] +fn std_relay_fields_preserved_and_echoed() { + let raw = make_raw( + 1, 6, 2, 0xBEEF, 7, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [10, 9, 9, 9], + [8; 6], 0, 0, vec![53, 1, 1, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.hops, 2); + assert_eq!(p.giaddr, Ipv4Addr::new(10, 9, 9, 9)); + assert_eq!(p.secs, 7); + let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(10, 9, 9, 50), p); + // hmm: reply_once consumes a Packet struct, not raw; rebuild relayed struct + let _ = rep; + // struct-level relay echo (no network needed for the rule itself) + let relayed = Packet { + reply: false, hops: 2, xid: 0xBEEF, secs: 7, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::new(10, 9, 9, 9), + chaddr: [8; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let rep2 = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(10, 9, 9, 50), relayed); + assert_eq!(rep2.hops, 0, "server MUST reset hops"); + assert_eq!(rep2.giaddr, Ipv4Addr::new(10, 9, 9, 9), "server MUST echo giaddr"); +} + +/// RFC 2131 §4.1 deviation locked: `Server::send` chooses destination from +/// `broadcast`/`src` only — `giaddr` is echoed but NEVER used for routing. +/// A relayed request is still answered at the peer socket, not at `giaddr`. +#[test] +fn std_giaddr_never_selects_destination_quirk() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + assert_eq!(p.giaddr, Ipv4Addr::new(10, 99, 99, 99)); + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(10, 99, 99, 50), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let req = Packet { + reply: false, hops: 1, xid: 0xC1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::new(10, 99, 99, 99), + chaddr: [9; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let mut buf = [0u8; 1500]; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut rbuf = [0u8; 1500]; + // arrives back at the peer socket even though giaddr points elsewhere + let (n, _) = client.recv_from(&mut rbuf).expect("reply must arrive at src, not giaddr"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.giaddr, Ipv4Addr::new(10, 99, 99, 99)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(10, 99, 99, 50)); +} + +/// RFC 2131 §4.2: `secs` counts seconds since the client began acquiring; +/// it is filled by the client (preserved on decode) and zeroed by the server. +#[test] +fn std_secs_client_fills_server_zeroes() { + let raw = make_raw( + 1, 6, 0, 0xC2, 30, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1; 6], 0, 0, vec![53, 1, 1, 255], + ); + assert_eq!(unwrap_packet(Packet::from(&raw)).secs, 30); + let req = Packet { + reply: false, hops: 0, xid: 0xC2, secs: 30, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [1; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 90), req); + assert_eq!(rep.secs, 0); +} + +// =========================================================================== +// D. Lease-time accuracy (RFC 2131 §3.5, RFC 2132 §9.10) +// =========================================================================== + +/// RFC 2132 §9.10: lease time is uint32 seconds, big-endian; 0xFFFFFFFF = +/// infinite. Must round-trip through packets exactly. +#[test] +fn std_lease_time_edge_values_roundtrip() { + for secs in [0u32, 1, 60, 3600, 86400, 0x7FFFFFFF, u32::MAX] { + let p = Packet { + reply: true, hops: 0, xid: 0xD1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::new(192, 168, 1, 91), + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [2; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::IpAddressLeaseTime(secs), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!( + q.option(IP_ADDRESS_LEASE_TIME), + Some(&DhcpOption::IpAddressLeaseTime(secs)), + "lease {}s must survive", + secs + ); + // wire is exactly big-endian uint32 + let raw = DhcpOption::IpAddressLeaseTime(secs).to_raw(); + assert_eq!(raw.data, secs.to_be_bytes().to_vec()); + } +} + +/// RFC 2132 §9.11/§9.12: T1 (renewal) / T2 (rebinding) are uint32 seconds. +/// This stack has no typed variant, so they MUST survive as `Unrecognized` +/// with 4-byte big-endian payloads (client/server still interoperate). +#[test] +fn std_t1_t2_passthrough_with_be_seconds() { + let t1: u32 = 1800; + let t2: u32 = 3150; + let p = Packet { + reply: true, hops: 0, xid: 0xD2, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::new(192, 168, 1, 92), + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [3; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::Unrecognized(RawDhcpOption { code: RENEWAL_TIME_VALUE, data: t1.to_be_bytes().to_vec() }), + DhcpOption::Unrecognized(RawDhcpOption { code: REBINDING_TIME_VALUE, data: t2.to_be_bytes().to_vec() }), + ], + }; + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!( + q.option(RENEWAL_TIME_VALUE), + Some(&DhcpOption::Unrecognized(RawDhcpOption { code: 58, data: t1.to_be_bytes().to_vec() })) + ); + assert_eq!( + q.option(REBINDING_TIME_VALUE), + Some(&DhcpOption::Unrecognized(RawDhcpOption { code: 59, data: t2.to_be_bytes().to_vec() })) + ); +} + +/// RFC 2131 §3.5: `yiaddr` 0.0.0.0 in a NAK means "no address granted". +#[test] +fn std_nak_yiaddr_zero_means_no_grant() { + let req = Packet { + reply: false, hops: 0, xid: 0xD3, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [4; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + let rep = reply_once(MessageType::Nak, vec![], Ipv4Addr::UNSPECIFIED, req); + assert_eq!(rep.message_type(), Ok(MessageType::Nak)); + assert_eq!(rep.yiaddr, Ipv4Addr::UNSPECIFIED); +} + +// =========================================================================== +// E. sname/file split + Overload limitation (RFC 2131 §2, RFC 2132 §9.3) +// =========================================================================== + +/// RFC 951 §3: `chaddr` field is 16 bytes (6 + 10 pad), `sname` 64, `file` +/// 128. Encoder MUST zero all 202 bytes when unused. +#[test] +fn std_chaddr_sname_file_zero_split() { + let p = client_packet(0xE1, [0xAA; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let mut buf = [0xFFu8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(&enc[28..34], &[0xAA; 6]); // chaddr bytes + assert!(enc[34..44].iter().all(|&b| b == 0)); // chaddr pad (10) + assert!(enc[44..108].iter().all(|&b| b == 0)); // sname (64) + assert!(enc[108..236].iter().all(|&b| b == 0)); // file (128) +} + +/// Decoder ignores all three regions except the first 6 `chaddr` bytes. +#[test] +fn std_sname_file_regions_ignored_independently() { + let raw = make_raw( + 1, 6, 0, 0xE2, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [5; 6], 0x41, 0x42, vec![53, 1, 1, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.chaddr, [5; 6]); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); +} + +/// RFC 2132 §9.3 deviation: Overload (52) is decoded as opaque data and the +/// `sname`/`file` regions are NEVER scanned for options. A uniform TLV +/// hidden in `sname` must not appear in `packet.options`. +#[test] +fn std_overload_does_not_trigger_sname_parsing_quirk() { + // overload=1 ("file holds options") + a well-formed option smuggled + // into the sname region (bytes 44..): it must be ignored. + let mut raw = make_raw( + 1, 6, 0, 0xE3, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [6; 6], 0, 0, vec![52, 1, 1, 53, 1, 2, 255], + ); + // smuggle [1,4,255,255,255,0] (subnet) into sname at offset 44 + raw[44] = 1; + raw[45] = 4; + raw[46..50].copy_from_slice(&[255, 255, 255, 0]); + let p = unwrap_packet(Packet::from(&raw)); + // only the real options area counts: overload + msgtype + let codes: Vec = p.options.iter().map(|o| o.code()).collect(); + assert_eq!(codes, vec![52, 53]); + assert!(p.option(SUBNET_MASK).is_none(), "sname TLV must be ignored"); +} + +// =========================================================================== +// F. BOOTP compatibility edges: sizes, chaddr field, xid randomness shape +// =========================================================================== + +/// Fixed header alone (236 bytes, no cookie/options) is rejected; header + +/// cookie with only END is the smallest decodable datagram. +#[test] +fn std_minimum_decodable_sizes() { + // all-zero 236 bytes fails hlen first (hlen=0), not the cookie check + assert!(matches!(Packet::from(&[0u8; 236]), Err(CustomErr::InvalidHlen))); + // valid header (op=1, htype=1, hlen=6) but no cookie -> NomError(Tag) + let mut no_cookie = vec![0u8; 236]; + no_cookie[0] = 1; + no_cookie[1] = 1; + no_cookie[2] = 6; + assert!(matches!(Packet::from(&no_cookie), Err(CustomErr::NomError(_)))); + let mut smallest = vec![0u8; 236]; + smallest[0] = 1; + smallest[1] = 1; + smallest[2] = 6; + smallest.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]); + assert!(Packet::from(&smallest).is_ok()); + assert!(matches!(Packet::from(&[0u8; 10]), Err(CustomErr::InvalidHlen))); +} + +/// RFC 951: `chaddr` on the wire is 16 bytes; only the first `hlen` (6) are +/// significant. Bytes 34..44 MUST be zero and MUST NOT leak into `chaddr`. +#[test] +fn std_chaddr_field_is_16_bytes_first_6_significant() { + let mut raw = simple_raw(vec![53, 1, 1, 255]); + raw[28..34].copy_from_slice(&[10, 20, 30, 40, 50, 60]); + raw[34..44].copy_from_slice(&[99; 10]); // non-zero pad must be ignored + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.chaddr, [10, 20, 30, 40, 50, 60]); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc[34..44].iter().all(|&b| b == 0), "encoder must re-zero pad"); +} + +/// RFC 2131 §4.2: `xid` is a random transaction id chosen by the client and +/// echoed by the server; distinct requests SHOULD use distinct ids (shape +/// test: 0, 1, MAX all survive the round-trip). +#[test] +fn std_xid_uniqueness_shape() { + for xid in [0u32, 1, 0x12345678, u32::MAX] { + let p = client_packet(xid, [7; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.xid, xid); + let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 93), p); + assert_eq!(rep.xid, xid, "server MUST echo xid"); + } +} + +/// RFC 2132 §2: option data length is one byte, so no single option payload +/// may exceed 255 bytes. `to_raw` + `encode` MUST NOT emit a truncated length +/// prefix (lengths used here stay small and exact; 100-byte payloads fit in +/// `to_raw` but would overflow the 300-byte packet, so the Packet-level +/// check uses 20 bytes which fits: 240 + 3 + 22 + 1 = 266). +#[test] +fn std_option_len_single_byte_exact() { + let big = vec![9u8; 100]; + let raw = DhcpOption::Unrecognized(RawDhcpOption { code: 200, data: big.clone() }).to_raw(); + assert_eq!(raw.data.len(), 100); + let data = vec![9u8; 20]; + let p = Packet { + reply: false, hops: 0, xid: 0xF1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::Unrecognized(RawDhcpOption { code: 200, data }), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + // second option header at 243: code 200, len 20 + assert_eq!(enc[243], 200); + assert_eq!(enc[244], 20); +} + +/// RFC 2131 §3.1: a subnet mask, when present, SHOULD be a contiguous mask. +/// The stack performs no validation (correct layering) and MUST preserve any +/// 4-byte value untouched. +#[test] +fn std_subnet_mask_not_validated() { + for mask in [Ipv4Addr::new(255, 255, 255, 0), Ipv4Addr::new(1, 2, 3, 4)] { + let p = Packet { + reply: true, hops: 0, xid: 0xF2, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::SubnetMask(mask), + ], + }; + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.option(SUBNET_MASK), Some(&DhcpOption::SubnetMask(mask))); + } +} diff --git a/tests/test_dhcp_tables.rs b/tests/test_dhcp_tables.rs new file mode 100644 index 0000000..c84db4a --- /dev/null +++ b/tests/test_dhcp_tables.rs @@ -0,0 +1,636 @@ +//! DHCP message-table + validation accuracy suite, part 3. +//! +//! Covers RFC 2131 / RFC 2132 / RFC 951 areas NOT covered by +//! `test_dhcp_rfc.rs` / `test_dhcp_standards.rs`: +//! +//! - RFC 2131 Table 1 (client) + Table 3 (server) address/option matrices +//! - RFC 2132 option-length validation (exact vs short vs long) deviations +//! - RFC 2131 §4.1 destination selection + port documentation +//! - RFC 951/2131 size constants (236 header + 64 vend = 300, chaddr/sname/file split) +//! - RFC 2131 §2 flags MBZ handling +//! - RFC 1918 + subnet self-consistency of the example network +//! - PRL required-option guarantee (53+54 always survive filtering) +//! +//! `*_quirk` tests lock current deviations so they cannot change silently. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn client_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, options: opts, + } +} + +fn reply_once(msg: MessageType, additional: Vec, offer: Ipv4Addr, req: Packet) -> Packet { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H { + msg: MessageType, + add: Option>, + offer: Ipv4Addr, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let add = self.add.take().unwrap(); + let _ = s.reply(self.msg, add, self.offer, p); + } + } + let h = H { msg, add: Some(additional), offer }; + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), h, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("no reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +fn roundtrip(p: Packet) -> Packet { + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + unwrap_packet(Packet::from(&enc)) +} + +// =========================================================================== +// A. RFC 2131 Table 1 — client messages: address + option presence matrix +// =========================================================================== + +/// RFC 2131 Table 1 row: DHCPDISCOVER — ciaddr 0, no Requested IP, no Server +/// ID, PRL SHOULD be present. yiaddr/siaddr MUST be 0. +#[test] +fn tbl1_discover_full_matrix() { + let p = Packet { + reply: false, hops: 0, xid: 0xD001, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x11; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::ParameterRequestList(vec![1, 3, 6, 51]), + ], + }; + assert_eq!(p.ciaddr, Ipv4Addr::UNSPECIFIED, "Table 1: Discover ciaddr=0"); + assert_eq!(p.yiaddr, Ipv4Addr::UNSPECIFIED); + assert_eq!(p.siaddr, Ipv4Addr::UNSPECIFIED); + assert!(p.option(SERVER_IDENTIFIER).is_none(), "Discover: no Server ID"); + assert!(p.option(REQUESTED_IP_ADDRESS).is_none(), "Discover: no Requested IP"); + assert!(p.option(PARAMETER_REQUEST_LIST).is_some(), "Discover: PRL expected"); + let q = roundtrip(p); + assert_eq!(q.message_type(), Ok(MessageType::Discover)); + assert!(q.option(SERVER_IDENTIFIER).is_none()); +} + +/// RFC 2131 Table 1 row: DHCPREQUEST SELECTING — ciaddr 0, Requested IP + +/// Server ID present (selects among Offers), PRL present. +#[test] +fn tbl1_request_selecting_full_matrix() { + let p = Packet { + reply: false, hops: 0, xid: 0xD002, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x22; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70)), + DhcpOption::ParameterRequestList(vec![1, 3]), + ], + }; + assert_eq!(p.ciaddr, Ipv4Addr::UNSPECIFIED); + assert!(p.option(SERVER_IDENTIFIER).is_some()); + assert!(p.option(REQUESTED_IP_ADDRESS).is_some()); + let q = roundtrip(p); + assert_eq!( + q.option(SERVER_IDENTIFIER), + Some(&DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1))) + ); + assert_eq!( + q.option(REQUESTED_IP_ADDRESS), + Some(&DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70))) + ); +} + +/// RFC 2131 Table 1 row: DHCPREQUEST INIT-REBOOT — ciaddr 0, Requested IP +/// present (remembered address), Server ID absent. +#[test] +fn tbl1_request_init_reboot_full_matrix() { + let p = Packet { + reply: false, hops: 0, xid: 0xD003, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x33; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 80)), + ], + }; + assert!(p.option(SERVER_IDENTIFIER).is_none(), "INIT-REBOOT: no Server ID"); + assert!(p.option(REQUESTED_IP_ADDRESS).is_some()); + let q = roundtrip(p); + assert!(q.option(SERVER_IDENTIFIER).is_none()); +} + +/// RFC 2131 Table 1 rows: RENEWING (unicast, ciaddr set, no options 50/54) +/// and REBINDING (broadcast, ciaddr set). Only the ciaddr/broadcast differ. +#[test] +fn tbl1_request_renewing_rebinding_matrix() { + for (xid, broadcast) in [(0xD004u32, false), (0xD005u32, true)] { + let p = Packet { + reply: false, hops: 0, xid, secs: 0, broadcast, + ciaddr: Ipv4Addr::new(192, 168, 1, 50), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x44; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + assert!(p.option(SERVER_IDENTIFIER).is_none(), "RENEW/REBIND: no Server ID"); + assert!(p.option(REQUESTED_IP_ADDRESS).is_none(), "RENEW/REBIND: no Requested IP"); + // broadcast struct encodes [128,0] which decodes false (quirk), so + // only verify the unicast member round-trips the flag; the broadcast + // member is verified at the wire level elsewhere. + if !broadcast { + let q = roundtrip(p); + assert_eq!(q.ciaddr, Ipv4Addr::new(192, 168, 1, 50)); + assert!(!q.broadcast); + } + } +} + +/// RFC 2131 Table 1 rows: DECLINE (Server ID + Requested IP, ciaddr 0), +/// RELEASE (Server ID + ciaddr set, no Requested IP), INFORM (ciaddr set, +/// PRL present, no Requested IP). +#[test] +fn tbl1_decline_release_inform_matrix() { + let decline = Packet { + reply: false, hops: 0, xid: 0xD006, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x55; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Decline), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70)), + ], + }; + assert_eq!(decline.ciaddr, Ipv4Addr::UNSPECIFIED); + assert!(decline.option(SERVER_IDENTIFIER).is_some()); + assert!(decline.option(REQUESTED_IP_ADDRESS).is_some()); + + let release = Packet { + reply: false, hops: 0, xid: 0xD007, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 70), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x55; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + ], + }; + assert!(release.option(REQUESTED_IP_ADDRESS).is_none(), "RELEASE: no Requested IP"); + assert_eq!(release.ciaddr, Ipv4Addr::new(192, 168, 1, 70)); + + let inform = Packet { + reply: false, hops: 0, xid: 0xD008, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 60), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x66; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Inform), + DhcpOption::ParameterRequestList(vec![1, 15]), + ], + }; + assert!(inform.option(REQUESTED_IP_ADDRESS).is_none(), "INFORM: no Requested IP"); + for p in [decline, release, inform] { + let mt = p.message_type().unwrap(); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.message_type(), Ok(mt)); + } +} + +// =========================================================================== +// B. RFC 2131 Table 3 — server messages: required fields +// =========================================================================== + +/// RFC 2131 Table 3 row: DHCPOFFER — BOOTREPLY, yiaddr set (offered), +/// Server ID present, Subnet/Lease SHOULD be present when configured. +#[test] +fn tbl3_offer_required_fields() { + let req = client_packet(0xE001, [0x11; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let rep = reply_once( + MessageType::Offer, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(86400), + ], + Ipv4Addr::new(192, 168, 1, 100), + req, + ); + assert!(rep.reply, "Table 3: Offer is BOOTREPLY"); + assert_ne!(rep.yiaddr, Ipv4Addr::UNSPECIFIED, "Offer MUST set yiaddr"); + assert!(rep.option(SERVER_IDENTIFIER).is_some(), "Offer MUST carry Server ID"); + assert!(rep.option(SUBNET_MASK).is_some()); + assert!(rep.option(IP_ADDRESS_LEASE_TIME).is_some()); +} + +/// RFC 2131 Table 3 row: DHCPACK — BOOTREPLY, yiaddr committed, Server ID + +/// Lease Time present; ciaddr echoed when the request carried one. +#[test] +fn tbl3_ack_required_fields_and_ciaddr_echo() { + let mut req = client_packet(0xE002, [0x22; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Request, + )]); + req.ciaddr = Ipv4Addr::new(192, 168, 1, 50); + let rep = reply_once( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(3600)], + Ipv4Addr::new(192, 168, 1, 50), + req, + ); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 1, 50)); + assert_eq!(rep.ciaddr, Ipv4Addr::new(192, 168, 1, 50), "Ack echoes RENEW ciaddr"); + assert!(rep.option(IP_ADDRESS_LEASE_TIME).is_some()); +} + +/// RFC 2131 Table 3 row: DHCPNAK — BOOTREPLY, yiaddr MUST be 0, ciaddr MUST +/// be 0, SHOULD carry Message (56) explaining the failure. +#[test] +fn tbl3_nak_required_fields_and_message() { + let req = client_packet(0xE003, [0x33; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Request, + )]); + let rep = reply_once( + MessageType::Nak, + vec![DhcpOption::Message("not available".to_string())], + Ipv4Addr::UNSPECIFIED, + req, + ); + assert!(rep.reply); + assert_eq!(rep.yiaddr, Ipv4Addr::UNSPECIFIED, "NAK yiaddr MUST be 0"); + assert_eq!(rep.ciaddr, Ipv4Addr::UNSPECIFIED, "NAK ciaddr MUST be 0"); + assert_eq!( + rep.option(MESSAGE), + Some(&DhcpOption::Message("not available".to_string())), + "NAK SHOULD carry Message" + ); +} + +// =========================================================================== +// C. RFC 2132 option-length validation deviations +// =========================================================================== + +/// RFC 2132: Server ID (54) / Requested IP (50) / Subnet (1) MUST be 4 bytes. +/// Current behavior: short (<4) rejected, long (>4) silently truncated to the +/// first 4 bytes. Locked so a strict validator cannot break captures. +#[test] +fn optlen_ipv4_short_rejected_long_truncated_quirk() { + for code in [SERVER_IDENTIFIER, REQUESTED_IP_ADDRESS, SUBNET_MASK] { + assert!( + matches!(decode_option(&[code, 3, 1, 2, 3]), Err(CustomErr::InvalidHlen)), + "code {} len 3 must fail", + code + ); + match decode_option(&[code, 5, 192, 168, 1, 1, 99]) { + Ok((_, o)) => { + let ip = match o { + DhcpOption::ServerIdentifier(x) + | DhcpOption::RequestedIpAddress(x) + | DhcpOption::SubnetMask(x) => x, + _ => panic!("code {} wrong variant", code), + }; + assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 1), "code {} takes first 4", code); + } + Err(_) => panic!("code {} len 5 must succeed with truncation", code), + } + } +} + +/// RFC 2132 §9.10: Lease Time (51) MUST be 4 bytes uint32. Short rejected, +/// long truncated to first 4 BE bytes (same deviation as IPv4 options). +#[test] +fn optlen_lease_short_rejected_long_truncated_quirk() { + assert!(matches!(decode_option(&[51, 3, 0, 0, 1]), Err(CustomErr::InvalidHlen))); + match decode_option(&[51, 5, 0, 0, 0, 10, 99]) { + Ok((_, DhcpOption::IpAddressLeaseTime(t))) => assert_eq!(t, 10), + _ => panic!("lease len 5 must truncate to 10"), + } +} + +/// RFC 2132 §9.6: Message Type (53) MUST be length 1 with values 1-8. +/// Length-0 rejected as InvalidHlen (not UnrecognizedMessageType); length>1 +/// uses the first byte; unknown first byte is UnrecognizedMessageType. +#[test] +fn optlen_msgtype_len_rules() { + assert!(matches!(decode_option(&[53, 0]), Err(CustomErr::InvalidHlen))); + for bad in [0u8, 9, 255] { + assert!( + matches!(decode_option(&[53, 1, bad]), Err(CustomErr::UnrecognizedMessageType)), + "53 value {}", + bad + ); + } + match decode_option(&[53, 2, 3, 99]) { + Ok((_, DhcpOption::DhcpMessageType(t))) => assert_eq!(t, MessageType::Request), + _ => panic!("53 len 2 uses first byte"), + } +} + +/// RFC 2132: PRL (55) / HostName (12) / Message (56) accept any length incl. +/// 0; Unrecognized preserves any length incl. 0 exactly. +#[test] +fn optlen_variable_and_unknown_exact() { + match decode_option(&[55, 0]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert!(v.is_empty()), + _ => panic!("empty PRL"), + } + match decode_option(&[12, 0]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, ""), + _ => panic!("empty hostname"), + } + for (code, data) in [(200u8, vec![]), (200, vec![1]), (15u8, vec![9; 100])] { + let wire = [vec![code, data.len() as u8], data.clone()].concat(); + match decode_option(&wire) { + Ok((rest, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, code); + assert_eq!(r.data, data); + assert!(rest.is_empty()); + } + _ => panic!("code {} len {} must passthrough", code, data.len()), + } + } +} + +// =========================================================================== +// D. RFC 2131 §4.1 ports + destination selection documentation +// =========================================================================== + +/// RFC 2131 §4.1: DHCP uses UDP server port 67 / client port 68; client sends +/// to 255.255.255.255 (or relay 67), server unicasts or broadcasts. +/// Port binding itself is the app's job (`src/main.rs` binds +/// 0.0.0.0:67); here we lock that replies preserve the peer port and that +/// the broadcast path uses the configured broadcast IP. +#[test] +fn ports_unicast_preserves_peer_port_documented() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 10), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let expect_port = client.local_addr().unwrap().port(); + let mut buf = [0u8; 1500]; + let req = client_packet(0xF001, [1; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut rbuf = [0u8; 1500]; + let (_, src) = client.recv_from(&mut rbuf).expect("unicast reply"); + assert_eq!(src.port(), srv_addr.port(), "reply comes from server port"); + assert_ne!(expect_port, 0); +} + +/// RFC 2131 §4.1 + §3.3: destination ignores `yiaddr`/`giaddr`; only +/// `broadcast` (or all-zero src, untestable over loopback) selects the +/// broadcast IP. A relayed unicast request is still answered at the peer. +#[test] +fn destination_ignores_yiaddr_and_giaddr_quirk() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + // yiaddr/giaddr intentionally "wrong" to prove they are unused + assert_eq!(p.yiaddr, Ipv4Addr::new(9, 9, 9, 9)); + assert_eq!(p.giaddr, Ipv4Addr::new(10, 9, 9, 9)); + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(10, 9, 9, 50), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let req = Packet { + reply: false, hops: 1, xid: 0xF002, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::new(9, 9, 9, 9), + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::new(10, 9, 9, 9), + chaddr: [2; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let mut buf = [0u8; 1500]; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut rbuf = [0u8; 1500]; + let (n, _) = client.recv_from(&mut rbuf).expect("reply at peer, not giaddr/yiaddr"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.yiaddr, Ipv4Addr::new(10, 9, 9, 50)); +} + +// =========================================================================== +// E. RFC 951/2131 sizes: 236 header + 64 vend minimum = 300 on the wire, +// chaddr/sname/file split +// =========================================================================== + +/// RFC 951 §3: fixed header 236 = 28 (op..giaddr) + 16 (chaddr) + 64 (sname) +/// + 128 (file). Encoder MUST emit exactly this split zeroed when unused. +#[test] +fn sizes_header_split_28_16_64_128() { + let p = client_packet(0xF011, [0xCC; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let mut buf = [0xFFu8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() >= 241); + assert_eq!(&enc[28..34], &[0xCC; 6]); + assert!(enc[34..44].iter().all(|&b| b == 0), "chaddr[6..16]"); + assert!(enc[44..108].iter().all(|&b| b == 0), "sname[64]"); + assert!(enc[108..236].iter().all(|&b| b == 0), "file[128]"); + assert_eq!(236 - 28, 208); + assert_eq!(10 + 64 + 128, 202); + assert_eq!(208 - 6, 202); +} + +/// RFC 2131/951 conformance: the BOOTP/DHCP minimum on the wire is 300 +/// bytes. The implementation sizes the zero pad to 300 and returns only +/// `len` bytes (241..300): small packets carry the full 300-byte pad region +/// zeroed, so strict clients that require BOOTP-minimum framing accept them. +#[test] +fn sizes_minimum_300_bootp() { + let p = client_packet(0xF012, [0; 6], vec![]); + let mut buf = [0xFFu8; 1500]; + let len = p.encode(&mut buf).len(); + assert_eq!(len, 241, "empty options: 240 + 1 END"); + assert!(buf[len..300].iter().all(|&b| b == 0), "pad zeroed to 300"); + // even the largest encodable datagram fits a 1500 MTU + let many = vec![DhcpOption::Unrecognized(RawDhcpOption { code: 200, data: vec![7u8; 20] })]; + let big = Packet { + reply: true, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], options: many, + }; + let mut buf2 = [0u8; 1500]; + assert!(big.encode(&mut buf2).len() <= 300); +} + +// =========================================================================== +// F. RFC 2131 §2 flags: only top bit defined, rest MBZ +// =========================================================================== + +/// RFC 2131 §2: `flags` = 0x8000 broadcast + 15 MBZ low bits. The decoder only +/// tests bit 0x0080 of the assembled u16 (quirk), so all MBZ patterns except +/// low-byte 0x80 decode as unicast — including the RFC-correct 0x8000. +#[test] +fn flags_mbz_only_low_byte_0x80_matters_quirk() { + fn decode_flags(f: [u8; 2]) -> bool { + let mut raw = vec![0u8; 236]; + raw[0] = 1; + raw[1] = 1; + raw[2] = 6; + raw[10] = f[0]; + raw[11] = f[1]; + raw.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]); + unwrap_packet(Packet::from(&raw)).broadcast + } + assert!(!decode_flags([0, 0])); + assert!(!decode_flags([128, 0]), "RFC broadcast 0x8000 decodes false"); + assert!(decode_flags([0, 128])); + assert!(decode_flags([0, 129]), "low bit extras ignored"); + assert!(decode_flags([255, 128])); + assert!(!decode_flags([127, 127]), "0x7F7F has no 0x0080 bit"); + assert!(!decode_flags([1, 0]), "MBZ bit alone is unicast"); +} + +// =========================================================================== +// G. RFC 1918 + subnet self-consistency of the example /24 +// =========================================================================== + +/// Example network (examples/server.rs + README) MUST be self-consistent: +/// private 192.168.2.0/24, mask .0, router .1, server .1, pool .2-.253, +/// broadcast .255, public DNS. Locks the topology against typos. +#[test] +fn example_network_topology_self_consistent() { + let server = Ipv4Addr::new(192, 168, 2, 1); + let mask = Ipv4Addr::new(255, 255, 255, 0); + let router = Ipv4Addr::new(192, 168, 2, 1); + let broadcast = Ipv4Addr::new(192, 168, 2, 255); + let pool_first = Ipv4Addr::new(192, 168, 2, 2); + let pool_last = Ipv4Addr::new(192, 168, 2, 253); + let dns = Ipv4Addr::new(8, 8, 8, 8); + // RFC 1918 private + assert!(server.is_private() && pool_first.is_private()); + // subnet math: network = ip & mask + let net = u32::from(server) & u32::from(mask); + assert_eq!(Ipv4Addr::from(net), Ipv4Addr::new(192, 168, 2, 0)); + assert_eq!(u32::from(broadcast), net | !u32::from(mask)); + // router == server (example collocation), pool inside subnet, excludes + // network (.0), server (.1), broadcast (.255) + assert_eq!(router, server); + let s = u32::from(pool_first); + let e = u32::from(pool_last); + assert_eq!(e - s + 1, 252); + assert!(s > net + 1 && e < net | !u32::from(mask)); + // DNS is public (not private/loopback/link-local/unspecified) + assert!(!dns.is_private() && !dns.is_loopback() && !dns.is_unspecified()); + // pool extremes survive encode/decode as yiaddr + for ip in [pool_first, pool_last] { + let p = Packet { + reply: true, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: ip, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Offer)], + }; + let mut buf = [0u8; 1500]; + assert_eq!(unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())).yiaddr, ip); + } +} + +// =========================================================================== +// H. PRL required-option guarantee +// =========================================================================== + +/// Server replies MUST always carry Message Type (53) + Server ID (54), even +/// when the client PRL asks for unrelated/unknown codes. Unknown PRL codes +/// are ignored; defaults [53,54,1,51,6,3] fill the remainder in order. +#[test] +fn prl_always_keeps_msgtype_and_serverid() { + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::IpAddressLeaseTime(100), + ]; + server::filter_options_by_req(&mut opts, &[200, 201]); + let codes: Vec = opts.iter().map(|o| o.code()).collect(); + assert!(codes.contains(&53) && codes.contains(&54)); + assert_eq!(codes, vec![53, 54, 1, 51]); +} + +/// Live PRL proof: client asks only for Domain (15, unconfigured) — reply +/// still carries 53+54 plus configured defaults, and never the unconfigured +/// Domain option. +#[test] +fn prl_live_required_options_survive() { + let req = Packet { + reply: false, hops: 0, xid: 0xF021, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xEE; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::ParameterRequestList(vec![15]), + ], + }; + let rep = reply_once( + MessageType::Ack, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(3600), + ], + Ipv4Addr::new(192, 168, 1, 82), + req, + ); + let codes: Vec = rep.options.iter().map(|o| o.code()).collect(); + assert!(codes.contains(&53) && codes.contains(&54)); + assert!(!codes.contains(&15), "unconfigured Domain MUST NOT appear"); +} diff --git a/tests/test_dhcpv6.rs b/tests/test_dhcpv6.rs new file mode 100644 index 0000000..565af7b --- /dev/null +++ b/tests/test_dhcpv6.rs @@ -0,0 +1,649 @@ +//! Tests for `dhcp4r::dhcpv6` (RFC 8415 codec + managed range allocator). +//! +//! Locks the managed DHCPv6 range behavior: message/option wire format, +//! strict all-or-nothing decoding (no v4-style swallow quirk by design), +//! and pool allocation mirroring the v4 example — except infinite leases +//! are NOT stealable here (deliberate difference, see below). +//! +//! Pure codec + allocator tests only (no sockets: loopback IPv6 availability +//! varies by platform, so nothing here binds). + +use dhcp4r::dhcpv6::*; +use std::net::Ipv6Addr; +use std::time::{Duration, Instant}; + +fn duid(b: &[u8]) -> Vec { + b.to_vec() +} + +fn v6(a: [u16; 8]) -> Ipv6Addr { + Ipv6Addr::new(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]) +} + +// --------------------------------------------------------------------------- +// message types +// --------------------------------------------------------------------------- + +#[test] +fn msgtype_values_match_rfc8415() { + assert_eq!(MsgType::Solicit as u8, 1); + assert_eq!(MsgType::Advertise as u8, 2); + assert_eq!(MsgType::Request as u8, 3); + assert_eq!(MsgType::Confirm as u8, 4); + assert_eq!(MsgType::Renew as u8, 5); + assert_eq!(MsgType::Rebind as u8, 6); + assert_eq!(MsgType::Reply as u8, 7); + assert_eq!(MsgType::Release as u8, 8); + assert_eq!(MsgType::Decline as u8, 9); + assert_eq!(MsgType::Reconfigure as u8, 10); + assert_eq!(MsgType::InformationRequest as u8, 11); + assert_eq!(MsgType::RelayForward as u8, 12); + assert_eq!(MsgType::RelayReply as u8, 13); + for v in 1u8..=13 { + assert!(MsgType::from(v).is_ok(), "{}", v); + } +} + +#[test] +fn msgtype_from_invalid_is_err() { + for v in [0u8, 14, 255] { + assert_eq!(MsgType::from(v), Err(DecodeError::BadMessageType(v))); + } +} + +// --------------------------------------------------------------------------- +// transaction id: 24-bit big-endian, masked on encode +// --------------------------------------------------------------------------- + +#[test] +fn transaction_id_three_bytes_be() { + let p = Packet { + msg_type: MsgType::Solicit, + transaction_id: 0x12_34_56, + options: vec![], + }; + assert_eq!(&p.encode().unwrap()[..4], &[1, 0x12, 0x34, 0x56]); + assert_eq!(Packet::from(&[7, 0x12, 0x34, 0x56]).unwrap().transaction_id, 0x12_34_56); +} + +#[test] +fn transaction_id_top_byte_masked_on_encode() { + let p = Packet { + msg_type: MsgType::Solicit, + transaction_id: 0xFF_12_34_56, + options: vec![], + }; + let enc = p.encode().unwrap(); + assert_eq!(&enc[1..4], &[0x12, 0x34, 0x56]); + assert_eq!(Packet::from(&enc).unwrap().transaction_id, 0x12_34_56); +} + +// --------------------------------------------------------------------------- +// option encode lengths +// --------------------------------------------------------------------------- + +#[test] +fn option_ids_match_rfc8415() { + assert_eq!(Dhcpv6Option::ClientId(vec![]).code(), 1); + assert_eq!(Dhcpv6Option::ServerId(vec![]).code(), 2); + assert_eq!( + Dhcpv6Option::IaNa(IaNa { iaid: 0, t1: 0, t2: 0, addrs: vec![] }).code(), + 3 + ); + assert_eq!(Dhcpv6Option::DnsServers(vec![]).code(), 23); + assert_eq!(Dhcpv6Option::StatusCode(0, String::new()).code(), 13); + assert_eq!( + Dhcpv6Option::Unrecognized(RawDhcpv6Option { code: 99, data: vec![] }).code(), + 99 + ); +} + +#[test] +fn duid_options_opaque_including_empty() { + let raw = Dhcpv6Option::ClientId(duid(&[1, 2, 3])).to_raw(); + assert_eq!((raw.code, raw.data), (1, vec![1, 2, 3])); + let raw = Dhcpv6Option::ServerId(vec![]).to_raw(); + assert_eq!((raw.code, raw.data), (2, vec![])); +} + +#[test] +fn iana_encodes_iaid_t1_t2_then_iaaddr_suboptions() { + let raw = Dhcpv6Option::IaNa(IaNa { + iaid: 0x01020304, + t1: 100, + t2: 200, + addrs: vec![ + IaAddr { addr: v6([0xfd00, 0, 0, 0, 0, 0, 0, 1]), preferred: 300, valid: 400 }, + IaAddr { addr: v6([0xfd00, 0, 0, 0, 0, 0, 0, 2]), preferred: 301, valid: 401 }, + ], + }) + .to_raw(); + assert_eq!(raw.code, 3); + // 12-byte header + 2 sub-options of 28 data bytes each + assert_eq!(raw.data.len(), 12 + 2 * 28); + assert_eq!(&raw.data[..12], &[1, 2, 3, 4, 0, 0, 0, 100, 0, 0, 0, 200]); + // first sub-option: code 5, len 24, addr, preferred, valid + assert_eq!(&raw.data[12..16], &[0, 5, 0, 24]); + assert_eq!(&raw.data[16..32], &v6([0xfd00, 0, 0, 0, 0, 0, 0, 1]).octets()); + assert_eq!(&raw.data[32..36], &300u32.to_be_bytes()); + assert_eq!(&raw.data[36..40], &400u32.to_be_bytes()); + // second sub-option header sits right after the first 24 data bytes + assert_eq!(&raw.data[40..44], &[0, 5, 0, 24]); + // empty IA_NA is just the 12-byte header + let raw = Dhcpv6Option::IaNa(IaNa { iaid: 7, t1: 0, t2: 0, addrs: vec![] }).to_raw(); + assert_eq!(raw.data.len(), 12); +} + +#[test] +fn dns_servers_concatenates_16_byte_addrs() { + assert!(Dhcpv6Option::DnsServers(vec![]).to_raw().data.is_empty()); + let raw = Dhcpv6Option::DnsServers(vec![ + v6([0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888]), + v6([0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8844]), + ]) + .to_raw(); + assert_eq!(raw.code, 23); + assert_eq!(raw.data.len(), 32); +} + +#[test] +fn status_code_is_be_code_plus_text() { + let raw = Dhcpv6Option::StatusCode(2, "NoAddrsAvail".to_string()).to_raw(); + assert_eq!(raw.code, 13); + assert_eq!(&raw.data[..2], &[0, 2]); + assert_eq!(&raw.data[2..], b"NoAddrsAvail"); + let raw = Dhcpv6Option::StatusCode(0, String::new()).to_raw(); + assert_eq!(raw.code, 13); + assert_eq!(raw.data, vec![0, 0]); +} + +#[test] +fn unrecognized_round_trips_code_and_data() { + let inner = RawDhcpv6Option { code: 60000, data: vec![9, 8, 7] }; + assert_eq!(Dhcpv6Option::Unrecognized(inner.clone()).to_raw(), inner); +} + +#[test] +fn title_known_and_unknown() { + assert_eq!(title(1), Some("Client Identifier")); + assert_eq!(title(3), Some("Identity Association for Non-temporary Addresses")); + assert_eq!(title(13), Some("Status Code")); + assert_eq!(title(23), Some("DNS Recursive Name Server")); + assert_eq!(title(0), None); + assert_eq!(title(255), None); + assert_eq!(title(60000), None); +} + +// --------------------------------------------------------------------------- +// decode errors: strict, whole-packet rejection +// --------------------------------------------------------------------------- + +#[test] +fn decode_rejects_truncated_header() { + for len in 0..4 { + let buf = vec![7u8; len]; + assert_eq!(Packet::from(&buf), Err(DecodeError::Truncated), "len {}", len); + } +} + +#[test] +fn decode_rejects_bad_msgtype() { + assert_eq!(Packet::from(&[0, 0, 0, 1]), Err(DecodeError::BadMessageType(0))); + assert_eq!(Packet::from(&[14, 0, 0, 1]), Err(DecodeError::BadMessageType(14))); +} + +#[test] +fn decode_rejects_truncated_option_header_and_data() { + // 3 trailing bytes: no full option header + assert_eq!( + Packet::from(&[1, 0, 0, 1, 53, 1, 2]), + Err(DecodeError::Truncated) + ); + // declared len overruns the datagram + assert_eq!( + Packet::from(&[1, 0, 0, 1, 0, 1, 0, 10, 1, 2]), + Err(DecodeError::Truncated) + ); + // direct: header needs 4 bytes + assert_eq!(decode_option(&[1, 2, 3]), Err(DecodeError::Truncated)); + assert_eq!(decode_option(&[]), Err(DecodeError::Truncated)); +} + +#[test] +fn decode_malformed_iana_variants() { + // shorter than IAID+T1+T2 + assert_eq!(decode_option(&[0, 3, 0, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), Err(DecodeError::Malformed)); + // trailing 1-3 bytes after header are not a sub-option + let mut wire = vec![0, 3, 0, 13]; + wire.extend_from_slice(&[0u8; 12]); + wire.push(0xAA); + assert_eq!(decode_option(&wire), Err(DecodeError::Malformed)); + // sub-option length overruns + let mut wire = vec![0, 3, 0, 14]; + wire.extend_from_slice(&[0u8; 12]); + wire.extend_from_slice(&[0, 5, 0, 28, 1, 2]); + assert_eq!(decode_option(&wire), Err(DecodeError::Malformed)); + // IAADDR body shorter than 24 + let mut wire = vec![0, 3, 0, 12 + 4 + 10]; + wire.extend_from_slice(&[0u8; 12]); + wire.extend_from_slice(&[0, 5, 0, 10]); + wire.extend_from_slice(&[0u8; 10]); + assert_eq!(decode_option(&wire), Err(DecodeError::Malformed)); +} + +#[test] +fn decode_malformed_dns_and_status() { + // DNS length not a multiple of 16 + assert_eq!(decode_option(&[0, 23, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), Err(DecodeError::Malformed)); + // status shorter than 2-byte code + assert_eq!(decode_option(&[0, 13, 0, 1, 0]), Err(DecodeError::Malformed)); + // status message not UTF-8 + assert_eq!( + decode_option(&[0, 13, 0, 4, 0, 2, 0xFF, 0xFE]), + Err(DecodeError::BadUtf8) + ); + // empty status message is fine + match decode_option(&[0, 13, 0, 2, 0, 0]) { + Ok((_, Dhcpv6Option::StatusCode(0, m))) => assert_eq!(m, ""), + _ => panic!("empty status message"), + } +} + +// --------------------------------------------------------------------------- +// packet round-trips (options run to end of datagram: no END byte) +// --------------------------------------------------------------------------- + +#[test] +fn packet_empty_options_roundtrip() { + let p = Packet { msg_type: MsgType::Solicit, transaction_id: 1, options: vec![] }; + let enc = p.encode().unwrap(); + assert_eq!(enc, vec![1, 0, 0, 1]); + assert_eq!(Packet::from(&enc).unwrap(), p); +} + +#[test] +fn packet_full_solicit_roundtrip_without_end_byte() { + let p = Packet { + msg_type: MsgType::Solicit, + transaction_id: 0xABCDEF, + options: vec![ + Dhcpv6Option::ClientId(duid(&[0, 3, 0, 1, 2, 0, 0x5e, 0xaa, 0xbb, 0xcc])), + Dhcpv6Option::IaNa(IaNa { + iaid: 0x0A0B0C0D, + t1: 0, + t2: 0, + addrs: vec![], + }), + Dhcpv6Option::DnsServers(vec![v6([0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888])]), + Dhcpv6Option::Unrecognized(RawDhcpv6Option { code: 999, data: vec![1, 2] }), + ], + }; + let enc = p.encode().unwrap(); + // no END marker anywhere: last bytes are the final option's data + assert_eq!(&enc[enc.len() - 2..], &[1, 2]); + assert_eq!(Packet::from(&enc).unwrap(), p); +} + +#[test] +fn packet_trailing_garbage_rejects_whole_packet() { + // valid option followed by 2 undecodable bytes: strict Err (by design, + // unlike the v4 swallow-and-desync behavior). + let mut enc = Packet { + msg_type: MsgType::Solicit, + transaction_id: 1, + options: vec![Dhcpv6Option::ClientId(duid(&[1]))], + } + .encode() + .unwrap(); + enc.extend_from_slice(&[9, 9]); + assert_eq!(Packet::from(&enc), Err(DecodeError::Truncated)); +} + +#[test] +fn packet_option_first_wins_and_iana_accessor() { + let p = Packet { + msg_type: MsgType::Request, + transaction_id: 2, + options: vec![ + Dhcpv6Option::IaNa(IaNa { iaid: 1, t1: 0, t2: 0, addrs: vec![] }), + Dhcpv6Option::IaNa(IaNa { iaid: 2, t1: 0, t2: 0, addrs: vec![] }), + ], + }; + assert_eq!(p.first_iana().unwrap().iaid, 1); + assert!(p.option(999).is_none()); + let bare = Packet { msg_type: MsgType::Solicit, transaction_id: 0, options: vec![] }; + assert!(bare.first_iana().is_none()); +} + +#[test] +fn encode_rejects_option_over_u16_max() { + let p = Packet { + msg_type: MsgType::Solicit, + transaction_id: 1, + options: vec![Dhcpv6Option::Unrecognized(RawDhcpv6Option { + code: 200, + data: vec![0u8; 65536], + })], + }; + assert_eq!(p.encode(), Err(EncodeError::OptionTooLarge)); +} + +#[test] +fn is_for_server_exact_duid_match_only() { + let ours = duid(&[1, 2, 3]); + let mk = |opts| Packet { msg_type: MsgType::Request, transaction_id: 1, options: opts }; + assert!(is_for_server( + &ours, + &mk(vec![Dhcpv6Option::ServerId(duid(&[1, 2, 3]))]) + )); + assert!(!is_for_server(&ours, &mk(vec![Dhcpv6Option::ServerId(vec![])]))); + assert!(!is_for_server( + &ours, + &mk(vec![Dhcpv6Option::ServerId(duid(&[1, 2, 4]))]) + )); + assert!(!is_for_server(&ours, &mk(vec![])), "missing ServerId"); + assert!( + !is_for_server( + &ours, + &mk(vec![Dhcpv6Option::Unrecognized(RawDhcpv6Option { + code: 2, + data: duid(&[1, 2, 3]) + })]) + ), + "Unrecognized(2) is not a ServerId" + ); +} + +#[test] +fn default_t1_t2_halves_and_seven_eighths() { + assert_eq!(default_t1_t2(86400), (43200, 75600)); + assert_eq!(default_t1_t2(3600), (1800, 3150)); + assert_eq!(default_t1_t2(0), (0, 0)); + assert_eq!(default_t1_t2(u32::MAX), (2147483647, 3758096384)); +} + +// --------------------------------------------------------------------------- +// V6Pool managed range +// --------------------------------------------------------------------------- + +fn pool() -> V6Pool { + V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + 1000, + Duration::from_secs(86400), + ) +} + +fn ip(n: u16) -> Ipv6Addr { + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, n) +} + +#[test] +fn pool_accessors() { + let p = pool(); + assert_eq!(p.start_addr(), ip(0x100)); + assert_eq!(p.count(), 1000); + assert_eq!(p.len(), 0); + assert!(p.is_empty()); +} + +#[test] +fn pool_boundaries_first_offer_and_last() { + let mut p = pool(); + // round-robin starts at start+1 like the v4 example + assert_eq!(p.discover(&duid(&[1])), Some(ip(0x101))); + // last usable address is start+count-1 + assert!(p.available(&duid(&[2]), &ip(0x100 + 999))); + assert!(!p.available(&duid(&[2]), &ip(0x100 + 1000)), "exclusive upper bound"); + assert!(!p.available(&duid(&[2]), &ip(0xFF)), "below pool"); + assert!(!p.available(&duid(&[2]), &v6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1])), "other prefix"); + // unleased in-pool address is available + assert!(p.available(&duid(&[3]), &ip(0x200))); +} + +#[test] +fn pool_exhaustion_returns_none_then_recovers() { + let mut p = V6Pool::new(ip(0x100), 4, Duration::from_secs(60)); + for i in 0u8..4 { + let d = duid(&[i]); + let got = p.discover(&d).unwrap(); + p.request(&d, got).unwrap(); + } + assert_eq!(p.len(), 4); + assert_eq!(p.discover(&duid(&[9])), None, "pool exhausted"); + assert!(p.request(&duid(&[9]), ip(0x101)).is_err()); + assert!(p.release(&duid(&[0]))); + // freed .101 is first in scan order (round-robin resumes after last=0) + assert_eq!(p.discover(&duid(&[9])), Some(ip(0x101))); +} + +#[test] +fn pool_request_prefers_current_without_refresh_quirk() { + let mut p = pool(); + let mac = duid(&[7]); + assert_eq!(p.request(&mac, ip(0x110)), Ok(ip(0x110))); + let before = p.get(&ip(0x110)).unwrap().1; + // asking for someone else's IP still returns our own, untouched + assert_eq!(p.request(&mac, ip(0x120)), Ok(ip(0x110))); + assert_eq!(p.get(&ip(0x110)).unwrap().1, before, "no expiry refresh, like v4"); + assert!(p.get(&ip(0x120)).is_none()); +} + +#[test] +fn pool_infinite_leases_are_not_stealable() { + // deliberate difference from the v4 available() quirk: None expiry here + // means permanently reserved, and strangers get Err, not the address. + let mut p = pool(); + let owner = duid(&[0xF4, 0x5C]); + let stranger = duid(&[0xDE, 0xAD]); + p.insert(ip(0x190), owner.clone(), None); + assert!(!p.available(&stranger, &ip(0x190))); + assert!(p.request(&stranger, ip(0x190)).is_err()); + assert_eq!(p.request(&owner, ip(0x190)), Ok(ip(0x190))); + assert_eq!(p.discover(&owner), Some(ip(0x190))); +} + +#[test] +fn pool_expired_leases_reusable() { + let mut p = pool(); + p.insert(ip(0x1A0), duid(&[1]), Some(Instant::now() - Duration::from_secs(1))); + assert!(p.available(&duid(&[2]), &ip(0x1A0))); + assert_eq!(p.request(&duid(&[2]), ip(0x1A0)), Ok(ip(0x1A0))); +} + +#[test] +fn pool_release_true_false() { + let mut p = pool(); + assert!(!p.release(&duid(&[1])), "nothing held"); + assert_eq!(p.request(&duid(&[1]), ip(0x1B0)), Ok(ip(0x1B0))); + assert!(p.release(&duid(&[1]))); + assert!(!p.release(&duid(&[1])), "already gone"); + assert!(p.is_empty()); +} + +#[test] +fn pool_duid_identity_is_exact_bytes() { + let mut p = pool(); + assert_eq!(p.request(&duid(&[1, 2]), ip(0x1C0)), Ok(ip(0x1C0))); + // prefix-extended DUID is a different client: blocked + assert!(!p.available(&duid(&[1, 2, 3]), &ip(0x1C0))); + assert_eq!(p.current_lease(&duid(&[1, 2, 3])), None); + assert_eq!(p.current_lease(&duid(&[1, 2])), Some(ip(0x1C0))); +} + +#[test] +fn pool_empty_count_never_panics() { + let mut p = V6Pool::new(ip(0x100), 0, Duration::from_secs(60)); + assert_eq!(p.discover(&duid(&[1])), None); + assert!(p.request(&duid(&[1]), ip(0x100)).is_err()); + assert!(!p.available(&duid(&[1]), &ip(0x100))); +} + +#[test] +fn pool_max_start_never_panics() { + // start near u128::MAX with a count reaching past it: the exclusive end + // overflows, so every candidate is out of range — but small enough to + // scan quickly (a huge count must never mean a huge loop). + let near_max = Ipv6Addr::from(u128::MAX - 3); + let mut p = V6Pool::new(near_max, 16, Duration::from_secs(60)); + assert_eq!(p.discover(&duid(&[1])), None, "overflowed candidates skipped"); + assert!(!p.available(&duid(&[1]), &near_max)); + assert!(p.request(&duid(&[1]), near_max).is_err()); + // sheer count size alone must not hang the scan either: an empty pool + // returns None after scanning, and the loop is bounded by count — keep + // counts under test small by construction (see pool_exhaustion test). + let mut tiny = V6Pool::new(ip(0x100), 0, Duration::from_secs(60)); + assert_eq!(tiny.discover(&duid(&[1])), None); +} + +// --------------------------------------------------------------------------- +// coverageg gaps: sub-option skipping, identity edges, pool corners +// --------------------------------------------------------------------------- + +#[test] +fn iana_skips_unknown_suboptions_keeps_iaaddrs() { + // unknown sub-option (code 99) interleaved between two IAADDRs: skipped + // by length while both addresses still decode. + let mut data = vec![0u8; 12]; // iaid/t1/t2 zeroes + data[0..4].copy_from_slice(&9u32.to_be_bytes()); + let addr = |n: u16| { + let mut sub = vec![0u8, 5, 0, 24]; + sub.extend_from_slice(&v6([0xfd00, 0, 0, 0, 0, 0, 0, n]).octets()); + sub.extend_from_slice(&100u32.to_be_bytes()); + sub.extend_from_slice(&200u32.to_be_bytes()); + sub + }; + data.extend_from_slice(&addr(1)); + data.extend_from_slice(&[0, 99, 0, 2, 0xAA, 0xBB]); // unknown, skipped + data.extend_from_slice(&addr(2)); + let mut wire = vec![0, 3, (data.len() >> 8) as u8, (data.len() & 0xFF) as u8]; + wire.extend_from_slice(&data); + match decode_option(&wire) { + Ok((_, Dhcpv6Option::IaNa(ia))) => { + assert_eq!(ia.iaid, 9); + assert_eq!( + ia.addrs.iter().map(|a| a.addr).collect::>(), + vec![v6([0xfd00, 0, 0, 0, 0, 0, 0, 1]), v6([0xfd00, 0, 0, 0, 0, 0, 0, 2])] + ); + } + _ => panic!("unknown sub-options must be skipped"), + } +} + +#[test] +fn iaaddr_trailing_subsuboptions_ignored() { + // IAADDR data longer than 24 bytes: first 24 parse, rest ignored. + let mut body = vec![0u8; 0]; + body.extend_from_slice(&v6([0xfd00, 0, 0, 0, 0, 0, 0, 9]).octets()); + body.extend_from_slice(&300u32.to_be_bytes()); + body.extend_from_slice(&400u32.to_be_bytes()); + body.extend_from_slice(&[9, 9, 9, 9, 9, 9]); // sub-sub-options, ignored + let mut data = vec![0u8; 12]; + let mut wire = vec![0, 3, 0, 0]; // len patched below + let mut opt = vec![0, 5, 0, body.len() as u8]; + opt.extend_from_slice(&body); + data.extend_from_slice(&opt); + wire[2..4].copy_from_slice(&(data.len() as u16).to_be_bytes()); + wire.extend_from_slice(&data); + match decode_option(&wire) { + Ok((_, Dhcpv6Option::IaNa(ia))) => { + assert_eq!(ia.addrs.len(), 1); + assert_eq!(ia.addrs[0].addr, v6([0xfd00, 0, 0, 0, 0, 0, 0, 9])); + assert_eq!((ia.addrs[0].preferred, ia.addrs[0].valid), (300, 400)); + } + _ => panic!("trailing sub-sub-option bytes must be ignored"), + } +} + +#[test] +fn is_for_server_first_identifier_wins() { + let ours = duid(&[1, 2, 3]); + let mk = |opts| Packet { msg_type: MsgType::Request, transaction_id: 1, options: opts }; + assert!(is_for_server( + &ours, + &mk(vec![ + Dhcpv6Option::ServerId(duid(&[1, 2, 3])), + Dhcpv6Option::ServerId(duid(&[9, 9, 9])), + ]) + )); + assert!(!is_for_server( + &ours, + &mk(vec![ + Dhcpv6Option::ServerId(duid(&[9, 9, 9])), + Dhcpv6Option::ServerId(duid(&[1, 2, 3])), + ]) + )); +} + +#[test] +fn empty_clientid_decodes() { + match decode_option(&[0, 1, 0, 0]) { + Ok((rest, Dhcpv6Option::ClientId(d))) => { + assert!(d.is_empty()); + assert!(rest.is_empty()); + } + _ => panic!("empty ClientId must decode"), + } + // and it round-trips through a packet + let p = Packet { + msg_type: MsgType::Solicit, + transaction_id: 5, + options: vec![Dhcpv6Option::ClientId(vec![])], + }; + assert_eq!(Packet::from(&p.encode().unwrap()).unwrap(), p); +} + +#[test] +fn opaque_messages_roundtrip() { + // Confirm / Reconfigure / InformationRequest / RelayForward / RelayReply + // carry no special-cased structure: header + opaque options round-trip. + for mt in [ + MsgType::Confirm, + MsgType::Reconfigure, + MsgType::InformationRequest, + MsgType::RelayForward, + MsgType::RelayReply, + ] { + let p = Packet { + msg_type: mt, + transaction_id: 0x0A0B0C, + options: vec![ + Dhcpv6Option::ClientId(duid(&[7])), + Dhcpv6Option::Unrecognized(RawDhcpv6Option { + code: 65000, + data: vec![1, 2, 3], + }), + ], + }; + let enc = p.encode().unwrap(); + assert_eq!(enc[0], mt as u8); + assert_eq!(Packet::from(&enc).unwrap(), p); + } +} + +#[test] +fn transaction_id_zero_and_max_roundtrip() { + for tid in [0u32, 0xFF_FF_FF] { + let p = Packet { msg_type: MsgType::Solicit, transaction_id: tid, options: vec![] }; + let enc = p.encode().unwrap(); + assert_eq!(Packet::from(&enc).unwrap().transaction_id, tid); + } +} + +#[test] +fn pool_request_out_of_range_err_no_insert() { + let mut p = pool(); + assert!(p.request(&duid(&[1]), v6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1])).is_err()); + assert!(p.is_empty(), "out-of-range request must not insert"); +} + +#[test] +fn pool_single_address_pool_cycles() { + // count == 1: modulo-1 scan cannot divide by zero (loop runs once) and + // the lone address is offered, committed, then reported current. + let mut p = V6Pool::new(ip(0x100), 1, Duration::from_secs(60)); + assert_eq!(p.discover(&duid(&[1])), Some(ip(0x100))); + assert_eq!(p.request(&duid(&[1]), ip(0x100)), Ok(ip(0x100))); + assert_eq!(p.discover(&duid(&[1])), Some(ip(0x100))); + assert_eq!(p.discover(&duid(&[2])), None, "taken single slot"); +} diff --git a/tests/test_examples_extra.rs b/tests/test_examples_extra.rs new file mode 100644 index 0000000..3b60a85 --- /dev/null +++ b/tests/test_examples_extra.rs @@ -0,0 +1,332 @@ +//! Extra example coverage: `chaddr` formatting and the full +//! `src/main.rs` MyServer state machine (Discover / Request / +//! Release / Decline), including the Request-not-gated vs Release-gated quirk. + +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// monitor::chaddr copy (examples/monitor.rs) +// --------------------------------------------------------------------------- + +fn chaddr(a: &[u8]) -> String { + a[1..].iter().fold(format!("{:02x}", a[0]), |acc, &b| { + format!("{}:{:02x}", acc, &b) + }) +} + +#[test] +fn chaddr_formats_six_byte_mac_lowercase_colon() { + assert_eq!(chaddr(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), "aa:bb:cc:dd:ee:ff"); + assert_eq!(chaddr(&[0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D]), "f4:5c:19:af:96:8d"); +} + +#[test] +fn chaddr_pads_single_nibbles_with_zero() { + assert_eq!(chaddr(&[1, 2, 3]), "01:02:03"); + assert_eq!(chaddr(&[0, 0, 0, 0, 0, 0]), "00:00:00:00:00:00"); + assert_eq!(chaddr(&[255, 255]), "ff:ff"); +} + +#[test] +fn chaddr_single_byte_has_no_colon() { + assert_eq!(chaddr(&[0xAB]), "ab"); + assert_eq!(chaddr(&[0]), "00"); +} + +#[test] +#[should_panic] +fn chaddr_empty_panics_quirk() { + let _ = chaddr(&[]); +} + +// --------------------------------------------------------------------------- +// examples/server.rs constants locked numerically +// --------------------------------------------------------------------------- + +const SERVER_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 2, 1); +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +#[test] +fn example_constants_are_exact() { + assert_eq!(SERVER_IP, Ipv4Addr::new(192, 168, 2, 1)); + assert_eq!(IP_START_NUM, 0xC0A8_0202); + assert_eq!(IP_START_NUM, 3232236034); + assert_eq!(LEASE_NUM, 252); + assert_eq!(LEASE_DURATION_SECS, 86400); + assert_eq!(Ipv4Addr::from(IP_START_NUM), Ipv4Addr::new(192, 168, 2, 2)); + assert_eq!( + Ipv4Addr::from(IP_START_NUM + LEASE_NUM - 1), + Ipv4Addr::new(192, 168, 2, 253), + "last usable IP" + ); +} + +// --------------------------------------------------------------------------- +// MyServer replica (same fields, same method bodies as examples/server.rs) +// --------------------------------------------------------------------------- + +struct Replica { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl Replica { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + /// Mirrors Discover branch: returns offered IP (no insert). + fn discover(&mut self, chaddr: &[u8; 6]) -> Option { + if let Some(ip) = self.current_lease(chaddr) { + return Some(ip); + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(chaddr, &cand) { + return Some(cand); + } + } + None + } + /// Mirrors Request branch. `for_this` is computed by caller but IGNORED + /// (the `if !for_this { return; }` is commented out in the example). + /// Returns Ack(ip) or Nak. + fn request( + &mut self, + chaddr: &[u8; 6], + requested: Option, + ciaddr: Ipv4Addr, + _for_this: bool, + ) -> Result { + let req_ip = requested.unwrap_or(ciaddr); + if let Some(ip) = self.current_lease(chaddr) { + return Ok(ip); + } + if !self.available(chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + (chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))), + ); + Ok(req_ip) + } + /// Mirrors Release/Decline branch: gated on for_this_server. + /// Returns true if a lease was removed. + fn release(&mut self, chaddr: &[u8; 6], for_this: bool) -> bool { + if !for_this { + return false; + } + if let Some(ip) = self.current_lease(chaddr) { + self.leases.remove(&ip); + return true; + } + false + } +} + +#[test] +fn discover_prefers_existing_even_if_expired() { + let mut s = Replica::new(); + let mac = [1, 2, 3, 4, 5, 6]; + // expired lease still returned (current_lease ignores expiry) + s.leases.insert( + Ipv4Addr::new(192, 168, 2, 50), + (mac, Some(Instant::now() - Duration::from_secs(10))), + ); + assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(192, 168, 2, 50))); + assert_eq!(s.last_lease, 0, "existing path must not bump last_lease"); +} + +#[test] +fn discover_prefers_infinite_lease() { + let mut s = Replica::new(); + let mac = [0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D]; + s.leases.insert(Ipv4Addr::new(192, 168, 2, 90), (mac, None)); + assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(192, 168, 2, 90))); +} + +#[test] +fn discover_round_robin_increments_and_wraps() { + let mut s = Replica::new(); + let mac = [9, 9, 9, 9, 9, 9]; + // last_lease 0 -> first offer .3 (0+1), then .4, ... + assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(192, 168, 2, 3))); + assert_eq!(s.last_lease, 1); + // occupy .4 with another MAC so it is skipped + s.leases.insert( + Ipv4Addr::new(192, 168, 2, 4), + ([8, 8, 8, 8, 8, 8], Some(Instant::now() + Duration::from_secs(3600))), + ); + assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(192, 168, 2, 5))); + // wrap: set to last index, next is 0 -> IP_START (.2). Use fresh MAC with no lease. + s.last_lease = LEASE_NUM - 1; + let mac2 = [7, 7, 7, 7, 7, 7]; + assert_eq!(s.discover(&mac2), Some(Ipv4Addr::new(192, 168, 2, 2))); + assert_eq!(s.last_lease, 0); +} + +#[test] +fn discover_returns_none_when_pool_full() { + let mut s = Replica::new(); + // fill entire pool with other MACs, unexpired + for i in 0..LEASE_NUM { + let ip: Ipv4Addr = (IP_START_NUM + i).into(); + s.leases.insert( + ip, + ([8, 8, 8, 8, 8, 8], Some(Instant::now() + Duration::from_secs(3600))), + ); + } + // infinite leases are stealable per available() quirk, so use unexpired + // timed leases above to truly fill. New MAC gets None. + assert_eq!(s.discover(&[1, 2, 3, 4, 5, 6]), None); +} + +#[test] +fn request_prefers_current_lease_without_insert_or_renew_quirk() { + let mut s = Replica::new(); + let mac = [1, 2, 3, 4, 5, 6]; + let before = Instant::now() + Duration::from_secs(3600); + s.leases.insert(Ipv4Addr::new(192, 168, 2, 20), (mac, Some(before))); + let n_before = s.leases.len(); + // request different IP, but current_lease wins and no insert happens + let res = s.request(&mac, Some(Ipv4Addr::new(192, 168, 2, 99)), Ipv4Addr::new(0, 0, 0, 0), true); + assert_eq!(res, Ok(Ipv4Addr::new(192, 168, 2, 20))); + assert_eq!(s.leases.len(), n_before, "no new insert on current_lease path"); + assert!(!s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 99))); +} + +#[test] +fn request_inserts_with_future_expiry_and_acks() { + let mut s = Replica::new(); + let mac = [2, 2, 2, 2, 2, 2]; + let before = Instant::now(); + let res = s.request(&mac, Some(Ipv4Addr::new(192, 168, 2, 60)), Ipv4Addr::new(0, 0, 0, 0), true); + assert_eq!(res, Ok(Ipv4Addr::new(192, 168, 2, 60))); + let (_, expiry) = s.leases.get(&Ipv4Addr::new(192, 168, 2, 60)).unwrap(); + let exp = expiry.expect("new lease must have Some expiry"); + assert!(exp > before, "expiry must be in the future"); + assert!(exp <= Instant::now().add(s.lease_duration + Duration::from_secs(5))); +} + +#[test] +fn request_uses_ciaddr_when_no_requested_ip() { + let mut s = Replica::new(); + let mac = [3, 3, 3, 3, 3, 3]; + let res = s.request(&mac, None, Ipv4Addr::new(192, 168, 2, 70), true); + assert_eq!(res, Ok(Ipv4Addr::new(192, 168, 2, 70))); + assert!(s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 70))); +} + +#[test] +fn request_naks_when_unavailable() { + let mut s = Replica::new(); + s.leases.insert( + Ipv4Addr::new(192, 168, 2, 80), + ([8, 8, 8, 8, 8, 8], Some(Instant::now() + Duration::from_secs(3600))), + ); + let res = s.request(&[1, 2, 3, 4, 5, 6], Some(Ipv4Addr::new(192, 168, 2, 80)), Ipv4Addr::new(0, 0, 0, 0), true); + assert_eq!(res, Err("Requested IP not available")); + // out-of-pool also Naks + let res = s.request(&[1, 2, 3, 4, 5, 6], Some(Ipv4Addr::new(10, 0, 0, 5)), Ipv4Addr::new(0, 0, 0, 0), true); + assert!(res.is_err()); +} + +#[test] +fn request_ignores_for_this_server_quirk() { + // In examples/server.rs the Request gate is commented out, so even + // for_this==false still processes (unlike Release). + let mut s = Replica::new(); + let mac = [4, 4, 4, 4, 4, 4]; + let res = s.request(&mac, Some(Ipv4Addr::new(192, 168, 2, 61)), Ipv4Addr::new(0, 0, 0, 0), false); + assert_eq!(res, Ok(Ipv4Addr::new(192, 168, 2, 61)), "Request must proceed even when not for this server"); +} + +#[test] +fn release_gated_on_for_this_server_and_removes() { + let mut s = Replica::new(); + let mac = [5, 5, 5, 5, 5, 5]; + s.leases.insert(Ipv4Addr::new(192, 168, 2, 62), (mac, Some(Instant::now() + Duration::from_secs(100)))); + // not for this server -> no removal + assert!(!s.release(&mac, false)); + assert!(s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 62))); + // for this server -> removed + assert!(s.release(&mac, true)); + assert!(!s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 62))); + // no lease -> false + assert!(!s.release(&mac, true)); +} + +#[test] +fn current_lease_with_two_ips_returns_one_of_them() { + let mut s = Replica::new(); + let mac = [6, 6, 6, 6, 6, 6]; + s.leases.insert(Ipv4Addr::new(192, 168, 2, 30), (mac, None)); + s.leases.insert(Ipv4Addr::new(192, 168, 2, 31), (mac, None)); + let got = s.current_lease(&mac).expect("must find one"); + assert!( + got == Ipv4Addr::new(192, 168, 2, 30) || got == Ipv4Addr::new(192, 168, 2, 31), + "got {:?}", + got + ); +} + +#[test] +fn leases_line_uppercase_and_single_digit_parse() { + // from_str_radix is case-insensitive and accepts single digits + let parse = |line: &str| { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() != 2 { + return None; + } + let mac_parts: Vec = parts[0] + .split(':') + .filter_map(|p| u8::from_str_radix(p, 16).ok()) + .collect(); + if mac_parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + mac.copy_from_slice(&mac_parts); + let ip = parts[1].trim().parse::().ok()?; + Some((mac, ip)) + }; + let (mac, _) = parse("F4:5C:19:AF:96:8D,192.168.2.90").unwrap(); + assert_eq!(mac, [0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D]); + let (mac, _) = parse("1:2:3:4:5:6,192.168.2.10").unwrap(); + assert_eq!(mac, [1, 2, 3, 4, 5, 6]); + assert!(parse("aa:bb:cc:dd:ee:ff:00,192.168.2.10").is_none(), "7 octets rejected"); + assert!(parse("").is_none()); + // trim() strips trailing newline, so an explicit \n still parses + assert!(parse("aa:bb:cc:dd:ee:ff,192.168.2.10\n").is_some()); +} diff --git a/tests/test_examples_v6.rs b/tests/test_examples_v6.rs new file mode 100644 index 0000000..31e8a6d --- /dev/null +++ b/tests/test_examples_v6.rs @@ -0,0 +1,434 @@ +//! Tests for the example DHCPv6 service logic (`examples/server.rs` v6 half). +//! +//! The example binary cannot be imported, so — following this repo's +//! convention (see `test_examples_extra.rs`) — `TestV6Server` below mirrors +//! the `serve_v6` match arms body-for-body but returns `Option` +//! instead of sending on a socket. This locks the Solicit/Request/Renew/ +//! Rebind/Release/Decline decisions, the IAID echo, T1/T2 and DNS policy, +//! the NoAddrsAvail shape, and the `leases6` loader's skip rules. + +use dhcp4r::dhcpv6::*; +use std::net::Ipv6Addr; +use std::time::Duration; + +fn v6(a: [u16; 8]) -> Ipv6Addr { + Ipv6Addr::new(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]) +} + +const SERVER_DUID: [u8; 10] = [0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc]; +const LEASE_SECS: u32 = 86400; + +fn pool() -> V6Pool { + V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + 1000, + Duration::from_secs(LEASE_SECS as u64), + ) +} + +fn ip(n: u16) -> Ipv6Addr { + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, n) +} + +// --------------------------------------------------------------------------- +// TestV6Server: same decisions as examples/server.rs serve_v6, pure return +// --------------------------------------------------------------------------- + +struct TestV6Server { + pool: V6Pool, + server_duid: Vec, + lease_secs: u32, + dns: Vec, +} + +impl TestV6Server { + fn new() -> Self { + Self { + pool: pool(), + server_duid: SERVER_DUID.to_vec(), + lease_secs: LEASE_SECS, + dns: vec![v6([0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888])], + } + } + + fn success(&self, req: &Packet, msg_type: MsgType, ip: Ipv6Addr) -> Packet { + let iaid = req.first_iana().map(|ia| ia.iaid).unwrap_or(0); + let (t1, t2) = default_t1_t2(self.lease_secs); + let duid = match req.option(OPT_CLIENTID) { + Some(Dhcpv6Option::ClientId(d)) => d.clone(), + _ => vec![], + }; + let mut options = vec![ + Dhcpv6Option::ServerId(self.server_duid.clone()), + Dhcpv6Option::ClientId(duid), + Dhcpv6Option::IaNa(IaNa { + iaid, + t1, + t2, + addrs: vec![IaAddr { addr: ip, preferred: self.lease_secs, valid: self.lease_secs }], + }), + ]; + if !self.dns.is_empty() { + options.push(Dhcpv6Option::DnsServers(self.dns.clone())); + } + Packet { msg_type, transaction_id: req.transaction_id, options } + } + + fn nodata(&self, req: &Packet, msg_type: MsgType) -> Packet { + let iaid = req.first_iana().map(|ia| ia.iaid).unwrap_or(0); + let duid = match req.option(OPT_CLIENTID) { + Some(Dhcpv6Option::ClientId(d)) => d.clone(), + _ => vec![], + }; + Packet { + msg_type, + transaction_id: req.transaction_id, + options: vec![ + Dhcpv6Option::ServerId(self.server_duid.clone()), + Dhcpv6Option::ClientId(duid), + Dhcpv6Option::IaNa(IaNa { iaid, t1: 0, t2: 0, addrs: vec![] }), + Dhcpv6Option::StatusCode(STATUS_NO_ADDRS_AVAIL, "NoAddrsAvail".to_string()), + ], + } + } + + fn handle(&mut self, req: &Packet) -> Option { + let duid = match req.option(OPT_CLIENTID) { + Some(Dhcpv6Option::ClientId(d)) => d.clone(), + _ => return None, + }; + match req.msg_type { + MsgType::Solicit => match self.pool.discover(&duid) { + Some(ip) => Some(self.success(req, MsgType::Advertise, ip)), + None => Some(self.nodata(req, MsgType::Advertise)), + }, + MsgType::Request | MsgType::Renew | MsgType::Rebind => { + let wanted = req.first_iana()?.addrs.first()?.addr; + match self.pool.request(&duid, wanted) { + Ok(ip) => Some(self.success(req, MsgType::Reply, ip)), + Err(_) => Some(self.nodata(req, MsgType::Reply)), + } + } + MsgType::Release | MsgType::Decline => { + if !is_for_server(&self.server_duid, req) { + return None; + } + self.pool.release(&duid); + None + } + _ => None, + } + } +} + +fn solicit(duid_bytes: &[u8], iaid: Option) -> Packet { + let mut options = vec![Dhcpv6Option::ClientId(duid_bytes.to_vec())]; + if let Some(id) = iaid { + options.push(Dhcpv6Option::IaNa(IaNa { iaid: id, t1: 0, t2: 0, addrs: vec![] })); + } + Packet { msg_type: MsgType::Solicit, transaction_id: 0x112233, options } +} + +fn request_for(duid_bytes: &[u8], iaid: u32, addr: Ipv6Addr) -> Packet { + Packet { + msg_type: MsgType::Request, + transaction_id: 0x445566, + options: vec![ + Dhcpv6Option::ClientId(duid_bytes.to_vec()), + Dhcpv6Option::ServerId(SERVER_DUID.to_vec()), + Dhcpv6Option::IaNa(IaNa { + iaid, + t1: 0, + t2: 0, + addrs: vec![IaAddr { addr, preferred: 0, valid: 0 }], + }), + ], + } +} + +fn codes(p: &Packet) -> Vec { + p.options.iter().map(|o| o.code()).collect() +} + +// --------------------------------------------------------------------------- +// Solicit flows +// --------------------------------------------------------------------------- + +#[test] +fn solicit_fresh_duid_advertises_first_free() { + let mut s = TestV6Server::new(); + let rep = s.handle(&solicit(&[1], Some(0x0A0B0C0D))).unwrap(); + assert_eq!(rep.msg_type, MsgType::Advertise); + assert_eq!(rep.transaction_id, 0x112233, "tid echoed"); + assert_eq!(codes(&rep), vec![2, 1, 3, 23]); + assert_eq!( + rep.option(OPT_SERVERID), + Some(&Dhcpv6Option::ServerId(SERVER_DUID.to_vec())) + ); + assert_eq!(rep.option(OPT_CLIENTID), Some(&Dhcpv6Option::ClientId(vec![1]))); + match rep.option(OPT_IA_NA) { + Some(Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0x0A0B0C0D, "request IAID echoed"); + assert_eq!((ia.t1, ia.t2), (43200, 75600)); + assert_eq!(ia.addrs.len(), 1); + assert_eq!(ia.addrs[0].addr, ip(0x101), "first free past start"); + assert_eq!((ia.addrs[0].preferred, ia.addrs[0].valid), (86400, 86400)); + } + _ => panic!("Advertise must carry IA_NA"), + } + assert!(rep.option(OPT_STATUS_CODE).is_none(), "success has no status"); +} + +#[test] +fn solicit_prefers_current_lease() { + let mut s = TestV6Server::new(); + s.pool.request(&[1], ip(0x110)).unwrap(); + let rep = s.handle(&solicit(&[1], Some(7))).unwrap(); + match rep.option(OPT_IA_NA) { + Some(Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.addrs.len(), 1); + assert_eq!(ia.addrs[0].addr, ip(0x110)); + } + _ => panic!("must re-offer current lease"), + } +} + +#[test] +fn solicit_no_clientid_ignored() { + let mut s = TestV6Server::new(); + let req = Packet { + msg_type: MsgType::Solicit, + transaction_id: 1, + options: vec![Dhcpv6Option::IaNa(IaNa { iaid: 1, t1: 0, t2: 0, addrs: vec![] })], + }; + assert!(s.handle(&req).is_none(), "no DUID means no identity"); +} + +#[test] +fn solicit_without_iana_advertises_iaid_zero() { + let mut s = TestV6Server::new(); + let req = Packet { + msg_type: MsgType::Solicit, + transaction_id: 2, + options: vec![Dhcpv6Option::ClientId(vec![9])], + }; + let rep = s.handle(&req).unwrap(); + match rep.option(OPT_IA_NA) { + Some(Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0, "missing IA_NA defaults IAID to 0"); + assert_eq!(ia.addrs.len(), 1); + } + _ => panic!("must still Advertise"), + } +} + +// --------------------------------------------------------------------------- +// Request / Renew / Rebind flows +// --------------------------------------------------------------------------- + +#[test] +fn request_commits_and_acks_with_lease() { + let mut s = TestV6Server::new(); + let rep = s.handle(&request_for(&[2], 0x01020304, ip(0x120))).unwrap(); + assert_eq!(rep.msg_type, MsgType::Reply); + assert_eq!(rep.transaction_id, 0x445566); + match rep.option(OPT_IA_NA) { + Some(Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0x01020304); + assert_eq!(ia.addrs.len(), 1); + assert_eq!((ia.addrs[0].preferred, ia.addrs[0].valid), (86400, 86400)); + } + _ => panic!("Reply must carry IA_NA"), + } + assert!(s.pool.get(&ip(0x120)).is_some(), "committed server-side"); + assert!(rep.option(OPT_STATUS_CODE).is_none()); +} + +#[test] +fn request_without_iana_ignored() { + let mut s = TestV6Server::new(); + let req = Packet { + msg_type: MsgType::Request, + transaction_id: 3, + options: vec![ + Dhcpv6Option::ClientId(vec![2]), + Dhcpv6Option::ServerId(SERVER_DUID.to_vec()), + ], + }; + assert!(s.handle(&req).is_none(), "no IA_NA means nothing to commit"); + assert!(s.pool.is_empty()); +} + +#[test] +fn request_unavailable_gets_nodata_status() { + let mut s = TestV6Server::new(); + s.pool.request(&[8], ip(0x130)).unwrap(); // squatter + let rep = s.handle(&request_for(&[9], 0x11111111, ip(0x130))).unwrap(); + assert_eq!(rep.msg_type, MsgType::Reply); + assert_eq!(codes(&rep), vec![2, 1, 3, 13]); + match rep.option(OPT_IA_NA) { + Some(Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0x11111111); + assert!(ia.addrs.is_empty()); + assert_eq!((ia.t1, ia.t2), (0, 0)); + } + _ => panic!("nodata Reply keeps an empty IA_NA"), + } + assert_eq!( + rep.option(OPT_STATUS_CODE), + Some(&Dhcpv6Option::StatusCode(STATUS_NO_ADDRS_AVAIL, "NoAddrsAvail".to_string())) + ); +} + +#[test] +fn renew_and_rebind_behave_like_request() { + for mt in [MsgType::Renew, MsgType::Rebind] { + let mut s = TestV6Server::new(); + let mut req = request_for(&[3], 0x22222222, ip(0x140)); + req.msg_type = mt; + req.transaction_id = 0x333333; + let rep = s.handle(&req).unwrap(); + assert_eq!(rep.msg_type, MsgType::Reply, "{:?}", mt); + assert_eq!(rep.transaction_id, 0x333333); + match rep.option(OPT_IA_NA) { + Some(Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.addrs.len(), 1); + assert_eq!(ia.addrs[0].addr, ip(0x140)); + } + _ => panic!("{:?} must commit like Request", mt), + } + } +} + +// --------------------------------------------------------------------------- +// Release / Decline / ignored types +// --------------------------------------------------------------------------- + +#[test] +fn release_and_decline_free_with_correct_id() { + for mt in [MsgType::Release, MsgType::Decline] { + let mut s = TestV6Server::new(); + s.pool.request(&[4], ip(0x150)).unwrap(); + let pkt = Packet { + msg_type: mt, + transaction_id: 4, + options: vec![ + Dhcpv6Option::ClientId(vec![4]), + Dhcpv6Option::ServerId(SERVER_DUID.to_vec()), + ], + }; + assert!(s.handle(&pkt).is_none(), "{:?} sends no reply", mt); + assert!(s.pool.get(&ip(0x150)).is_none(), "{:?} must free", mt); + } +} + +#[test] +fn release_wrong_or_missing_id_keeps_lease() { + for opts in [ + vec![ + Dhcpv6Option::ClientId(vec![5]), + Dhcpv6Option::ServerId(vec![9, 9, 9]), + ], + vec![Dhcpv6Option::ClientId(vec![5])], + ] { + let mut s = TestV6Server::new(); + s.pool.request(&[5], ip(0x160)).unwrap(); + let pkt = Packet { msg_type: MsgType::Release, transaction_id: 5, options: opts }; + assert!(s.handle(&pkt).is_none()); + assert!(s.pool.get(&ip(0x160)).is_some(), "lease must survive"); + } +} + +#[test] +fn confirm_and_informationrequest_ignored() { + let mut s = TestV6Server::new(); + for mt in [MsgType::Confirm, MsgType::InformationRequest, MsgType::Reply, MsgType::Reconfigure] { + let req = Packet { + msg_type: mt, + transaction_id: 6, + options: vec![Dhcpv6Option::ClientId(vec![6])], + }; + assert!(s.handle(&req).is_none(), "{:?} must be ignored", mt); + } + assert!(s.pool.is_empty(), "ignored types insert nothing"); +} + +// --------------------------------------------------------------------------- +// leases6 loader rules (mirror of examples/server.rs serve_v6 loader) +// --------------------------------------------------------------------------- + +fn load_leases6(text: &str) -> V6Pool { + let mut pool = V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + 1000, + Duration::from_secs(86400), + ); + for line in text.lines() { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() == 2 { + if let (Ok(duid), Ok(ip)) = ( + dhcp4r::config::parse_duid_hex(parts[0].trim()), + parts[1].trim().parse::(), + ) { + if !duid.is_empty() { + pool.insert(ip, duid, None); + } + } + } + } + pool +} + +#[test] +fn leases6_loader_skips_malformed_keeps_good() { + let pool = load_leases6( + "00:03:00:01:02:00:5e:aa:bb:cc,fd00::100\n\ + not-hex-here,fd00::101\n\ + 00:03:00:01:02:00:5e:aa:bb:cc,not-an-ip\n\ + singlefield\n\ + a,b,c\n\ + ,fd00::102\n\ + 00:03:00:01:02:00:5e:aa:bb:dd,fd00::101\n", + ); + assert_eq!(pool.len(), 2); + assert_eq!( + pool.get(&v6([0xfd00, 0, 0, 0, 0, 0, 0, 0x100])).unwrap().0, + vec![0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc] + ); + // infinite (None) expiry like the v4 permanent file + assert!(pool.get(&v6([0xfd00, 0, 0, 0, 0, 0, 0, 0x100])).unwrap().1.is_none()); + assert_eq!( + pool.get(&v6([0xfd00, 0, 0, 0, 0, 0, 0, 0x101])).unwrap().0, + vec![0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xdd] + ); +} + +#[test] +fn leases6_duplicate_ip_last_wins() { + let pool = load_leases6( + "00:01,fd00::110\n00:02,fd00::110\n", + ); + assert_eq!(pool.len(), 1); + assert_eq!(pool.get(&v6([0xfd00, 0, 0, 0, 0, 0, 0, 0x110])).unwrap().0, vec![0x00, 0x02]); +} + +#[test] +fn oro_ignored_dns_always_included() { + // Option Request asking only for DNS still gets the full success shape + // incl. DNS — the server does not tailor by ORO (documented). + let mut s = TestV6Server::new(); + let req = Packet { + msg_type: MsgType::Solicit, + transaction_id: 7, + options: vec![ + Dhcpv6Option::ClientId(vec![8]), + Dhcpv6Option::Unrecognized(dhcp4r::dhcpv6::RawDhcpv6Option { + code: OPT_ORO, + data: vec![0, 23], + }), + ], + }; + let rep = s.handle(&req).unwrap(); + assert!(rep.option(OPT_DNS_SERVERS).is_some()); + assert_eq!(codes(&rep), vec![2, 1, 3, 23]); +} diff --git a/tests/test_hostname.rs b/tests/test_hostname.rs new file mode 100644 index 0000000..9c0551f --- /dev/null +++ b/tests/test_hostname.rs @@ -0,0 +1,421 @@ +//! Tests for DHCP option 12 (Host Name) tracking in `examples/server.rs`. +//! +//! The library codec for option 12 already round-trips (see `test_packet.rs`); +//! what was missing was any *server-side* use: leases stored only +//! `(mac, expiry)`, and neither the server logs nor the monitor showed the +//! name. The example now stores the announced name per lease, refreshes it +//! whenever a packet carries one, and the monitor prints it. +//! +//! Following this repo's convention, `HostnameServer` below mirrors the +//! example's arms body-for-body (Discover incl. rapid-commit insert, +//! Request, Release) but returns values instead of sending on a socket. +//! Naming rule locked throughout: a packet *with* option 12 sets the stored +//! name (even to empty); a packet *without* it leaves the stored name +//! untouched, so renewals that omit it don't flap the recorded name. + +use dhcp4r::{options, packet}; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::ops::Add; +use std::time::{Duration, Instant}; + +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_SECS: u32 = 86400; + +/// Same body as the `hostname_of` helper in `examples/server.rs`. +fn hostname_of(in_packet: &packet::Packet) -> Option { + match in_packet.option(options::HOST_NAME) { + Some(options::DhcpOption::HostName(name)) => Some(name.clone()), + _ => None, + } +} + +/// Same display rule as `examples/monitor.rs`: "-" when absent. +fn display_name(name: &Option) -> String { + match name { + Some(n) => n.clone(), + _ => "-".to_string(), + } +} + +struct HostnameServer { + leases: HashMap, Option)>, + last_lease: u32, + lease_duration: Duration, + enable_rapid_commit: bool, +} + +impl HostnameServer { + fn new(rapid: bool) -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::from_secs(LEASE_SECS as u64), + enable_rapid_commit: rapid, + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry, _)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + fn remember_hostname(&mut self, ip: &Ipv4Addr, hostname: &Option) { + if let (Some(name), Some(entry)) = (hostname, self.leases.get_mut(ip)) { + entry.2 = Some(name.clone()); + } + } + fn name_of(&self, ip: &Ipv4Addr) -> Option> { + self.leases.get(ip).map(|(_, _, name)| name.clone()) + } + // Mirrors the Discover arm (rapid branch + legacy path), pure return. + fn discover(&mut self, p: &packet::Packet) -> Option { + let hostname = hostname_of(p); + if self.enable_rapid_commit && p.option(options::RAPID_COMMIT).is_some() { + if let Some(ip) = self.current_lease(&p.chaddr) { + self.remember_hostname(&ip, &hostname); + return Some(ip); + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&p.chaddr, &cand) { + self.leases.insert( + cand, + ( + p.chaddr, + Some(Instant::now().add(self.lease_duration)), + hostname.clone(), + ), + ); + return Some(cand); + } + } + return None; + } + if let Some(ip) = self.current_lease(&p.chaddr) { + self.remember_hostname(&ip, &hostname); + return Some(ip); + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&p.chaddr, &cand) { + return Some(cand); + } + } + None + } + // Mirrors the Request arm (RequestedIp else ciaddr; current wins with + // refresh; unavailable NAKs; else insert). for_this gate omitted like the + // example (commented out upstream). + fn request( + &mut self, + p: &packet::Packet, + requested: Option, + ) -> Result { + let hostname = hostname_of(p); + let req_ip = requested.unwrap_or(p.ciaddr); + if let Some(ip) = self.current_lease(&p.chaddr) { + self.remember_hostname(&ip, &hostname); + return Ok(ip); + } + if !self.available(&p.chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + ( + p.chaddr, + Some(Instant::now().add(self.lease_duration)), + hostname.clone(), + ), + ); + Ok(req_ip) + } + fn release(&mut self, chaddr: &[u8; 6], for_this: bool) -> bool { + if !for_this { + return false; + } + if let Some(ip) = self.current_lease(chaddr) { + self.leases.remove(&ip); + return true; + } + false + } +} + +fn discover_pkt(chaddr: [u8; 6], hostname: Option<&str>) -> packet::Packet { + let mut options = vec![options::DhcpOption::DhcpMessageType( + options::MessageType::Discover, + )]; + if let Some(name) = hostname { + options.push(options::DhcpOption::HostName(name.to_string())); + } + packet::Packet { + reply: false, + hops: 0, + xid: 1, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options, + } +} + +fn request_pkt( + chaddr: [u8; 6], + requested: Option, + hostname: Option<&str>, +) -> packet::Packet { + let mut options = vec![options::DhcpOption::DhcpMessageType( + options::MessageType::Request, + )]; + if let Some(ip) = requested { + options.push(options::DhcpOption::RequestedIpAddress(ip)); + } + if let Some(name) = hostname { + options.push(options::DhcpOption::HostName(name.to_string())); + } + packet::Packet { + reply: false, + hops: 0, + xid: 2, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options, + } +} + +/// Build a raw DHCP packet: 236-byte BOOTP header + cookie + options bytes. +fn make_raw(chaddr: [u8; 6], opts_after_cookie: Vec) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = 1; + v[1] = 1; + v[2] = 6; + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +fn unwrap_packet(r: Result>) -> packet::Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +// --------------------------------------------------------------------------- +// insert + refresh semantics +// --------------------------------------------------------------------------- + +#[test] +fn request_inserts_hostname() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 10); + let p = request_pkt([1; 6], Some(ip), Some("laptop")); + assert_eq!(s.request(&p, Some(ip)), Ok(ip)); + assert_eq!(s.name_of(&ip), Some(Some("laptop".to_string()))); +} + +#[test] +fn request_without_hostname_stores_none() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 11); + let p = request_pkt([2; 6], Some(ip), None); + assert_eq!(s.request(&p, Some(ip)), Ok(ip)); + assert_eq!(s.name_of(&ip), Some(None)); +} + +#[test] +fn request_empty_hostname_stored_verbatim() { + // Empty string decodes fine, so it is stored as-is (no normalization). + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 12); + let p = request_pkt([3; 6], Some(ip), Some("")); + assert_eq!(s.request(&p, Some(ip)), Ok(ip)); + assert_eq!(s.name_of(&ip), Some(Some("".to_string()))); +} + +#[test] +fn discover_refreshes_hostname_on_existing_lease() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 13); + assert_eq!(s.request(&request_pkt([4; 6], Some(ip), None), Some(ip)), Ok(ip)); + assert_eq!(s.name_of(&ip), Some(None)); + assert_eq!(s.leases.len(), 1); + // later Discover announces a name: same IP, name recorded, no new entry + assert_eq!(s.discover(&discover_pkt([4; 6], Some("laptop"))), Some(ip)); + assert_eq!(s.name_of(&ip), Some(Some("laptop".to_string()))); + assert_eq!(s.leases.len(), 1, "refresh must not duplicate the lease"); +} + +#[test] +fn discover_without_hostname_preserves_stored_name() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 14); + assert_eq!( + s.request(&request_pkt([5; 6], Some(ip), Some("kept")), Some(ip)), + Ok(ip) + ); + // renewals that omit option 12 must not clear the recorded name + assert_eq!(s.discover(&discover_pkt([5; 6], None)), Some(ip)); + assert_eq!(s.name_of(&ip), Some(Some("kept".to_string()))); +} + +#[test] +fn request_renew_refreshes_hostname() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 15); + assert_eq!(s.request(&request_pkt([6; 6], Some(ip), Some("old")), Some(ip)), Ok(ip)); + // renew path (current lease hit) with a new name updates it in place + assert_eq!( + s.request(&request_pkt([6; 6], Some(ip), Some("new")), Some(ip)), + Ok(ip) + ); + assert_eq!(s.name_of(&ip), Some(Some("new".to_string()))); + assert_eq!(s.leases.len(), 1); +} + +#[test] +fn request_renew_without_hostname_preserves_name() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 16); + assert_eq!(s.request(&request_pkt([7; 6], Some(ip), Some("kept")), Some(ip)), Ok(ip)); + assert_eq!(s.request(&request_pkt([7; 6], Some(ip), None), Some(ip)), Ok(ip)); + assert_eq!(s.name_of(&ip), Some(Some("kept".to_string()))); +} + +// --------------------------------------------------------------------------- +// lifecycle: release removes the name with the lease +// --------------------------------------------------------------------------- + +#[test] +fn release_removes_hostname_with_lease() { + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 17); + assert_eq!(s.request(&request_pkt([8; 6], Some(ip), Some("gone")), Some(ip)), Ok(ip)); + assert!(s.release(&[8; 6], true)); + assert_eq!(s.name_of(&ip), None, "name goes away with the lease"); + assert!(s.leases.is_empty()); + // the freed address is reusable and takes the next name cleanly + assert_eq!( + s.request(&request_pkt([9; 6], Some(ip), Some("next")), Some(ip)), + Ok(ip) + ); + assert_eq!(s.name_of(&ip), Some(Some("next".to_string()))); +} + +// --------------------------------------------------------------------------- +// rapid commit + misc +// --------------------------------------------------------------------------- + +#[test] +fn rapid_commit_insert_stores_hostname() { + let mut s = HostnameServer::new(true); + let mut p = discover_pkt([10; 6], Some("quick")); + p.options.push(options::DhcpOption::Unrecognized( + options::RawDhcpOption { code: options::RAPID_COMMIT, data: vec![] }, + )); + let ip = s.discover(&p).expect("rapid commit must offer"); + assert_eq!(s.name_of(&ip), Some(Some("quick".to_string()))); +} + +#[test] +fn rapid_existing_lease_refreshes_hostname() { + let mut s = HostnameServer::new(true); + let ip = Ipv4Addr::new(192, 168, 2, 18); + assert_eq!(s.request(&request_pkt([11; 6], Some(ip), None), Some(ip)), Ok(ip)); + let mut p = discover_pkt([11; 6], Some("renamed")); + p.options.push(options::DhcpOption::Unrecognized( + options::RawDhcpOption { code: options::RAPID_COMMIT, data: vec![] }, + )); + assert_eq!(s.discover(&p), Some(ip)); + assert_eq!(s.name_of(&ip), Some(Some("renamed".to_string()))); +} + +#[test] +fn hostname_255b_and_unicode_store_exactly() { + let mut s = HostnameServer::new(false); + let long = "x".repeat(255); + let ip = Ipv4Addr::new(192, 168, 2, 19); + assert_eq!( + s.request(&request_pkt([12; 6], Some(ip), Some(&long)), Some(ip)), + Ok(ip) + ); + assert_eq!(s.name_of(&ip), Some(Some(long))); + let ip2 = Ipv4Addr::new(192, 168, 2, 20); + assert_eq!( + s.request(&request_pkt([13; 6], Some(ip2), Some("büro-01")), Some(ip2)), + Ok(ip2) + ); + assert_eq!(s.name_of(&ip2), Some(Some("büro-01".to_string()))); +} + +#[test] +fn malformed_hostname_treated_as_absent() { + // Invalid UTF-8 in option 12 is rejected by the decoder, so the packet + // arrives with no HostName at all: existing names are preserved and no + // garbage is ever stored. + let raw = make_raw([14; 6], vec![53, 1, 1, 12, 2, 0xFF, 0xFE, 255]); + let p = unwrap_packet(packet::Packet::from(&raw)); + assert_eq!(hostname_of(&p), None); + // ...while a valid name on the wire extracts exactly + let raw2 = make_raw([14; 6], vec![53, 1, 1, 12, 3, b'f', b'o', b'o', 255]); + let q = unwrap_packet(packet::Packet::from(&raw2)); + assert_eq!(hostname_of(&q), Some("foo".to_string())); + // ...and a nameless packet never clears a stored name + let mut s = HostnameServer::new(false); + let ip = Ipv4Addr::new(192, 168, 2, 21); + assert_eq!(s.request(&request_pkt([14; 6], Some(ip), Some("kept")), Some(ip)), Ok(ip)); + let bare = discover_pkt([14; 6], None); + assert_eq!(s.discover(&bare), Some(ip)); + assert_eq!(s.name_of(&ip), Some(Some("kept".to_string()))); +} + +#[test] +fn two_clients_hostnames_isolated() { + let mut s = HostnameServer::new(false); + let a = Ipv4Addr::new(192, 168, 2, 22); + let b = Ipv4Addr::new(192, 168, 2, 23); + assert_eq!(s.request(&request_pkt([0xA0; 6], Some(a), Some("alpha")), Some(a)), Ok(a)); + assert_eq!(s.request(&request_pkt([0xB0; 6], Some(b), Some("beta")), Some(b)), Ok(b)); + assert_eq!(s.name_of(&a), Some(Some("alpha".to_string()))); + assert_eq!(s.name_of(&b), Some(Some("beta".to_string()))); + // refreshing one never touches the other + assert_eq!(s.discover(&discover_pkt([0xA0; 6], Some("alpha2"))), Some(a)); + assert_eq!(s.name_of(&a), Some(Some("alpha2".to_string()))); + assert_eq!(s.name_of(&b), Some(Some("beta".to_string()))); +} + +#[test] +fn monitor_display_fallback() { + assert_eq!(display_name(&None), "-"); + assert_eq!(display_name(&Some("laptop".to_string())), "laptop"); + assert_eq!(display_name(&Some("".to_string())), ""); +} diff --git a/tests/test_lease_logic.rs b/tests/test_lease_logic.rs new file mode 100644 index 0000000..9f61c5e --- /dev/null +++ b/tests/test_lease_logic.rs @@ -0,0 +1,183 @@ +//! Regression tests for the lease-management logic in `examples/server.rs`. +//! +//! The example server is not a library, so its `available` / `current_lease` +//! helpers are copied here verbatim (same constants, same expressions) to +//! lock their exact current behavior, including quirks: +//! - usable pool is `[IP_START_NUM, IP_START_NUM+LEASE_NUM)` (exclusive upper) +//! - infinite leases (`None` expiry, from the `leases` file) are reported as +//! `available` to *any* MAC (map_or(true)) — i.e. stealable (bug locked) +//! - expired leases are reusable; unexpired leases owned by another MAC block +//! - `current_lease` returns *some* IP for a MAC when present (HashMap order +//! means which one is unspecified when a MAC holds several) + +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::time::{Duration, Instant}; + +// --- copied constants from examples/server.rs --- +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const LEASE_NUM: u32 = 252; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); + +// --- copied helpers from examples/server.rs (renamed, same bodies) --- +fn available( + leases: &HashMap)>, + chaddr: &[u8; 6], + addr: &Ipv4Addr, +) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } +} + +fn current_lease( + leases: &HashMap)>, + chaddr: &[u8; 6], +) -> Option { + for (i, v) in leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None +} + +fn parse_leases_line(line: &str) -> Option<([u8; 6], Ipv4Addr)> { + // Mirrors the parsing loop in examples/server.rs main(). + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() != 2 { + return None; + } + let mac_parts: Vec = parts[0] + .split(':') + .filter_map(|part| u8::from_str_radix(part, 16).ok()) + .collect(); + if mac_parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + mac.copy_from_slice(&mac_parts); + let ip = parts[1].trim().parse::().ok()?; + Some((mac, ip)) +} + +#[test] +fn pool_boundaries_are_exact() { + let leases = HashMap::new(); + let mac = [1, 2, 3, 4, 5, 6]; + assert!(available(&leases, &mac, &Ipv4Addr::new(192, 168, 2, 2)), "first IP usable"); + assert!(available(&leases, &mac, &Ipv4Addr::new(192, 168, 2, 253)), "last IP (.2+251) usable"); + assert!(!available(&leases, &mac, &Ipv4Addr::new(192, 168, 2, 254)), "exclusive upper bound"); + assert!(!available(&leases, &mac, &Ipv4Addr::new(192, 168, 2, 1)), "server IP below pool"); + assert!(!available(&leases, &mac, &Ipv4Addr::new(192, 168, 2, 255)), "broadcast above pool"); + assert!(!available(&leases, &mac, &Ipv4Addr::new(192, 168, 3, 2)), "next subnet blocked"); + assert!(!available(&leases, &mac, &Ipv4Addr::new(10, 0, 0, 5)), "other subnet blocked"); +} + +#[test] +fn unleased_ip_in_pool_is_available() { + let leases = HashMap::new(); + assert!(available(&leases, &[9, 9, 9, 9, 9, 9], &Ipv4Addr::new(192, 168, 2, 100))); +} + +#[test] +fn own_lease_is_available_even_if_unexpired() { + let mac = [1, 2, 3, 4, 5, 6]; + let mut leases = HashMap::new(); + leases.insert( + Ipv4Addr::new(192, 168, 2, 10), + (mac, Some(Instant::now() + Duration::from_secs(3600))), + ); + assert!(available(&leases, &mac, &Ipv4Addr::new(192, 168, 2, 10))); +} + +#[test] +fn others_unexpired_lease_blocks() { + let mut leases = HashMap::new(); + leases.insert( + Ipv4Addr::new(192, 168, 2, 10), + ([1, 1, 1, 1, 1, 1], Some(Instant::now() + Duration::from_secs(3600))), + ); + assert!(!available(&leases, &[2, 2, 2, 2, 2, 2], &Ipv4Addr::new(192, 168, 2, 10))); +} + +#[test] +fn expired_lease_is_reusable_quirk() { + let mut leases = HashMap::new(); + leases.insert( + Ipv4Addr::new(192, 168, 2, 10), + ( + [1, 1, 1, 1, 1, 1], + Some(Instant::now() - Duration::from_secs(1)), + ), + ); + assert!(available(&leases, &[2, 2, 2, 2, 2, 2], &Ipv4Addr::new(192, 168, 2, 10))); +} + +#[test] +fn infinite_lease_is_reported_available_to_anyone_quirk() { + // Leases loaded from the `leases` file use None expiry (INFINITE_LEASE). + // map_or(true, ..) makes them look available even to a different MAC. + let mut leases = HashMap::new(); + leases.insert(Ipv4Addr::new(192, 168, 2, 90), ([0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D], None)); + assert!(available( + &leases, + &[0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D], + &Ipv4Addr::new(192, 168, 2, 90) + )); + assert!( + available(&leases, &[1, 2, 3, 4, 5, 6], &Ipv4Addr::new(192, 168, 2, 90)), + "infinite lease stealable (bug locked)" + ); +} + +#[test] +fn out_of_pool_never_available_even_if_unleased() { + let leases = HashMap::new(); + assert!(!available(&leases, &[1, 2, 3, 4, 5, 6], &Ipv4Addr::new(192, 168, 2, 1))); +} + +#[test] +fn current_lease_finds_mac() { + let mac_a = [1, 2, 3, 4, 5, 6]; + let mac_b = [9, 9, 9, 9, 9, 9]; + let mut leases = HashMap::new(); + leases.insert(Ipv4Addr::new(192, 168, 2, 10), (mac_a, None)); + leases.insert(Ipv4Addr::new(192, 168, 2, 11), (mac_b, None)); + assert_eq!(current_lease(&leases, &mac_a), Some(Ipv4Addr::new(192, 168, 2, 10))); + assert_eq!(current_lease(&leases, &mac_b), Some(Ipv4Addr::new(192, 168, 2, 11))); + assert_eq!(current_lease(&leases, &[0, 0, 0, 0, 0, 0]), None); +} + +#[test] +fn current_lease_empty_is_none() { + let leases: HashMap)> = HashMap::new(); + assert_eq!(current_lease(&leases, &[1, 2, 3, 4, 5, 6]), None); +} + +#[test] +fn leases_file_line_parsing_matches_example() { + // Example from README + let (mac, ip) = parse_leases_line("f4:5c:19:af:96:8d,192.168.2.90").unwrap(); + assert_eq!(mac, [0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D]); + assert_eq!(ip, Ipv4Addr::new(192, 168, 2, 90)); + + // whitespace around IP trimmed + let (_, ip) = parse_leases_line("aa:bb:cc:dd:ee:ff, 192.168.2.10 ").unwrap(); + assert_eq!(ip, Ipv4Addr::new(192, 168, 2, 10)); + + // malformed MAC -> None (filtered, line skipped) + assert!(parse_leases_line("zz:zz:zz:zz:zz:zz,192.168.2.10").is_none()); + assert!(parse_leases_line("aa:bb:cc,192.168.2.10").is_none()); + // wrong field count -> None + assert!(parse_leases_line("justonefield").is_none()); + assert!(parse_leases_line("a,b,c").is_none()); + // bad IP -> None (example would unwrap+panic; helper returns None to avoid panic in tests) + assert!(parse_leases_line("aa:bb:cc:dd:ee:ff,not-an-ip").is_none()); +} diff --git a/tests/test_ntp.rs b/tests/test_ntp.rs new file mode 100644 index 0000000..596da01 --- /dev/null +++ b/tests/test_ntp.rs @@ -0,0 +1,416 @@ +//! Tests for NTP server advertisement (DHCP option 42). +//! +//! There is no typed NTP variant in `DhcpOption` by design, so the example +//! server builds option 42 as an `Unrecognized` option with the same N*4-byte +//! layout as DNS servers. These tests lock that wire shape, the +//! `enable_ntp` + `ntp_ips` config rule (mirroring `examples/server.rs`), and +//! the interplay with PRL filtering and the 300-byte wire cap. +//! +//! Conventions: option 42 is never in the filter defaults +//! `[53,54,1,51,6,3]`, so with a PRL present it survives only when requested. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::time::Duration; + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +fn codes(opts: &[DhcpOption]) -> Vec { + opts.iter().map(|o| o.code()).collect() +} + +fn recv_with_timeout(sock: &UdpSocket, buf: &mut [u8]) -> (usize, std::net::SocketAddr) { + sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + sock.recv_from(buf).expect("timed out waiting for reply") +} + +/// Mirrors the example `MyServer::offer_options` NTP rule: option 42 is +/// built only when enabled AND the list is non-empty, concatenated N*4. +fn ntp_option(enable_ntp: bool, ntp_ips: &[Ipv4Addr]) -> Option { + if enable_ntp && !ntp_ips.is_empty() { + let mut data = Vec::with_capacity(ntp_ips.len() * 4); + for ip in ntp_ips { + data.extend_from_slice(&ip.octets()); + } + Some(DhcpOption::Unrecognized(RawDhcpOption { + code: NETWORK_TIME_PROTOCOL_SERVERS, + data, + })) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// wire shape (unit) +// --------------------------------------------------------------------------- + +#[test] +fn ntp_wire_format_is_code_42_len_4n() { + assert_eq!(NETWORK_TIME_PROTOCOL_SERVERS, 42); + let one = DhcpOption::Unrecognized(RawDhcpOption { + code: 42, + data: vec![192, 168, 2, 1], + }) + .to_raw(); + assert_eq!(one.code, 42); + assert_eq!(one.data, vec![192, 168, 2, 1]); + // round-trips through decode intact (like any Unrecognized option) + let p = test_packet( + 1, + [0; 6], + vec![ + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::Unrecognized(RawDhcpOption { + code: 42, + data: vec![10, 0, 0, 1, 10, 0, 0, 2], + }), + ], + ); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!( + q.option(42), + Some(&DhcpOption::Unrecognized(RawDhcpOption { + code: 42, + data: vec![10, 0, 0, 1, 10, 0, 0, 2], + })) + ); +} + +#[test] +fn ntp_decodes_as_unrecognized_not_typed() { + // No typed variant exists: decode must fall through to Unrecognized, + // which keeps the branch-matrix test's typed set unchanged. + match decode_option(&[42, 4, 192, 168, 2, 1]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 42); + assert_eq!(r.data, vec![192, 168, 2, 1]); + } + _ => panic!("option 42 must decode as Unrecognized"), + } +} + +#[test] +fn ntp_multi_server_concatenation_order() { + let ips = vec![ + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(10, 0, 0, 2), + Ipv4Addr::new(10, 0, 0, 3), + ]; + let opt = ntp_option(true, &ips).unwrap(); + let raw = opt.to_raw(); + assert_eq!(raw.code, 42); + assert_eq!(raw.data.len(), 12); + assert_eq!(&raw.data[0..4], &[10, 0, 0, 1]); + assert_eq!(&raw.data[4..8], &[10, 0, 0, 2]); + assert_eq!(&raw.data[8..12], &[10, 0, 0, 3]); +} + +#[test] +fn ntp_rule_disabled_or_empty_yields_nothing() { + assert!(ntp_option(false, &[Ipv4Addr::new(192, 168, 2, 1)]).is_none()); + assert!(ntp_option(true, &[]).is_none()); + assert!(ntp_option(false, &[]).is_none()); +} + +#[test] +fn ntp_rule_enabled_yields_concatenated() { + let opt = ntp_option(true, &[Ipv4Addr::new(192, 168, 2, 1)]).unwrap(); + assert_eq!(opt.code(), 42); + assert_eq!( + opt.to_raw().data, + vec![192, 168, 2, 1], + "single server, 4 bytes" + ); +} + +// --------------------------------------------------------------------------- +// reply behavior (live Server::reply over loopback, as in test_server.rs) +// --------------------------------------------------------------------------- + +#[test] +fn ntp_offered_without_prl_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(3600), + DhcpOption::Unrecognized(RawDhcpOption { + code: NETWORK_TIME_PROTOCOL_SERVERS, + data: vec![192, 168, 2, 1], + }), + ], + Ipv4Addr::new(10, 0, 0, 50), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // no PRL: everything kept in order after auto [53,54] + let req = test_packet( + 0xA001, + [1; 6], + vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + ); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(codes(&rep.options), vec![53, 54, 1, 51, 42]); + assert_eq!( + rep.option(42), + Some(&DhcpOption::Unrecognized(RawDhcpOption { + code: 42, + data: vec![192, 168, 2, 1], + })) + ); +} + +#[test] +fn ntp_filtered_without_prl_request_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Ack, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Unrecognized(RawDhcpOption { + code: NETWORK_TIME_PROTOCOL_SERVERS, + data: vec![192, 168, 2, 1], + }), + ], + Ipv4Addr::new(10, 0, 0, 50), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // PRL asks only for Subnet(1): 42 is neither requested nor default. + let req = Packet { + reply: false, hops: 0, xid: 0xA002, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [2; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::ParameterRequestList(vec![1]), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(codes(&rep.options), vec![1, 53, 54]); + assert!(rep.option(42).is_none(), "unrequested NTP must be filtered"); +} + +#[test] +fn ntp_kept_when_prl_requests_it_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Ack, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Unrecognized(RawDhcpOption { + code: NETWORK_TIME_PROTOCOL_SERVERS, + data: vec![192, 168, 2, 1], + }), + ], + Ipv4Addr::new(10, 0, 0, 50), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // PRL asks for 42 first: requested order, then defaults. + let req = Packet { + reply: false, hops: 0, xid: 0xA003, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [3; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::ParameterRequestList(vec![42]), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(codes(&rep.options), vec![42, 53, 54, 1]); + assert_eq!( + rep.option(42), + Some(&DhcpOption::Unrecognized(RawDhcpOption { + code: 42, + data: vec![192, 168, 2, 1], + })) + ); +} + +#[test] +fn ntp_two_servers_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![DhcpOption::Unrecognized(RawDhcpOption { + code: NETWORK_TIME_PROTOCOL_SERVERS, + data: vec![10, 0, 0, 1, 10, 0, 0, 2], + })], + Ipv4Addr::new(10, 0, 0, 50), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = test_packet( + 0xA004, + [4; 6], + vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + ); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(codes(&rep.options), vec![53, 54, 42]); + match rep.option(42) { + Some(DhcpOption::Unrecognized(r)) => { + assert_eq!(r.code, 42); + assert_eq!(r.data, vec![10, 0, 0, 1, 10, 0, 0, 2]); + } + _ => panic!("two-server NTP list must survive"), + } +} + +#[test] +fn ntp_full_example_set_live() { + // Mirrors the example reply order [lease, subnet, router, dns] + NTP: + // 240+3+6 (auto) +6+6+6+6 (lease/subnet/router/dns) +8 (NTP) = 287, + // which fits the 300-byte wire cap, so every TLV reaches the wire. + // Decoded, Router still poisons the option loop (decoder quirk), leaving + // [53,54,51,1] — the wire bytes are identical to before for that prefix. + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![ + DhcpOption::IpAddressLeaseTime(86400), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Router(vec![Ipv4Addr::new(10, 0, 0, 1)]), + DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]), + DhcpOption::Unrecognized(RawDhcpOption { + code: NETWORK_TIME_PROTOCOL_SERVERS, + data: vec![10, 0, 0, 2], + }), + ], + Ipv4Addr::new(10, 0, 0, 50), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = test_packet( + 0xA005, + [5; 6], + vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + ); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + // every TLV fits the 300-byte cap now: router, DNS and NTP on the wire. + assert!( + rbuf[..n].windows(6).any(|w| w == [3, 4, 10, 0, 0, 1]), + "router TLV on the wire" + ); + assert!( + rbuf[..n].windows(6).any(|w| w == [6, 4, 8, 8, 8, 8]), + "DNS reaches the wire under the 300 cap" + ); + assert!( + rbuf[..n].windows(6).any(|w| w == [42, 4, 10, 0, 0, 2]), + "NTP reaches the wire under the 300 cap" + ); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(codes(&rep.options), vec![53, 54, 51, 1]); + assert!(rep.option(42).is_none()); +} diff --git a/tests/test_options.rs b/tests/test_options.rs new file mode 100644 index 0000000..28a9389 --- /dev/null +++ b/tests/test_options.rs @@ -0,0 +1,354 @@ +//! Exact-functionality regression tests for `dhcp4r::options`. + +use dhcp4r::options::*; +use std::net::Ipv4Addr; + +#[test] +fn constants_have_exact_wire_values() { + assert_eq!(SUBNET_MASK, 1); + assert_eq!(TIME_OFFSET, 2); + assert_eq!(ROUTER, 3); + assert_eq!(TIME_SERVER, 4); + assert_eq!(NAME_SERVER, 5); + assert_eq!(DOMAIN_NAME_SERVER, 6); + assert_eq!(LOG_SERVER, 7); + assert_eq!(COOKIE_SERVER, 8); + assert_eq!(LPR_SERVER, 9); + assert_eq!(IMPRESS_SERVER, 10); + assert_eq!(RESOURCE_LOCATION_SERVER, 11); + assert_eq!(HOST_NAME, 12); + assert_eq!(BOOT_FILE_SIZE, 13); + assert_eq!(MERIT_DUMP_FILE, 14); + assert_eq!(DOMAIN_NAME, 15); + assert_eq!(SWAP_SERVER, 16); + assert_eq!(ROOT_PATH, 17); + assert_eq!(EXTENSIONS_PATH, 18); + assert_eq!(IP_FORWARDING_ENABLE_DISABLE, 19); + assert_eq!(NON_LOCAL_SOURCE_ROUTING_ENABLE_DISABLE, 20); + assert_eq!(POLICY_FILTER, 21); + assert_eq!(MAXIMUM_DATAGRAM_REASSEMBLY_SIZE, 22); + assert_eq!(DEFAULT_IP_TIME_TO_LIVE, 23); + assert_eq!(PATH_MTU_AGING_TIMEOUT, 24); + assert_eq!(PATH_MTU_PLATEAU_TABLE, 25); + assert_eq!(INTERFACE_MTU, 26); + assert_eq!(ALL_SUBNETS_ARE_LOCAL, 27); + assert_eq!(BROADCAST_ADDRESS, 28); + assert_eq!(PERFORM_MASK_DISCOVERY, 29); + assert_eq!(MASK_SUPPLIER, 30); + assert_eq!(PERFORM_ROUTER_DISCOVERY, 31); + assert_eq!(ROUTER_SOLICITATION_ADDRESS, 32); + assert_eq!(STATIC_ROUTE, 33); + assert_eq!(TRAILER_ENCAPSULATION, 34); + assert_eq!(ARP_CACHE_TIMEOUT, 35); + assert_eq!(ETHERNET_ENCAPSULATION, 36); + assert_eq!(TCP_DEFAULT_TTL, 37); + assert_eq!(TCP_KEEPALIVE_INTERVAL, 38); + assert_eq!(TCP_KEEPALIVE_GARBAGE, 39); + assert_eq!(NETWORK_INFORMATION_SERVICE_DOMAIN, 40); + assert_eq!(NETWORK_INFORMATION_SERVERS, 41); + assert_eq!(NETWORK_TIME_PROTOCOL_SERVERS, 42); + assert_eq!(VENDOR_SPECIFIC_INFORMATION, 43); + assert_eq!(NETBIOS_OVER_TCPIP_NAME_SERVER, 44); + assert_eq!(NETBIOS_OVER_TCPIP_DATAGRAM_DISTRIBUTION_SERVER, 45); + assert_eq!(NETBIOS_OVER_TCPIP_NODE_TYPE, 46); + assert_eq!(NETBIOS_OVER_TCPIP_SCOPE, 47); + assert_eq!(XWINDOW_SYSTEM_FONT_SERVER, 48); + assert_eq!(XWINDOW_SYSTEM_DISPLAY_MANAGER, 49); + assert_eq!(REQUESTED_IP_ADDRESS, 50); + assert_eq!(IP_ADDRESS_LEASE_TIME, 51); + assert_eq!(OVERLOAD, 52); + assert_eq!(DHCP_MESSAGE_TYPE, 53); + assert_eq!(SERVER_IDENTIFIER, 54); + assert_eq!(PARAMETER_REQUEST_LIST, 55); + assert_eq!(MESSAGE, 56); + assert_eq!(MAXIMUM_DHCP_MESSAGE_SIZE, 57); + assert_eq!(RENEWAL_TIME_VALUE, 58); + assert_eq!(REBINDING_TIME_VALUE, 59); + assert_eq!(VENDOR_CLASS_IDENTIFIER, 60); + assert_eq!(CLIENT_IDENTIFIER, 61); + assert_eq!(NETWORK_INFORMATION_SERVICEPLUS_DOMAIN, 64); + assert_eq!(NETWORK_INFORMATION_SERVICEPLUS_SERVERS, 65); + assert_eq!(TFTP_SERVER_NAME, 66); + assert_eq!(BOOTFILE_NAME, 67); + assert_eq!(MOBILE_IP_HOME_AGENT, 68); + assert_eq!(SIMPLE_MAIL_TRANSPORT_PROTOCOL, 69); + assert_eq!(POST_OFFICE_PROTOCOL_SERVER, 70); + assert_eq!(NETWORK_NEWS_TRANSPORT_PROTOCOL, 71); + assert_eq!(DEFAULT_WORLD_WIDE_WEB_SERVER, 72); + assert_eq!(DEFAULT_FINGER_SERVER, 73); + assert_eq!(DEFAULT_INTERNET_RELAY_CHAT_SERVER, 74); + assert_eq!(STREETTALK_SERVER, 75); + assert_eq!(STREETTALK_DIRECTORY_ASSISTANCE, 76); + assert_eq!(USER_CLASS, 77); + assert_eq!(RAPID_COMMIT, 80); + assert_eq!(RELAY_AGENT_INFORMATION, 82); + assert_eq!(CLIENT_ARCHITECTURE, 93); + assert_eq!(TZ_POSIX_STRING, 100); + assert_eq!(TZ_DATABASE_STRING, 101); + assert_eq!(CLASSLESS_ROUTE_FORMAT, 121); +} + +#[test] +fn title_returns_exact_strings_for_known_codes() { + let cases: &[(u8, &str)] = &[ + (1, "Subnet Mask"), + (2, "Time Offset"), + (3, "Router"), + (4, "Time Server"), + (5, "Name Server"), + (6, "Domain Name Server"), + (7, "Log Server"), + (8, "Cookie Server"), + (9, "LPR Server"), + (10, "Impress Server"), + (11, "Resource Location Server"), + (12, "Host Name"), + (13, "Boot File Size"), + (14, "Merit Dump File"), + (15, "Domain Name"), + (16, "Swap Server"), + (17, "Root Path"), + (18, "Extensions Path"), + (19, "IP Forwarding Enable/Disable"), + (20, "Non-Local Source Routing Enable/Disable"), + (21, "Policy Filter"), + (22, "Maximum Datagram Reassembly Size"), + (23, "Default IP Time-to-live"), + (24, "Path MTU Aging Timeout"), + (25, "Path MTU Plateau Table"), + (26, "Interface MTU"), + (27, "All Subnets are Local"), + (28, "Broadcast Address"), + (29, "Perform Mask Discovery"), + (30, "Mask Supplier"), + (31, "Perform Router Discovery"), + (32, "Router Solicitation Address"), + (33, "Static Route"), + (34, "Trailer Encapsulation"), + (35, "ARP Cache Timeout"), + (36, "Ethernet Encapsulation"), + (37, "TCP Default TTL"), + (38, "TCP Keepalive Interval"), + (39, "TCP Keepalive Garbage"), + (40, "Network Information Service Domain"), + (41, "Network Information Servers"), + (42, "Network Time Protocol Servers"), + (43, "Vendor Specific Information"), + (44, "NetBIOS over TCP/IP Name Server"), + (45, "NetBIOS over TCP/IP Datagram Distribution Server"), + (46, "NetBIOS over TCP/IP Node Type"), + (47, "NetBIOS over TCP/IP Scope"), + (48, "X Window System Font Server"), + (49, "X Window System Display Manager"), + (50, "Requested IP Address"), + (51, "IP Address Lease Time"), + (52, "Overload"), + (53, "DHCP Message Type"), + (54, "Server Identifier"), + (55, "Parameter Request List"), + (56, "Message"), + (57, "Maximum DHCP Message Size"), + (58, "Renewal (T1) Time Value"), + (59, "Rebinding (T2) Time Value"), + (60, "Vendor class identifier"), + (61, "Client-identifier"), + (64, "Network Information Service+ Domain"), + (65, "Network Information Service+ Servers"), + (66, "TFTP server name"), + (67, "Bootfile name"), + (68, "Mobile IP Home Agent"), + (69, "Simple Mail Transport Protocol (SMTP) Server"), + (70, "Post Office Protocol (POP3) Server"), + (71, "Network News Transport Protocol (NNTP) Server"), + (72, "Default World Wide Web (WWW) Server"), + (73, "Default Finger Server"), + (74, "Default Internet Relay Chat (IRC) Server"), + (75, "StreetTalk Server"), + (76, "StreetTalk Directory Assistance (STDA) Server"), + (77, "User Class"), + (80, "Rapid Commit"), + (82, "Relay Agent Information"), + (93, "Client Architecture"), + (100, "TZ-POSIX String"), + (101, "TZ-Database String"), + (121, "Classless Route Format"), + ]; + for (code, expected) in cases { + assert_eq!(title(*code), Some(*expected), "title({})", code); + } +} + +#[test] +fn title_returns_none_for_unknown_codes() { + for code in [0u8, 62, 63, 78, 79, 81, 83, 99, 122, 200, 254, 255] { + assert_eq!(title(code), None, "title({}) should be None", code); + } +} + +#[test] +fn message_type_from_valid_values() { + assert_eq!(MessageType::from(1), Ok(MessageType::Discover)); + assert_eq!(MessageType::from(2), Ok(MessageType::Offer)); + assert_eq!(MessageType::from(3), Ok(MessageType::Request)); + assert_eq!(MessageType::from(4), Ok(MessageType::Decline)); + assert_eq!(MessageType::from(5), Ok(MessageType::Ack)); + assert_eq!(MessageType::from(6), Ok(MessageType::Nak)); + assert_eq!(MessageType::from(7), Ok(MessageType::Release)); + assert_eq!(MessageType::from(8), Ok(MessageType::Inform)); +} + +#[test] +fn message_type_discriminants_match_wire() { + assert_eq!(MessageType::Discover as u8, 1); + assert_eq!(MessageType::Offer as u8, 2); + assert_eq!(MessageType::Request as u8, 3); + assert_eq!(MessageType::Decline as u8, 4); + assert_eq!(MessageType::Ack as u8, 5); + assert_eq!(MessageType::Nak as u8, 6); + assert_eq!(MessageType::Release as u8, 7); + assert_eq!(MessageType::Inform as u8, 8); +} + +#[test] +fn message_type_from_invalid_returns_err() { + for v in [0u8, 9, 10, 100, 255] { + let r = MessageType::from(v); + assert!(r.is_err(), "from({}) should err", v); + let msg = r.unwrap_err(); + assert!(msg.contains("Invalid DHCP Message Type"), "msg: {}", msg); + assert!(msg.contains(&v.to_string()), "msg should contain value: {}", msg); + } +} + +#[test] +fn dhcp_option_code_matches_wire() { + assert_eq!(DhcpOption::DhcpMessageType(MessageType::Discover).code(), 53); + assert_eq!(DhcpOption::ServerIdentifier(Ipv4Addr::new(1, 2, 3, 4)).code(), 54); + assert_eq!(DhcpOption::ParameterRequestList(vec![1]).code(), 55); + assert_eq!(DhcpOption::RequestedIpAddress(Ipv4Addr::new(1, 2, 3, 4)).code(), 50); + assert_eq!(DhcpOption::HostName("h".to_string()).code(), 12); + assert_eq!(DhcpOption::Router(vec![]).code(), 3); + assert_eq!(DhcpOption::DomainNameServer(vec![]).code(), 6); + assert_eq!(DhcpOption::IpAddressLeaseTime(1).code(), 51); + assert_eq!(DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)).code(), 1); + assert_eq!(DhcpOption::Message("m".to_string()).code(), 56); + assert_eq!( + DhcpOption::Unrecognized(RawDhcpOption { code: 99, data: vec![1] }).code(), + 99 + ); +} + +#[test] +fn dhcp_option_to_raw_message_type() { + for (t, v) in [ + (MessageType::Discover, 1), + (MessageType::Offer, 2), + (MessageType::Request, 3), + (MessageType::Decline, 4), + (MessageType::Ack, 5), + (MessageType::Nak, 6), + (MessageType::Release, 7), + (MessageType::Inform, 8), + ] { + let raw = DhcpOption::DhcpMessageType(t).to_raw(); + assert_eq!(raw.code, 53); + assert_eq!(raw.data, vec![v]); + } +} + +#[test] +fn dhcp_option_to_raw_ipv4_single() { + let raw = DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)).to_raw(); + assert_eq!(raw.code, 54); + assert_eq!(raw.data, vec![192, 168, 1, 1]); + + let raw = DhcpOption::RequestedIpAddress(Ipv4Addr::new(10, 0, 0, 5)).to_raw(); + assert_eq!(raw.code, 50); + assert_eq!(raw.data, vec![10, 0, 0, 5]); + + let raw = DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)).to_raw(); + assert_eq!(raw.code, 1); + assert_eq!(raw.data, vec![255, 255, 255, 0]); +} + +#[test] +fn dhcp_option_to_raw_prl_clones() { + let prl = vec![1u8, 3, 6, 51]; + let raw = DhcpOption::ParameterRequestList(prl.clone()).to_raw(); + assert_eq!(raw.code, 55); + assert_eq!(raw.data, prl); +} + +#[test] +fn dhcp_option_to_raw_hostname_and_message() { + let raw = DhcpOption::HostName("foo".to_string()).to_raw(); + assert_eq!(raw.code, 12); + assert_eq!(raw.data, b"foo".to_vec()); + + let raw = DhcpOption::HostName(String::new()).to_raw(); + assert_eq!(raw.code, 12); + assert!(raw.data.is_empty()); + + let raw = DhcpOption::Message("hello".to_string()).to_raw(); + assert_eq!(raw.code, 56); + assert_eq!(raw.data, b"hello".to_vec()); +} + +#[test] +fn dhcp_option_to_raw_router_concatenates() { + let raw = DhcpOption::Router(vec![]).to_raw(); + assert_eq!(raw.code, 3); + assert!(raw.data.is_empty()); + + let raw = DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1)]).to_raw(); + assert_eq!(raw.code, 3); + assert_eq!(raw.data, vec![192, 168, 1, 1]); + + let raw = DhcpOption::Router(vec![ + Ipv4Addr::new(192, 168, 1, 1), + Ipv4Addr::new(10, 0, 0, 1), + ]) + .to_raw(); + assert_eq!(raw.code, 3); + assert_eq!(raw.data, vec![192, 168, 1, 1, 10, 0, 0, 1]); +} + +#[test] +fn dhcp_option_to_raw_dns_concatenates() { + let raw = DhcpOption::DomainNameServer(vec![]).to_raw(); + assert_eq!(raw.code, 6); + assert!(raw.data.is_empty()); + + let raw = DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]).to_raw(); + assert_eq!(raw.code, 6); + assert_eq!(raw.data, vec![8, 8, 8, 8]); + + let raw = DhcpOption::DomainNameServer(vec![ + Ipv4Addr::new(8, 8, 8, 8), + Ipv4Addr::new(8, 8, 4, 4), + ]) + .to_raw(); + assert_eq!(raw.code, 6); + assert_eq!(raw.data, vec![8, 8, 8, 8, 8, 8, 4, 4]); +} + +#[test] +fn dhcp_option_to_raw_lease_time_is_big_endian() { + let raw = DhcpOption::IpAddressLeaseTime(86400).to_raw(); + assert_eq!(raw.code, 51); + assert_eq!(raw.data, 86400u32.to_be_bytes().to_vec()); + assert_eq!(raw.data, vec![0, 1, 81, 128]); + + let raw = DhcpOption::IpAddressLeaseTime(0).to_raw(); + assert_eq!(raw.data, vec![0, 0, 0, 0]); + + let raw = DhcpOption::IpAddressLeaseTime(u32::MAX).to_raw(); + assert_eq!(raw.data, vec![255, 255, 255, 255]); +} + +#[test] +fn dhcp_option_to_raw_unrecognized_clones() { + let inner = RawDhcpOption { code: 200, data: vec![1, 2, 3] }; + let raw = DhcpOption::Unrecognized(inner.clone()).to_raw(); + assert_eq!(raw, inner); +} diff --git a/tests/test_packet.rs b/tests/test_packet.rs new file mode 100644 index 0000000..c6c9f35 --- /dev/null +++ b/tests/test_packet.rs @@ -0,0 +1,699 @@ +//! Exact-functionality regression tests for `dhcp4r::packet`. +//! +//! Locks current wire format, including known quirks: +//! - unknown `op` decodes as request (`reply=false`) +//! - broadcast flag checks `flags & 128` (low byte 0x80), NOT RFC 0x8000, +//! so `encode(broadcast=true)` -> raw `[128,0]` -> `decode` yields `false` +//! - `decode_option` panics on END (255) as first byte +//! - `Router` / `DomainNameServer` decode ALWAYS fails with `InvalidHlen` +//! (custom_many0 expects NomError terminator but decode_ipv4 gives InvalidHlen) +//! - `Packet::from` swallows all option-level errors and succeeds with +//! truncated/empty options; it only errors on header/cookie/hlen issues +//! - missing END with exact consumption panics on `split_at(1)` + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use std::net::Ipv4Addr; + +// --------------------------------------------------------------------------- +// helpers (CustomErr has no Debug, so no unwrap()) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet, got Err"), + } +} + +fn is_invalid_hlen(r: &Result>) -> bool { + matches!(r, Err(CustomErr::InvalidHlen)) +} + +fn is_tag_error(r: &Result>) -> bool { + matches!(r, Err(CustomErr::NomError(_))) +} + +/// Build a raw DHCP packet: 236-byte BOOTP header + cookie + options bytes. +/// Header: op,htype=1,hlen,hops,xid,secs,flags,ci,yi,si,gi,chaddr(6)+pad. +#[allow(clippy::too_many_arguments)] +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + // 34..236 already zero (sname/file/chaddr-pad) + v.extend_from_slice(&[99, 130, 83, 99]); // magic cookie + v.extend_from_slice(&opts_after_cookie); + v +} + +fn make_simple_raw(op: u8, flags: [u8; 2], hlen: u8, opts: Vec) -> Vec { + make_raw( + op, + hlen, + 7, + 0x12345678, + 0x0102, + flags, + [1, 2, 3, 4], + [5, 6, 7, 8], + [9, 10, 11, 12], + [13, 14, 15, 16], + [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF], + opts, + ) +} + +fn opt_raw(code: u8, data: &[u8]) -> Vec { + let mut v = vec![code, data.len() as u8]; + v.extend_from_slice(data); + v +} + +// --------------------------------------------------------------------------- +// decode: header fields +// --------------------------------------------------------------------------- + +#[test] +fn decode_valid_discover_preserves_all_header_fields() { + let raw = make_raw( + 1, 6, 9, 0xAABBCCDD, 0x1122, [0, 0], + [192, 168, 1, 10], [192, 168, 1, 20], [192, 168, 1, 1], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + [vec![53, 1, 1], vec![255]].concat(), + ); + let p = unwrap_packet(Packet::from(&raw)); + assert!(!p.reply); + assert_eq!(p.hops, 9); + assert_eq!(p.xid, 0xAABBCCDD); + assert_eq!(p.secs, 0x1122); + assert!(!p.broadcast); + assert_eq!(p.ciaddr, Ipv4Addr::new(192, 168, 1, 10)); + assert_eq!(p.yiaddr, Ipv4Addr::new(192, 168, 1, 20)); + assert_eq!(p.siaddr, Ipv4Addr::new(192, 168, 1, 1)); + assert_eq!(p.giaddr, Ipv4Addr::new(0, 0, 0, 0)); + assert_eq!(p.chaddr, [1, 2, 3, 4, 5, 6]); + assert_eq!(p.options.len(), 1); + assert_eq!(p.options[0], DhcpOption::DhcpMessageType(MessageType::Discover)); +} + +#[test] +fn decode_reply_flag_op2_true_op1_false() { + let req = unwrap_packet(Packet::from(&make_simple_raw(1, [0, 0], 6, vec![53, 1, 1, 255]))); + assert!(!req.reply); + let rep = unwrap_packet(Packet::from(&make_simple_raw(2, [0, 0], 6, vec![53, 1, 2, 255]))); + assert!(rep.reply); +} + +#[test] +fn decode_unknown_op_treated_as_request_quirk() { + // Current code maps any non-2 op to false (TODO in source). + for op in [0u8, 3, 99, 255] { + let p = unwrap_packet(Packet::from(&make_simple_raw(op, [0, 0], 6, vec![53, 1, 1, 255]))); + assert!(!p.reply, "op {} should decode to reply=false", op); + } +} + +#[test] +fn decode_rejects_bad_hlen() { + for hlen in [0u8, 1, 5, 7, 16, 255] { + let raw = make_simple_raw(1, [0, 0], hlen, vec![53, 1, 1, 255]); + let r = Packet::from(&raw); + assert!(is_invalid_hlen(&r), "hlen {} should be InvalidHlen", hlen); + } +} + +#[test] +fn decode_rejects_short_header() { + assert!(is_invalid_hlen(&Packet::from(&[1u8; 10]))); + assert!(is_invalid_hlen(&Packet::from(&[0u8; 235]))); + assert!(is_invalid_hlen(&Packet::from(&[]))); +} + +#[test] +fn decode_rejects_bad_cookie() { + let mut raw = make_simple_raw(1, [0, 0], 6, vec![53, 1, 1, 255]); + raw[236] = 0; + raw[237] = 0; + raw[238] = 0; + raw[239] = 0; + let r = Packet::from(&raw); + assert!(is_tag_error(&r), "bad cookie should be NomError(Tag)"); +} + +#[test] +fn decode_broadcast_flag_checks_low_byte_quirk() { + // encode(true) writes flags [128,0] (0x8000) but decode checks `flags & 128` + // (0x0080), so it decodes to false. Only [x,128|..] decodes true. + let p1 = unwrap_packet(Packet::from(&make_simple_raw(1, [128, 0], 6, vec![53, 1, 1, 255]))); + assert!(!p1.broadcast, "[128,0] (0x8000) decodes to false (bug locked)"); + + let p2 = unwrap_packet(Packet::from(&make_simple_raw(1, [0, 128], 6, vec![53, 1, 1, 255]))); + assert!(p2.broadcast, "[0,128] (0x0080) decodes to true"); + + let p3 = unwrap_packet(Packet::from(&make_simple_raw(1, [0, 0], 6, vec![53, 1, 1, 255]))); + assert!(!p3.broadcast); + + let p4 = unwrap_packet(Packet::from(&make_simple_raw(1, [255, 255], 6, vec![53, 1, 1, 255]))); + assert!(p4.broadcast, "[255,255] contains 0x80 in low byte"); +} + +// --------------------------------------------------------------------------- +// decode_option direct: each type +// --------------------------------------------------------------------------- + +#[test] +fn decode_option_message_type_all_values() { + for (byte, expect) in [ + (1, MessageType::Discover), + (2, MessageType::Offer), + (3, MessageType::Request), + (4, MessageType::Decline), + (5, MessageType::Ack), + (6, MessageType::Nak), + (7, MessageType::Release), + (8, MessageType::Inform), + ] { + match decode_option(&[53, 1, byte]) { + Ok((_, DhcpOption::DhcpMessageType(t))) => assert_eq!(t, expect), + other => panic!("53,1,{} should Ok, got {:?}", byte, other.is_ok()), + } + } +} + +#[test] +fn decode_option_message_type_invalid_is_unrecognized() { + assert!(matches!( + decode_option(&[53, 1, 9]), + Err(CustomErr::UnrecognizedMessageType) + )); + assert!(matches!( + decode_option(&[53, 1, 0]), + Err(CustomErr::UnrecognizedMessageType) + )); + assert!(matches!( + decode_option(&[53, 1, 255]), + Err(CustomErr::UnrecognizedMessageType) + )); +} + +#[test] +fn decode_option_server_id_requested_ip_subnet() { + match decode_option(&[54, 4, 192, 168, 1, 1]) { + Ok((_, DhcpOption::ServerIdentifier(ip))) => assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 1)), + _ => panic!("server id failed"), + } + match decode_option(&[50, 4, 10, 0, 0, 5]) { + Ok((_, DhcpOption::RequestedIpAddress(ip))) => assert_eq!(ip, Ipv4Addr::new(10, 0, 0, 5)), + _ => panic!("requested ip failed"), + } + match decode_option(&[1, 4, 255, 255, 255, 0]) { + Ok((_, DhcpOption::SubnetMask(ip))) => assert_eq!(ip, Ipv4Addr::new(255, 255, 255, 0)), + _ => panic!("subnet failed"), + } +} + +#[test] +fn decode_option_ipv4_ignores_trailing_bytes_quirk() { + // decode_ipv4 only reads first 4 bytes, extra ignored. + match decode_option(&[54, 5, 192, 168, 1, 1, 9]) { + Ok((_, DhcpOption::ServerIdentifier(ip))) => assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 1)), + _ => panic!("should ignore trailing byte"), + } +} + +#[test] +fn decode_option_ipv4_short_is_invalid_hlen() { + assert!(matches!( + decode_option(&[54, 3, 1, 2, 3]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!( + decode_option(&[51, 2, 0, 1]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!(decode_option(&[53, 0]), Err(CustomErr::InvalidHlen))); + assert!(matches!(decode_option(&[53, 1]), Err(CustomErr::InvalidHlen))); + assert!(matches!(decode_option(&[]), Err(CustomErr::InvalidHlen))); + assert!(matches!(decode_option(&[53]), Err(CustomErr::InvalidHlen))); +} + +#[test] +fn decode_option_hostname_and_message() { + match decode_option(&[12, 3, 65, 66, 67]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, "ABC"), + _ => panic!("hostname failed"), + } + match decode_option(&[56, 5, 104, 101, 108, 108, 111]) { + Ok((_, DhcpOption::Message(s))) => assert_eq!(s, "hello"), + _ => panic!("message failed"), + } + assert!(matches!( + decode_option(&[12, 2, 0xFF, 0xFE]), + Err(CustomErr::NonUtf8String) + )); + assert!(matches!( + decode_option(&[56, 2, 0xFF, 0xFE]), + Err(CustomErr::NonUtf8String) + )); +} + +#[test] +fn decode_option_prl_and_lease_and_unrecognized() { + match decode_option(&[55, 3, 1, 3, 6]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert_eq!(v, vec![1, 3, 6]), + _ => panic!("prl failed"), + } + match decode_option(&[55, 0]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert!(v.is_empty()), + _ => panic!("empty prl failed"), + } + match decode_option(&[51, 4, 0, 0, 0, 10]) { + Ok((_, DhcpOption::IpAddressLeaseTime(t))) => assert_eq!(t, 10), + _ => panic!("lease failed"), + } + match decode_option(&[99, 2, 1, 2]) { + Ok((_, DhcpOption::Unrecognized(raw))) => { + assert_eq!(raw.code, 99); + assert_eq!(raw.data, vec![1, 2]); + } + _ => panic!("unrecognized failed"), + } +} + +#[test] +fn decode_option_router_and_dns_always_fail_quirk() { + // BUG LOCK: custom_many0(decode_ipv4) can never succeed because + // decode_ipv4 signals exhaustion with InvalidHlen, not NomError, + // so the combinator propagates Err instead of terminating. + // Even a single valid address fails. + assert!(matches!( + decode_option(&[3, 4, 192, 168, 1, 1]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!( + decode_option(&[3, 8, 192, 168, 1, 1, 10, 0, 0, 1]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!(decode_option(&[3, 0]), Err(CustomErr::InvalidHlen))); + assert!(matches!( + decode_option(&[6, 4, 8, 8, 8, 8]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!(decode_option(&[6, 0]), Err(CustomErr::InvalidHlen))); +} + +#[test] +#[should_panic] +fn decode_option_end_as_first_byte_panics_quirk() { + let _ = decode_option(&[255, 1, 1]); +} + +// --------------------------------------------------------------------------- +// Packet::from swallowing / END handling quirks +// --------------------------------------------------------------------------- + +#[test] +fn packet_from_swallows_invalid_msgtype_and_yields_empty_options() { + // decode_option would Err(UnrecognizedMessageType), but Packet::from + // catches it in `while let Ok` and still returns Ok with 0 options. + let raw = make_simple_raw(1, [0, 0], 6, vec![53, 1, 9, 255]); + let p = unwrap_packet(Packet::from(&raw)); + assert!(p.options.is_empty()); +} + +#[test] +fn packet_from_swallows_non_utf8_and_yields_empty_options() { + let raw = make_simple_raw(1, [0, 0], 6, vec![12, 2, 0xFF, 0xFE, 255]); + let p = unwrap_packet(Packet::from(&raw)); + assert!(p.options.is_empty()); +} + +#[test] +fn packet_from_swallows_router_and_yields_empty_options() { + let mut opts = opt_raw(3, &[192, 168, 1, 1]); + opts.push(255); + let raw = make_simple_raw(1, [0, 0], 6, opts); + let p = unwrap_packet(Packet::from(&raw)); + assert!(p.options.is_empty(), "router poisons whole list: {:?}", p.options); +} + +#[test] +fn packet_from_keeps_decodable_prefix_before_poison_option() { + // [MsgType, Subnet] decode, then Router fails -> first two kept. + let mut opts = vec![]; + opts.extend(opt_raw(53, &[1])); + opts.extend(opt_raw(1, &[255, 255, 255, 0])); + opts.extend(opt_raw(3, &[192, 168, 1, 1])); + opts.push(255); + let raw = make_simple_raw(1, [0, 0], 6, opts); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 2); + assert_eq!(p.options[0], DhcpOption::DhcpMessageType(MessageType::Discover)); + assert_eq!( + p.options[1], + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)) + ); +} + +#[test] +fn packet_from_truncated_option_swallows_and_succeeds() { + // len says 1 but no data and no END: decode_option fails, loop exits, + // split_at(1) skips one byte, Ok with empty options. + let raw = make_simple_raw(1, [0, 0], 6, vec![53, 1]); + let p = unwrap_packet(Packet::from(&raw)); + assert!(p.options.is_empty()); +} + +#[test] +#[should_panic] +fn packet_from_with_only_end_panics_quirk() { + let raw = make_simple_raw(1, [0, 0], 6, vec![255, 0, 0]); + let _ = Packet::from(&raw); +} + +#[test] +#[should_panic] +fn packet_from_missing_end_with_exact_consumption_panics_quirk() { + // One valid option with no END consumes all bytes -> rest empty -> split_at(1) panics. + let raw = make_simple_raw(1, [0, 0], 6, vec![53, 1, 1]); + let _ = Packet::from(&raw); +} + +#[test] +fn packet_from_ignores_trailing_bytes_after_end() { + let mut opts = opt_raw(53, &[1]); + opts.push(255); + opts.extend_from_slice(&[0, 0, 0, 99, 100]); // PAD + garbage ignored + let raw = make_simple_raw(1, [0, 0], 6, opts); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1); +} + +#[test] +fn packet_from_multiple_options_preserves_order() { + let mut opts = vec![]; + opts.extend(opt_raw(53, &[3])); + opts.extend(opt_raw(54, &[192, 168, 1, 1])); + opts.extend(opt_raw(55, &[1, 3, 6])); + opts.extend(opt_raw(50, &[192, 168, 1, 100])); + opts.extend(opt_raw(1, &[255, 255, 255, 0])); + opts.extend(opt_raw(51, &[0, 0, 0, 10])); + opts.extend(opt_raw(12, b"host")); + opts.extend(opt_raw(56, b"msg")); + opts.push(255); + let raw = make_simple_raw(1, [0, 0], 6, opts); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 8); + assert_eq!(p.options[0].code(), 53); + assert_eq!(p.options[1].code(), 54); + assert_eq!(p.options[2].code(), 55); + assert_eq!(p.options[3].code(), 50); + assert_eq!(p.options[4].code(), 1); + assert_eq!(p.options[5].code(), 51); + assert_eq!(p.options[6].code(), 12); + assert_eq!(p.options[7].code(), 56); +} + +// --------------------------------------------------------------------------- +// option() and message_type() +// --------------------------------------------------------------------------- + +#[test] +fn packet_option_finds_by_code() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + ], + }; + assert!(p.option(53).is_some()); + assert!(p.option(1).is_some()); + assert!(p.option(3).is_none()); + assert!(p.option(255).is_none()); +} + +#[test] +fn packet_message_type_ok() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + assert_eq!(p.message_type(), Ok(MessageType::Request)); +} + +#[test] +fn packet_message_type_missing_is_err() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![], + }; + let r = p.message_type(); + assert!(r.is_err()); + assert!(r.unwrap_err().contains("MessageType")); +} + +#[test] +fn packet_message_type_wrong_variant_is_err() { + // Manually inject Unrecognized with code 53 (decoder never produces this, + // but message_type() has a branch for it). + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![DhcpOption::Unrecognized(RawDhcpOption { code: 53, data: vec![1] })], + }; + let r = p.message_type(); + assert!(r.is_err()); + assert!(r.unwrap_err().contains("53")); +} + +// --------------------------------------------------------------------------- +// encode() +// --------------------------------------------------------------------------- + +#[test] +fn encode_header_bytes_are_exact() { + let p = Packet { + reply: true, hops: 9, xid: 0xAABBCCDD, secs: 0x1122, broadcast: false, + ciaddr: Ipv4Addr::new(1, 2, 3, 4), yiaddr: Ipv4Addr::new(5, 6, 7, 8), + siaddr: Ipv4Addr::new(9, 10, 11, 12), giaddr: Ipv4Addr::new(13, 14, 15, 16), + chaddr: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF], + options: vec![DhcpOption::DhcpMessageType(MessageType::Offer)], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + // op=2 (reply), htype always 1, hlen 6, hops + assert_eq!(enc[0], 2); + assert_eq!(enc[1], 1); + assert_eq!(enc[2], 6); + assert_eq!(enc[3], 9); + assert_eq!(&enc[4..8], &[0xAA, 0xBB, 0xCC, 0xDD]); + assert_eq!(&enc[8..10], &[0x11, 0x22]); + assert_eq!(&enc[10..12], &[0, 0]); // broadcast false + assert_eq!(&enc[12..16], &[1, 2, 3, 4]); + assert_eq!(&enc[16..20], &[5, 6, 7, 8]); + assert_eq!(&enc[20..24], &[9, 10, 11, 12]); + assert_eq!(&enc[24..28], &[13, 14, 15, 16]); + assert_eq!(&enc[28..34], &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + // sname/file/chaddr-pad zeroed + assert!(enc[34..236].iter().all(|&b| b == 0)); + // cookie + assert_eq!(&enc[236..240], &[99, 130, 83, 99]); + // option + END + assert_eq!(&enc[240..243], &[53, 1, 2]); + assert_eq!(enc[243], 255); + assert_eq!(enc.len(), 244); +} + +#[test] +fn encode_request_op_and_broadcast_flag() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: true, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(enc[0], 1); + assert_eq!(&enc[10..12], &[128, 0], "broadcast true must encode as [128,0]"); +} + +#[test] +fn encode_empty_options_yields_only_end() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(enc.len(), 241); + assert_eq!(enc[240], 255); +} + +#[test] +fn encode_pads_buffer_to_300_with_zero() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let mut buf = [0xFFu8; 1500]; + let len = p.encode(&mut buf).len(); + assert_eq!(len, 244); + // bytes after returned slice up to 300 must be PAD (0) + assert!(buf[len..300].iter().all(|&b| b == 0)); +} + +#[test] +fn encode_truncates_options_to_fit_300() { + // Each Unrecognized(10 bytes data) costs 12 bytes. Start 240, END 1. + // 240+12*4=288, next would be 300 >= 300 -> break. So 4 fit. + let many: Vec = (0..50) + .map(|i| DhcpOption::Unrecognized(RawDhcpOption { code: 100, data: vec![i; 10] })) + .collect(); + let p = Packet { + reply: true, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(1, 1, 1, 1), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: many, + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() <= 300, "len {}", enc.len()); + assert_eq!(enc.len(), 289); + assert_eq!(enc[enc.len() - 1], 255); + // only first four options present + assert_eq!(&enc[240..252], &[100, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); +} + +#[test] +fn encode_broadcast_roundtrip_loses_flag_quirk() { + let p = Packet { + reply: true, hops: 0, xid: 0x12345678, secs: 0, broadcast: true, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(192, 168, 1, 10), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [1, 2, 3, 4, 5, 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Offer)], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(&enc[10..12], &[128, 0]); + let dec = unwrap_packet(Packet::from(&enc)); + assert!(!dec.broadcast, "roundtrip of broadcast=true yields false (bug locked)"); +} + +#[test] +fn roundtrip_preserves_decodable_fields() { + // NOTE: total options must fit in the 300-byte packet (240 + opts + 1 END). + // 3+6+6+6+5 = 26 bytes -> 267 total, fits. Adding more would trigger + // truncation (see encode_truncates_options_to_fit_300). + let p = Packet { + reply: true, hops: 3, xid: 0xDEADBEEF, secs: 99, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 1, 10), yiaddr: Ipv4Addr::new(192, 168, 1, 20), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(192, 168, 1, 1), + chaddr: [10, 20, 30, 40, 50, 60], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(3600), + DhcpOption::ParameterRequestList(vec![1, 3, 6]), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.reply, p.reply); + assert_eq!(q.hops, p.hops); + assert_eq!(q.xid, p.xid); + assert_eq!(q.secs, p.secs); + assert_eq!(q.broadcast, p.broadcast); + assert_eq!(q.ciaddr, p.ciaddr); + assert_eq!(q.yiaddr, p.yiaddr); + assert_eq!(q.siaddr, p.siaddr); + assert_eq!(q.giaddr, p.giaddr); + assert_eq!(q.chaddr, p.chaddr); + assert_eq!(q.options, p.options); +} + +#[test] +fn roundtrip_preserves_hostname_message_requestedip_when_fitting() { + let p = Packet { + reply: false, hops: 0, xid: 0x99, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [1, 2, 3, 4, 5, 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::HostName("h".to_string()), + DhcpOption::Message("m".to_string()), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 20)), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.options, p.options); +} + +#[test] +fn roundtrip_loses_router_and_dns_quirk() { + let p = Packet { + reply: true, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(10, 0, 0, 2), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [1, 2, 3, 4, 5, 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Offer), + DhcpOption::ServerIdentifier(Ipv4Addr::new(10, 0, 0, 1)), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Router(vec![Ipv4Addr::new(10, 0, 0, 1)]), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + // raw bytes DO contain router option 3 ... + assert!(enc.windows(2).any(|w| w == [3, 4]), "raw must contain router"); + // ... but decode drops it and everything after it + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.options.len(), 3, "router + tail lost: {:?}", q.options); +} diff --git a/tests/test_packet_extra.rs b/tests/test_packet_extra.rs new file mode 100644 index 0000000..fe7a3d9 --- /dev/null +++ b/tests/test_packet_extra.rs @@ -0,0 +1,335 @@ +//! Extra packet coverage: header fields the decoder ignores, remainder +//! handling, empty/truncated options, PAD handling, truncation boundaries, +//! tiny-buffer panics, Debug/Clone traits and full MessageType round-trips. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use std::net::Ipv4Addr; + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn make_raw(op: u8, htype: u8, sname_fill: u8, opts: Vec) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = htype; + v[2] = 6; + v[3] = 0; + v[4..8].copy_from_slice(&0x01020304u32.to_be_bytes()); + v[28..34].copy_from_slice(&[1, 2, 3, 4, 5, 6]); + for b in v[34..236].iter_mut() { + *b = sname_fill; + } + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts); + v +} + +#[test] +fn decode_ignores_htype_any_value_succeeds() { + for htype in [0u8, 1, 2, 99, 255] { + let raw = make_raw(1, htype, 0, vec![53, 1, 1, 255]); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.chaddr, [1, 2, 3, 4, 5, 6], "htype {}", htype); + } +} + +#[test] +fn encode_always_writes_htype_1_even_after_garbage_htype_decode() { + let raw = make_raw(1, 99, 0, vec![53, 1, 1, 255]); + let p = unwrap_packet(Packet::from(&raw)); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(enc[1], 1, "encode must always write htype=1"); +} + +#[test] +fn decode_ignores_sname_file_chaddr_pad_garbage() { + let raw = make_raw(1, 1, 0xAB, vec![53, 1, 1, 255]); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.chaddr, [1, 2, 3, 4, 5, 6]); + assert_eq!(p.options.len(), 1); +} + +#[test] +fn decode_option_returns_correct_remainder_for_chaining() { + let (rest, opt) = match decode_option(&[53, 1, 1, 99, 2, 5, 6]) { + Ok(v) => v, + Err(_) => panic!("should decode"), + }; + assert_eq!(opt, DhcpOption::DhcpMessageType(MessageType::Discover)); + assert_eq!(rest, &[99, 2, 5, 6]); + let (rest2, opt2) = match decode_option(rest) { + Ok(v) => v, + Err(_) => panic!("second should decode"), + }; + assert_eq!( + opt2, + DhcpOption::Unrecognized(RawDhcpOption { code: 99, data: vec![5, 6] }) + ); + assert!(rest2.is_empty()); +} + +#[test] +fn decode_option_empty_string_options_are_ok() { + match decode_option(&[12, 0]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, ""), + _ => panic!("empty hostname should Ok"), + } + match decode_option(&[56, 0]) { + Ok((_, DhcpOption::Message(s))) => assert_eq!(s, ""), + _ => panic!("empty message should Ok"), + } + match decode_option(&[55, 0]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert!(v.is_empty()), + _ => panic!("empty prl should Ok"), + } + match decode_option(&[99, 0]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 99); + assert!(r.data.is_empty()); + } + _ => panic!("empty unrecognized should Ok"), + } +} + +#[test] +fn decode_option_pad_code_zero_is_unrecognized_not_special() { + // PAD (0) is only special after END (ignored). Before END it decodes + // as a normal Unrecognized option with length+data. + match decode_option(&[0, 1, 2]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 0); + assert_eq!(r.data, vec![2]); + } + _ => panic!("code 0 should be Unrecognized"), + } +} + +#[test] +fn decode_option_declared_len_longer_than_data_is_invalid_hlen() { + assert!(matches!( + decode_option(&[53, 2, 1]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!( + decode_option(&[55, 3, 1]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!( + decode_option(&[1, 4, 1, 2]), + Err(CustomErr::InvalidHlen) + )); +} + +#[test] +fn decode_option_msgtype_ignores_trailing_data_bytes() { + // len 2, first data byte decides type, second ignored. + match decode_option(&[53, 2, 1, 2]) { + Ok((_, DhcpOption::DhcpMessageType(t))) => assert_eq!(t, MessageType::Discover), + _ => panic!("should take first byte"), + } + // len 0 has no data byte -> InvalidHlen (not UnrecognizedMessageType) + assert!(matches!( + decode_option(&[53, 0]), + Err(CustomErr::InvalidHlen) + )); +} + +#[test] +fn decode_bad_cookie_is_specifically_tag_kind() { + let mut raw = make_raw(1, 1, 0, vec![53, 1, 1, 255]); + raw[236] = 0; + match Packet::from(&raw) { + Err(CustomErr::NomError((_, ErrorKind::Tag))) => {} + _ => panic!("bad cookie must be NomError(Tag)"), + } +} + +#[test] +fn packet_option_returns_first_when_duplicated() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(1, 1, 1, 1)), + DhcpOption::SubnetMask(Ipv4Addr::new(2, 2, 2, 2)), + ], + }; + assert_eq!( + p.option(1), + Some(&DhcpOption::SubnetMask(Ipv4Addr::new(1, 1, 1, 1))) + ); +} + +#[test] +fn packet_message_type_all_eight_variants() { + for (mt, byte) in [ + (MessageType::Discover, 1), + (MessageType::Offer, 2), + (MessageType::Request, 3), + (MessageType::Decline, 4), + (MessageType::Ack, 5), + (MessageType::Nak, 6), + (MessageType::Release, 7), + (MessageType::Inform, 8), + ] { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![DhcpOption::DhcpMessageType(mt)], + }; + assert_eq!(p.message_type(), Ok(mt), "byte {}", byte); + } +} + +#[test] +fn encode_truncation_boundary_299_fits_300_breaks() { + // start 240, Msg 3 -> 243. Second option 2+N. + // 243+2+54+1 = 300 fits (condition 243+2+54=299 <300). + // 243+2+55+1 = 301 would break (243+2+55=300 >=300). + let mk = |n: usize| Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::Unrecognized(RawDhcpOption { code: 200, data: vec![7u8; n] }), + ], + }; + let mut buf = [0u8; 1500]; + let enc54 = mk(54).encode(&mut buf).to_vec(); + assert_eq!(enc54.len(), 300, "54-byte data must exactly fill to 300"); + assert!(enc54.windows(2).any(|w| w == [200, 54])); + assert_eq!(enc54[enc54.len() - 1], 255); + + let enc55 = mk(55).encode(&mut buf).to_vec(); + // 55-byte option does NOT fit, only Msg + END remain. + assert_eq!(enc55.len(), 244, "55-byte data must be truncated, len={}", enc55.len()); + assert!(!enc55.windows(2).any(|w| w == [200, 55])); +} + +#[test] +#[should_panic] +fn encode_tiny_buffer_panics_quirk() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![], + }; + let mut tiny = [0u8; 10]; + let _ = p.encode(&mut tiny); +} + +#[test] +fn encode_overwrites_prior_garbage_in_padding_with_zero() { + let p = Packet { + reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![], + }; + let mut buf = [0xFFu8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc[34..236].iter().all(|&b| b == 0)); + assert!(buf[enc.len()..300].iter().all(|&b| b == 0)); +} + +#[test] +fn encode_decode_xid_secs_edge_values() { + for (xid, secs) in [(0u32, 0u16), (u32::MAX, u16::MAX), (0x01020304, 0x0506)] { + let p = Packet { + reply: true, hops: 255, xid, secs, broadcast: false, + ciaddr: Ipv4Addr::new(255, 255, 255, 255), + yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), + giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [255; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Ack)], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(&enc[4..8], &xid.to_be_bytes()); + assert_eq!(&enc[8..10], &secs.to_be_bytes()); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.xid, xid); + assert_eq!(q.secs, secs); + assert_eq!(q.hops, 255); + } +} + +#[test] +fn all_message_types_roundtrip() { + for mt in [ + MessageType::Discover, MessageType::Offer, MessageType::Request, + MessageType::Decline, MessageType::Ack, MessageType::Nak, + MessageType::Release, MessageType::Inform, + ] { + let p = Packet { + reply: mt as u8 % 2 == 0, hops: 0, xid: 0x42, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![DhcpOption::DhcpMessageType(mt)], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.message_type(), Ok(mt)); + } +} + +#[test] +fn options_clone_and_debug_locked() { + let raw = RawDhcpOption { code: 7, data: vec![1, 2] }; + assert_eq!(raw.clone(), raw); + assert!(format!("{:?}", raw).contains("7")); + + let opt = DhcpOption::DhcpMessageType(MessageType::Discover); + assert!(format!("{:?}", opt).contains("Discover")); + + let mt = MessageType::Offer; + let mt2 = mt; // Copy + assert_eq!(mt, mt2); + assert!(format!("{:?}", mt).contains("Offer")); + + let p = Packet { + reply: true, hops: 0, xid: 1, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], options: vec![], + }; + assert!(format!("{:?}", p).contains("reply")); +} + +#[test] +fn title_exhaustive_all_256_codes() { + // Exactly the known codes return Some, all others None (80 = Rapid Commit). + let known: std::collections::HashSet = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, + 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, + 58, 59, 60, 61, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, + 80, 82, 93, 100, 101, 121, + ] + .iter() + .cloned() + .collect(); + assert_eq!(known.len(), 81); + for code in 0u8..=255 { + if known.contains(&code) { + assert!(title(code).is_some(), "code {} should have title", code); + } else { + assert!(title(code).is_none(), "code {} should be None", code); + } + } +} diff --git a/tests/test_rapid_commit.rs b/tests/test_rapid_commit.rs new file mode 100644 index 0000000..6012c8a --- /dev/null +++ b/tests/test_rapid_commit.rs @@ -0,0 +1,685 @@ +//! Tests for DHCP Rapid Commit (RFC 4039 option 80 for IPv4, RFC 8415 +//! option 14 for IPv6), gated by `enable_rapid_commit` in `dhcp.conf`. +//! +//! Design under test (mirrors `examples/server.rs`): with the flag off, or +//! without the option on the wire, behavior is exactly the legacy 4-message +//! (Discover->Offer) / Solicit->Advertise flow. With both present, the +//! server commits immediately (Ack/Reply: 2-message exchange). Both options +//! decode as `Unrecognized` — there is deliberately no typed variant, so the +//! branch-matrix test's typed set is unchanged. + +use dhcp4r::{dhcpv6, options, packet, server}; +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +fn unwrap_packet(r: Result>) -> packet::Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> packet::Packet { + packet::Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +fn recv_with_timeout(sock: &UdpSocket, buf: &mut [u8]) -> (usize, std::net::SocketAddr) { + sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + sock.recv_from(buf).expect("timed out waiting for reply") +} + +fn rapid_opt() -> options::DhcpOption { + options::DhcpOption::Unrecognized(options::RawDhcpOption { + code: options::RAPID_COMMIT, + data: vec![], + }) +} + +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +// --------------------------------------------------------------------------- +// v4 wire: option 80 is Unrecognized, no typed variant by design +// --------------------------------------------------------------------------- + +#[test] +fn rapid_commit_v4_const_and_title() { + assert_eq!(options::RAPID_COMMIT, 80); + assert_eq!(options::title(80), Some("Rapid Commit")); +} + +#[test] +fn rapid_commit_v4_decodes_as_unrecognized() { + // empty (the RFC 4039 shape) and non-empty payloads alike passthrough + for data in [vec![], vec![1, 2, 3]] { + let mut wire = vec![80, data.len() as u8]; + wire.extend_from_slice(&data); + match packet::decode_option(&wire) { + Ok((rest, options::DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 80); + assert_eq!(r.data, data); + assert!(rest.is_empty()); + } + _ => panic!("option 80 must decode as Unrecognized"), + } + } + // round-trips through a full packet like any Unrecognized option + let p = test_packet( + 1, + [0; 6], + vec![ + options::DhcpOption::DhcpMessageType(options::MessageType::Discover), + rapid_opt(), + ], + ); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(packet::Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.option(80), Some(&rapid_opt())); +} + +// --------------------------------------------------------------------------- +// v4 live: rapid-aware handler replica mirroring the example Discover arm +// --------------------------------------------------------------------------- + +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_SECS: u32 = 86400; + +struct RapidV4 { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, + enable_rapid_commit: bool, +} + +impl RapidV4 { + fn new(enabled: bool) -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::from_secs(LEASE_SECS as u64), + enable_rapid_commit: enabled, + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for RapidV4 { + fn handle_request(&mut self, server: &server::Server, in_packet: packet::Packet) { + match in_packet.message_type() { + Ok(options::MessageType::Discover) => { + // Rapid Commit (RFC 4039): commit immediately with an Ack. + if self.enable_rapid_commit + && in_packet.option(options::RAPID_COMMIT).is_some() + { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + options::MessageType::Ack, + vec![options::DhcpOption::IpAddressLeaseTime(LEASE_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + self.leases.insert( + cand, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + options::MessageType::Ack, + vec![options::DhcpOption::IpAddressLeaseTime(LEASE_SECS)], + cand, + in_packet, + ); + break; + } + } + return; + } + // Legacy path: prefer existing, else round-robin Offer. + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + options::MessageType::Offer, + vec![options::DhcpOption::IpAddressLeaseTime(LEASE_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + options::MessageType::Offer, + vec![options::DhcpOption::IpAddressLeaseTime(LEASE_SECS)], + cand, + in_packet, + ); + break; + } + } + } + _ => {} + } + } +} + +fn serve_rapid(enabled: bool) -> std::net::SocketAddr { + serve_rapid_with_leases(enabled, HashMap::new()) +} + +fn serve_rapid_with_leases( + enabled: bool, + leases: HashMap)>, +) -> std::net::SocketAddr { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let mut handler = RapidV4::new(enabled); + handler.leases = leases; + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(10, 0, 0, 255), + handler, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + srv_addr +} + +fn rapid_discover(xid: u32, chaddr: [u8; 6]) -> packet::Packet { + test_packet( + xid, + chaddr, + vec![ + options::DhcpOption::DhcpMessageType(options::MessageType::Discover), + rapid_opt(), + ], + ) +} + +#[test] +fn rapid_discover_acks_and_inserts_live() { + let srv_addr = serve_rapid(true); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + client + .send_to(&rapid_discover(0xB001, [1; 6]).encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(packet::Packet::from(&rbuf[..n])); + // 2-message exchange: Ack (not Offer) straight away... + assert_eq!(rep.message_type(), Ok(options::MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + // ...and the lease was committed: a plain Discover is offered the same IP + // via the current-lease path instead of rotating to .4. + let plain = test_packet( + 0xB002, + [1; 6], + vec![options::DhcpOption::DhcpMessageType(options::MessageType::Discover)], + ); + client.send_to(&plain.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + assert_eq!( + unwrap_packet(packet::Packet::from(&rbuf[..n])).yiaddr, + Ipv4Addr::new(192, 168, 2, 3) + ); +} + +#[test] +fn rapid_disabled_ignores_option_live() { + let srv_addr = serve_rapid(false); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // flag off: rapid-looking Discover is a plain Discover (Offer + no insert) + client + .send_to(&rapid_discover(0xB010, [2; 6]).encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(packet::Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(options::MessageType::Offer)); + // no insert happened: next Discover rotates instead of sticking + let plain = test_packet( + 0xB011, + [2; 6], + vec![options::DhcpOption::DhcpMessageType(options::MessageType::Discover)], + ); + client.send_to(&plain.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + assert_eq!( + unwrap_packet(packet::Packet::from(&rbuf[..n])).yiaddr, + Ipv4Addr::new(192, 168, 2, 4) + ); +} + +#[test] +fn rapid_without_option_is_plain_discover_live() { + let srv_addr = serve_rapid(true); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // flag on but no option 80 on the wire: legacy Offer path + let plain = test_packet( + 0xB020, + [3; 6], + vec![options::DhcpOption::DhcpMessageType(options::MessageType::Discover)], + ); + client.send_to(&plain.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + assert_eq!( + unwrap_packet(packet::Packet::from(&rbuf[..n])).message_type(), + Ok(options::MessageType::Offer) + ); +} + +#[test] +fn rapid_pool_exhausted_silent_live() { + // fill every address with other MACs so nothing is available + let mut leases = HashMap::new(); + for i in 0..LEASE_NUM { + let ip: Ipv4Addr = (IP_START_NUM + i).into(); + leases.insert( + ip, + ( + [0xCC, 0xDD, (i >> 8) as u8, i as u8, 0xEE, 0xFF], + Some(Instant::now() + Duration::from_secs(3600)), + ), + ); + } + let srv_addr = serve_rapid_with_leases(true, leases); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(400))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + client + .send_to(&rapid_discover(0xB030, [4; 6]).encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "exhausted rapid Discover must get no reply, like Discover" + ); +} + +#[test] +fn rapid_existing_lease_acked_without_moving_counter() { + let srv_addr = serve_rapid(true); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // take .3 via rapid commit first + client + .send_to(&rapid_discover(0xB040, [5; 6]).encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + assert_eq!( + unwrap_packet(packet::Packet::from(&rbuf[..n])).yiaddr, + Ipv4Addr::new(192, 168, 2, 3) + ); + // rapid Discover again (even naming another IP): Ack for the held lease. + // Requested IP is ignored on the rapid path by design (Discover-style + // selection), so the stray option must not steer the answer. + let mut opts = vec![ + options::DhcpOption::DhcpMessageType(options::MessageType::Discover), + rapid_opt(), + ]; + opts.push(options::DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 99))); + let retry = test_packet(0xB041, [5; 6], opts); + client.send_to(&retry.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(packet::Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(options::MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3), "held lease wins"); +} + +#[test] +fn rapid_truncated_option_falls_back_to_offer_live() { + let srv_addr = serve_rapid(true); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut rbuf = [0u8; 1500]; + // option 80 declares len 3 but only 1 byte follows: InvalidHlen swallows + // it, so option(80) is absent and the normal Offer path runs, not Ack. + let raw = make_raw( + 1, 6, 0, 0xB050, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [6; 6], + vec![53, 1, 1, 80, 3, 0xAA, 255], + ); + client.send_to(&raw, srv_addr).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("reply"); + let rep = unwrap_packet(packet::Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(options::MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); +} + +// --------------------------------------------------------------------------- +// v6: option 14 handling + rapid TestV6Server replica +// --------------------------------------------------------------------------- + +const V6_SERVER_DUID: [u8; 10] = + [0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc]; +const V6_LEASE_SECS: u32 = 86400; + +fn v6(a: [u16; 8]) -> Ipv6Addr { + Ipv6Addr::new(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]) +} + +fn v6pool() -> dhcpv6::V6Pool { + dhcpv6::V6Pool::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), + 1000, + Duration::from_secs(V6_LEASE_SECS as u64), + ) +} + +fn v6ip(n: u16) -> Ipv6Addr { + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, n) +} + +fn rapid6_opt() -> dhcpv6::Dhcpv6Option { + dhcpv6::Dhcpv6Option::Unrecognized(dhcpv6::RawDhcpv6Option { + code: dhcpv6::OPT_RAPID_COMMIT, + data: vec![], + }) +} + +struct RapidV6Server { + pool: dhcpv6::V6Pool, + server_duid: Vec, + lease_secs: u32, + dns: Vec, + enable_rapid_commit: bool, +} + +impl RapidV6Server { + fn new(enabled: bool) -> Self { + Self { + pool: v6pool(), + server_duid: V6_SERVER_DUID.to_vec(), + lease_secs: V6_LEASE_SECS, + dns: vec![v6([0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888])], + enable_rapid_commit: enabled, + } + } + + fn success( + &self, + req: &dhcpv6::Packet, + msg_type: dhcpv6::MsgType, + ip: Ipv6Addr, + ) -> dhcpv6::Packet { + let iaid = req.first_iana().map(|ia| ia.iaid).unwrap_or(0); + let (t1, t2) = dhcpv6::default_t1_t2(self.lease_secs); + let duid = match req.option(dhcpv6::OPT_CLIENTID) { + Some(dhcpv6::Dhcpv6Option::ClientId(d)) => d.clone(), + _ => vec![], + }; + let mut options = vec![ + dhcpv6::Dhcpv6Option::ServerId(self.server_duid.clone()), + dhcpv6::Dhcpv6Option::ClientId(duid), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid, + t1, + t2, + addrs: vec![dhcpv6::IaAddr { + addr: ip, + preferred: self.lease_secs, + valid: self.lease_secs, + }], + }), + ]; + if !self.dns.is_empty() { + options.push(dhcpv6::Dhcpv6Option::DnsServers(self.dns.clone())); + } + dhcpv6::Packet { msg_type, transaction_id: req.transaction_id, options } + } + + fn nodata(&self, req: &dhcpv6::Packet, msg_type: dhcpv6::MsgType) -> dhcpv6::Packet { + let iaid = req.first_iana().map(|ia| ia.iaid).unwrap_or(0); + let duid = match req.option(dhcpv6::OPT_CLIENTID) { + Some(dhcpv6::Dhcpv6Option::ClientId(d)) => d.clone(), + _ => vec![], + }; + dhcpv6::Packet { + msg_type, + transaction_id: req.transaction_id, + options: vec![ + dhcpv6::Dhcpv6Option::ServerId(self.server_duid.clone()), + dhcpv6::Dhcpv6Option::ClientId(duid), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { + iaid, + t1: 0, + t2: 0, + addrs: vec![], + }), + dhcpv6::Dhcpv6Option::StatusCode( + dhcpv6::STATUS_NO_ADDRS_AVAIL, + "NoAddrsAvail".to_string(), + ), + ], + } + } + + // Mirrors the updated examples/server.rs serve_v6 Solicit arm, pure return. + fn handle_solicit(&mut self, req: &dhcpv6::Packet) -> Option { + let duid = match req.option(dhcpv6::OPT_CLIENTID) { + Some(dhcpv6::Dhcpv6Option::ClientId(d)) => d.clone(), + _ => return None, + }; + debug_assert_eq!(req.msg_type, dhcpv6::MsgType::Solicit); + if self.enable_rapid_commit && req.option(dhcpv6::OPT_RAPID_COMMIT).is_some() { + return match self.pool.discover(&duid) { + Some(offered) => match self.pool.request(&duid, offered) { + Ok(ip) => Some(self.success(req, dhcpv6::MsgType::Reply, ip)), + Err(_) => Some(self.nodata(req, dhcpv6::MsgType::Reply)), + }, + None => Some(self.nodata(req, dhcpv6::MsgType::Reply)), + }; + } + match self.pool.discover(&duid) { + Some(ip) => Some(self.success(req, dhcpv6::MsgType::Advertise, ip)), + None => Some(self.nodata(req, dhcpv6::MsgType::Advertise)), + } + } +} + +fn solicit6(duid_bytes: &[u8], iaid: u32, rapid: bool) -> dhcpv6::Packet { + let mut options = vec![ + dhcpv6::Dhcpv6Option::ClientId(duid_bytes.to_vec()), + dhcpv6::Dhcpv6Option::IaNa(dhcpv6::IaNa { iaid, t1: 0, t2: 0, addrs: vec![] }), + ]; + if rapid { + options.push(rapid6_opt()); + } + dhcpv6::Packet { msg_type: dhcpv6::MsgType::Solicit, transaction_id: 0x112233, options } +} + +fn v6codes(p: &dhcpv6::Packet) -> Vec { + p.options.iter().map(|o| o.code()).collect() +} + +#[test] +fn v6_rapid_title_present() { + assert_eq!(dhcpv6::title(dhcpv6::OPT_RAPID_COMMIT), Some("Rapid Commit")); +} + +#[test] +fn v6_rapid_solicit_replies_committed() { + let mut s = RapidV6Server::new(true); + let rep = s.handle_solicit(&solicit6(&[1], 0x01020304, true)).unwrap(); + assert_eq!(rep.msg_type, dhcpv6::MsgType::Reply, "rapid commit skips Advertise"); + assert_eq!(rep.transaction_id, 0x112233); + assert_eq!(v6codes(&rep), vec![2, 1, 3, 23]); + match rep.option(dhcpv6::OPT_IA_NA) { + Some(dhcpv6::Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0x01020304, "request IAID echoed"); + assert_eq!(ia.addrs.len(), 1); + assert_eq!(ia.addrs[0].addr, v6ip(0x101), "first free past start"); + assert_eq!((ia.addrs[0].preferred, ia.addrs[0].valid), (86400, 86400)); + assert_eq!((ia.t1, ia.t2), (43200, 75600)); + } + _ => panic!("Reply must carry IA_NA"), + } + assert!(rep.option(dhcpv6::OPT_STATUS_CODE).is_none()); + // committed server-side: pool holds it for this DUID + assert_eq!(s.pool.current_lease(&[1]), Some(v6ip(0x101))); +} + +#[test] +fn v6_solicit_without_rapid_advertises() { + let mut s = RapidV6Server::new(true); + let rep = s.handle_solicit(&solicit6(&[2], 0x01020304, false)).unwrap(); + assert_eq!(rep.msg_type, dhcpv6::MsgType::Advertise, "no option 14, no rapid path"); + assert!(s.pool.is_empty()); +} + +#[test] +fn v6_rapid_disabled_ignores_option() { + let mut s = RapidV6Server::new(false); + let rep = s.handle_solicit(&solicit6(&[3], 0x01020304, true)).unwrap(); + assert_eq!(rep.msg_type, dhcpv6::MsgType::Advertise, "flag off: legacy flow"); + assert!(s.pool.is_empty()); +} + +#[test] +fn v6_rapid_exhausted_nodata_reply() { + let mut s = RapidV6Server::new(true); + // fill first 10 of the pool directly (fast, no network) + for i in 0u8..10 { + let ip = v6ip(0x100 + i as u16); + s.pool.request(&[0xA0 + i], ip).unwrap(); + } + // exhaust the rest through discover+request pairs is slow; instead fill + // everything except one chunk is overkill — fill via direct inserts. + for i in 10u16..1000 { + s.pool.insert( + v6ip(0x100 + i), + vec![0xB0, (i & 0xFF) as u8], + Some(Instant::now() + Duration::from_secs(3600)), + ); + } + assert_eq!(s.pool.len(), 1000); + let rep = s.handle_solicit(&solicit6(&[9], 0x09090909, true)).unwrap(); + assert_eq!(rep.msg_type, dhcpv6::MsgType::Reply, "rapid failure is still a Reply"); + assert_eq!(v6codes(&rep), vec![2, 1, 3, 13]); + match rep.option(dhcpv6::OPT_IA_NA) { + Some(dhcpv6::Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0x09090909); + assert!(ia.addrs.is_empty()); + } + _ => panic!("nodata Reply keeps an empty IA_NA"), + } + assert_eq!( + rep.option(dhcpv6::OPT_STATUS_CODE), + Some(&dhcpv6::Dhcpv6Option::StatusCode( + dhcpv6::STATUS_NO_ADDRS_AVAIL, + "NoAddrsAvail".to_string() + )) + ); +} + +#[test] +fn v6_rapid_without_iana_acks_iaid_zero() { + let mut s = RapidV6Server::new(true); + // ClientId + rapid option but no IA_NA: discover by DUID still works, + // reply IAID defaults to 0 like the normal path. + let req = dhcpv6::Packet { + msg_type: dhcpv6::MsgType::Solicit, + transaction_id: 0x0A0B0C, + options: vec![dhcpv6::Dhcpv6Option::ClientId(vec![5]), rapid6_opt()], + }; + let rep = s.handle_solicit(&req).unwrap(); + assert_eq!(rep.msg_type, dhcpv6::MsgType::Reply); + match rep.option(dhcpv6::OPT_IA_NA) { + Some(dhcpv6::Dhcpv6Option::IaNa(ia)) => { + assert_eq!(ia.iaid, 0); + assert_eq!(ia.addrs.len(), 1); + } + _ => panic!("must still commit"), + } + assert_eq!( + s.pool.current_lease(&[5]), + rep.option(dhcpv6::OPT_IA_NA).and_then(|ia| match ia { + dhcpv6::Dhcpv6Option::IaNa(ia) => Some(ia.addrs[0].addr), + _ => None, + }) + ); +} diff --git a/tests/test_rfc_accuracy_gaps.rs b/tests/test_rfc_accuracy_gaps.rs new file mode 100644 index 0000000..b4e79d2 --- /dev/null +++ b/tests/test_rfc_accuracy_gaps.rs @@ -0,0 +1,582 @@ +//! DHCP standards accuracy gaps, part 4. +//! +//! Covers RFC 2131 / 2132 / 951 corners NOT locked by +//! `test_dhcp_rfc.rs` / `test_dhcp_standards.rs` / `test_dhcp_tables.rs` / +//! `test_packet*.rs` / `test_server*.rs`: +//! +//! - exhaustive option-code decode branch matrix (typed vs passthrough) +//! - exact diagnostic strings for `MessageType::from` / `message_type()` +//! - encode buffer-size boundaries (240 panic / 241 minimal / 300 exact) +//! - missing-END with trailing byte succeeds vs exact-consumption panics +//! - option 255 (END) asymmetry: `to_raw` preserves, decode panics +//! - hops edge values + reply reset, flags wire bytes, siaddr zeroing +//! - NAK `yiaddr` left to caller (library does not force 0) +//! - example Offer shape + T1/T2 RFC defaults when 58/59 absent +//! - overload values 2/3, retransmit identity (xid/chaddr stable, secs grows) +//! +//! `*_quirk` tests lock current deviations so refactors cannot change them +//! silently. All tests pass against the current implementation. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// helpers (same patterns as the other RFC suites) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +fn client_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +fn reply_once( + msg: MessageType, + additional: Vec, + offer: Ipv4Addr, + req: Packet, +) -> Packet { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H { + msg: MessageType, + add: Option>, + offer: Ipv4Addr, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let add = self.add.take().unwrap(); + let _ = s.reply(self.msg, add, self.offer, p); + } + } + let h = H { + msg, + add: Some(additional), + offer, + }; + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 1, 1), + Ipv4Addr::new(192, 168, 1, 255), + h, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + client + .send_to(&req.encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("no reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +// =========================================================================== +// 1. Exhaustive option-code decode branch matrix +// =========================================================================== + +/// RFC 2132: only codes 1, 12, 50, 51, 53, 54, 55, 56 decode as typed +/// (3/6 always error — see quirk test below). Every other code 0..=254 must +/// decode as transparent `Unrecognized`. Code 255 (END) panics — tested +/// separately. Locks that adding a typed branch cannot break wire compat. +#[test] +fn gap_decode_branch_matrix_typed_vs_passthrough() { + // (code, wire bytes, expected typed check) + // typed codes with valid payloads + match decode_option(&[53, 1, 1]) { + Ok((_, DhcpOption::DhcpMessageType(MessageType::Discover))) => {} + _ => panic!("53 must be typed"), + } + match decode_option(&[54, 4, 10, 0, 0, 1]) { + Ok((_, DhcpOption::ServerIdentifier(ip))) => { + assert_eq!(ip, Ipv4Addr::new(10, 0, 0, 1)) + } + _ => panic!("54 must be typed"), + } + match decode_option(&[50, 4, 10, 0, 0, 9]) { + Ok((_, DhcpOption::RequestedIpAddress(ip))) => { + assert_eq!(ip, Ipv4Addr::new(10, 0, 0, 9)) + } + _ => panic!("50 must be typed"), + } + match decode_option(&[1, 4, 255, 255, 255, 0]) { + Ok((_, DhcpOption::SubnetMask(ip))) => { + assert_eq!(ip, Ipv4Addr::new(255, 255, 255, 0)) + } + _ => panic!("1 must be typed"), + } + match decode_option(&[51, 4, 0, 0, 0, 10]) { + Ok((_, DhcpOption::IpAddressLeaseTime(10))) => {} + _ => panic!("51 must be typed"), + } + match decode_option(&[55, 2, 1, 3]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert_eq!(v, vec![1, 3]), + _ => panic!("55 must be typed"), + } + match decode_option(&[12, 1, 65]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, "A"), + _ => panic!("12 must be typed"), + } + match decode_option(&[56, 1, 66]) { + Ok((_, DhcpOption::Message(s))) => assert_eq!(s, "B"), + _ => panic!("56 must be typed"), + } + // 3/6 are shaped correctly but the decoder always fails (known quirk) + assert!(matches!( + decode_option(&[3, 4, 192, 168, 1, 1]), + Err(CustomErr::InvalidHlen) + )); + assert!(matches!( + decode_option(&[6, 4, 8, 8, 8, 8]), + Err(CustomErr::InvalidHlen) + )); + // everything else 0..=254 (minus typed + 3/6/255) is passthrough + let typed: std::collections::HashSet = + [1, 3, 6, 12, 50, 51, 53, 54, 55, 56].iter().cloned().collect(); + for code in 0u8..=254 { + if typed.contains(&code) { + continue; + } + match decode_option(&[code, 1, 0xAB]) { + Ok((rest, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, code, "code {}", code); + assert_eq!(r.data, vec![0xAB], "code {}", code); + assert!(rest.is_empty(), "code {}", code); + } + _ => panic!("code {} must be Unrecognized passthrough", code), + } + } +} + +// =========================================================================== +// 2. Exact diagnostic strings (log parsing must not break) +// =========================================================================== + +/// `MessageType::from` error text is `Invalid DHCP Message Type: `. +#[test] +fn gap_message_type_from_error_string_exact() { + assert_eq!( + MessageType::from(9).unwrap_err(), + "Invalid DHCP Message Type: 9" + ); + assert_eq!( + MessageType::from(0).unwrap_err(), + "Invalid DHCP Message Type: 0" + ); + assert_eq!( + MessageType::from(255).unwrap_err(), + "Invalid DHCP Message Type: 255" + ); +} + +/// `Packet::message_type()` distinguishes missing option vs mistyped option. +#[test] +fn gap_packet_message_type_error_strings_exact() { + let bare = Packet { + reply: false, + hops: 0, + xid: 1, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![], + }; + assert_eq!( + bare.message_type().unwrap_err(), + "Packet does not have MessageType option" + ); + let mistyped = Packet { + reply: false, + hops: 0, + xid: 1, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![DhcpOption::Unrecognized(RawDhcpOption { + code: 53, + data: vec![1], + })], + }; + assert_eq!( + mistyped.message_type().unwrap_err(), + "Got wrong enum code 53 for DHCP_MESSAGE_TYPE" + ); +} + +// =========================================================================== +// 3. Encode buffer-size boundaries +// =========================================================================== + +/// A 300-byte buffer is the exact maximum the encoder can emit; it must work +/// and byte 299 must be END when options fill the packet. +#[test] +fn gap_encode_300_byte_buffer_exact_fit() { + // 54-byte unrecognized + Msg(3) fills 240+3+56+1 = 300 (see truncation test) + let p = Packet { + reply: false, + hops: 0, + xid: 1, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::Unrecognized(RawDhcpOption { + code: 200, + data: vec![7u8; 54], + }), + ], + }; + let mut buf = [0u8; 300]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(enc.len(), 300); + assert_eq!(enc[299], 255); +} + +/// Smallest buffer that can hold an empty-options packet is 241 bytes +/// (240 header+cookie + 1 END). 241 works; 240 panics on the return slice. +#[test] +fn gap_encode_241_byte_buffer_minimal_ok() { + let p = client_packet(1, [0; 6], vec![]); + let mut buf = [0u8; 241]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(enc.len(), 241); + assert_eq!(enc[240], 255); +} + +#[test] +#[should_panic] +fn gap_encode_240_byte_buffer_panics_quirk() { + let p = client_packet(1, [0; 6], vec![]); + let mut buf = [0u8; 240]; + let _ = p.encode(&mut buf); +} + +// =========================================================================== +// 4. Missing-END edge: trailing byte succeeds, exact consumption panics +// =========================================================================== + +/// No END but one trailing garbage byte: decode swallows the error, skips one +/// byte via `split_at(1)`, and keeps the options decoded so far. +#[test] +fn gap_missing_end_with_trailing_byte_succeeds() { + let raw = make_raw( + 1, 6, 0, 0xABCDEF01, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1, 2, 3, 4, 5, 6], + vec![53, 1, 1, 99], // valid Discover, then garbage 99, no END + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); +} + +// =========================================================================== +// 5. Option 255 (END) asymmetry +// =========================================================================== + +/// `to_raw` preserves code 255 byte-for-byte, but the decoder asserts +/// `code != END`, so such a packet can be encoded yet never decoded. +#[test] +fn gap_unrecognized_255_to_raw_preserves() { + let raw = DhcpOption::Unrecognized(RawDhcpOption { + code: 255, + data: vec![1], + }) + .to_raw(); + assert_eq!(raw.code, 255); + assert_eq!(raw.data, vec![1]); +} + +#[test] +#[should_panic] +fn gap_unrecognized_255_packet_decode_panics_quirk() { + let p = Packet { + reply: false, + hops: 0, + xid: 1, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![DhcpOption::Unrecognized(RawDhcpOption { + code: 255, + data: vec![1], + })], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + // first option byte on the wire is the END marker -> decode panics + assert_eq!(enc[240], 255); + let _ = Packet::from(&enc); +} + +// =========================================================================== +// 6. Hops / flags wire bytes / siaddr / NAK yiaddr +// =========================================================================== + +/// RFC 2131 §2: `hops` 0/16/255 survive decode; server replies reset to 0. +#[test] +fn gap_hops_edges_preserved_then_reset() { + for hops in [0u8, 16, 255] { + let raw = make_raw( + 1, 6, hops, 0x1234, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [9; 6], + vec![53, 1, 1, 255], + ); + assert_eq!(unwrap_packet(Packet::from(&raw)).hops, hops); + } + let mut req = client_packet(0x99, [9; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + req.hops = 16; + let rep = reply_once( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 1, 44), + req, + ); + assert_eq!(rep.hops, 0); +} + +/// Broadcast flag wire bytes: true -> [128,0], false -> [0,0]. +#[test] +fn gap_broadcast_flag_wire_bytes() { + for (bcast, expect) in [(false, [0u8, 0]), (true, [128u8, 0])] { + let p = Packet { + reply: true, + hops: 0, + xid: 1, + secs: 0, + broadcast: bcast, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(&enc[10..12], &expect, "broadcast={}", bcast); + } +} + +/// RFC 2131 §2: `siaddr` (next bootstrap server) is always zeroed on reply, +/// even when the request carried a non-zero `siaddr`. +#[test] +fn gap_reply_siaddr_always_zeroed() { + let req = Packet { + reply: false, + hops: 0, + xid: 0x55, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::new(5, 6, 7, 8), + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [3; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let rep = reply_once( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 1, 45), + req, + ); + assert_eq!(rep.siaddr, Ipv4Addr::UNSPECIFIED); +} + +/// Library leaves NAK `yiaddr` to the caller (example passes 0.0.0.0 per +/// RFC 2131 Table 3, but a non-zero offer IP is preserved as-is). +#[test] +fn gap_nak_yiaddr_left_to_caller_quirk() { + let req = client_packet(0x66, [4; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Request, + )]); + let rep = reply_once( + MessageType::Nak, + vec![], + Ipv4Addr::new(1, 2, 3, 4), + req, + ); + assert_eq!(rep.message_type(), Ok(MessageType::Nak)); + assert_eq!(rep.ciaddr, Ipv4Addr::UNSPECIFIED, "NAK ciaddr forced 0"); + assert_eq!( + rep.yiaddr, + Ipv4Addr::new(1, 2, 3, 4), + "NAK yiaddr NOT forced (caller decides)" + ); +} + +// =========================================================================== +// 7. Example Offer shape + T1/T2 defaults (RFC 2131 §4.4.5) +// =========================================================================== + +/// Example server Offers carry 53/54/51/1 on decode (router/DNS are on the +/// wire but dropped by the Router/DNS decode quirk) and never 58/59. +#[test] +fn gap_example_offer_shape_and_no_t1_t2() { + let req = client_packet(0x77, [0xAA; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + let rep = reply_once( + MessageType::Offer, + vec![ + DhcpOption::IpAddressLeaseTime(86400), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 2, 1)]), + DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]), + ], + Ipv4Addr::new(192, 168, 2, 90), + req, + ); + let codes: Vec = rep.options.iter().map(|o| o.code()).collect(); + assert_eq!(codes, vec![53, 54, 51, 1], "router/DNS dropped by decode quirk"); + assert!(rep.option(RENEWAL_TIME_VALUE).is_none(), "no T1"); + assert!(rep.option(REBINDING_TIME_VALUE).is_none(), "no T2"); +} + +/// RFC 2131 §4.4.5 defaults when T1/T2 absent: T1 = 0.5*lease, +/// T2 = 0.875*lease. Locks the arithmetic clients must use with our replies. +#[test] +fn gap_t1_t2_defaults_from_lease_time() { + fn defaults(lease: u32) -> (u32, u32) { + ((lease as u64 / 2) as u32, (lease as u64 * 7 / 8) as u32) + } + assert_eq!(defaults(86400), (43200, 75600)); + assert_eq!(defaults(3600), (1800, 3150)); + assert_eq!(defaults(0), (0, 0)); + // reply used above carries 86400 with no T1/T2, so these apply + let rep = reply_once( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(86400)], + Ipv4Addr::new(192, 168, 2, 91), + client_packet(0x78, [0xBB; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Request, + )]), + ); + match rep.option(IP_ADDRESS_LEASE_TIME) { + Some(DhcpOption::IpAddressLeaseTime(86400)) => {} + _ => panic!("lease must be 86400"), + } + assert!(rep.option(RENEWAL_TIME_VALUE).is_none()); + assert!(rep.option(REBINDING_TIME_VALUE).is_none()); +} + +// =========================================================================== +// 8. Overload values, retransmit identity +// =========================================================================== + +/// RFC 2132 §9.3 Overload values 1=file, 2=sname, 3=both all passthrough. +#[test] +fn gap_overload_values_1_2_3_passthrough() { + for v in [1u8, 2, 3] { + match decode_option(&[52, 1, v]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, OVERLOAD); + assert_eq!(r.data, vec![v]); + } + _ => panic!("overload {} must passthrough", v), + } + } +} + +/// RFC 2131 §4.4.1 retransmission: same xid+chaddr, growing secs = same +/// transaction. Decode must preserve the triple exactly. +#[test] +fn gap_retransmit_identity_xid_chaddr_secs() { + let ch = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]; + let first = unwrap_packet(Packet::from(&make_raw( + 1, 6, 0, 0x77777777, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + ch, + vec![53, 1, 1, 255], + ))); + let retry = unwrap_packet(Packet::from(&make_raw( + 1, 6, 0, 0x77777777, 5, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + ch, + vec![53, 1, 1, 255], + ))); + assert_eq!(first.xid, retry.xid); + assert_eq!(first.chaddr, retry.chaddr); + assert_eq!(first.secs, 0); + assert_eq!(retry.secs, 5); + assert_eq!(first.message_type(), retry.message_type()); +} diff --git a/tests/test_security.rs b/tests/test_security.rs new file mode 100644 index 0000000..80e9777 --- /dev/null +++ b/tests/test_security.rs @@ -0,0 +1,663 @@ +//! DHCP security regression suite. +//! +//! Locks security-relevant behavior of this DHCP stack so refactors cannot +//! silently open (or silently "fix" without review) attacker-visible holes. +//! All tests assert the *current* behavior and pass against it; tests for +//! known-unsafe behavior end in `_quirk` and state the threat in the docs. +//! +//! Threat model: untrusted bytes arrive via UDP (`Server::serve`), and any +//! peer can spoof `chaddr`/`xid`/`ciaddr`/`giaddr`/flags/options — there is +//! no authentication (RFC 3118 is not implemented) and identity is the raw +//! 6-byte `chaddr`. Lease state lives in the example `MyServer` replica here +//! (same bodies as `examples/server.rs`); the library itself trusts its +//! caller for `offer_ip` selection. +//! +//! Groups: +//! - A. Parser hardening: oversized / bomb packets stay bounded, no panics +//! - B. Malformed-option desync: first error truncates the option list +//! - C. Trust boundaries: op not validated, broadcast controls delivery, +//! `offer_ip`/`ciaddr`/`xid` echoed without validation +//! - D. Lease abuse: starvation, infinite-lease theft, release spoofing +//! - E. Config DoS: malformed `leases` line panics the example parser + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::HashMap; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +// Exact replica of examples/server.rs lease state (see test_examples_extra). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +struct Replica { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl Replica { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + fn discover(&mut self, chaddr: &[u8; 6]) -> Option { + if let Some(ip) = self.current_lease(chaddr) { + return Some(ip); + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(chaddr, &cand) { + return Some(cand); + } + } + None + } + fn request( + &mut self, + chaddr: &[u8; 6], + requested: Option, + ciaddr: Ipv4Addr, + _for_this: bool, + ) -> Result { + let req_ip = requested.unwrap_or(ciaddr); + if let Some(ip) = self.current_lease(chaddr) { + return Ok(ip); + } + if !self.available(chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + (chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))), + ); + Ok(req_ip) + } + fn release(&mut self, chaddr: &[u8; 6], for_this: bool) -> bool { + if !for_this { + return false; + } + if let Some(ip) = self.current_lease(chaddr) { + self.leases.remove(&ip); + return true; + } + false + } +} + +// =========================================================================== +// A. Parser hardening: oversized / hostile packets stay bounded, no panics +// =========================================================================== + +/// A 300-byte HostName cannot fit the 300-byte wire cap, so `encode` drops +/// the option instead of truncating the length byte (`as u8` wrap) — the +/// packet stays well-formed with just Discover + END. No panic, no corrupt +/// length prefix an attacker could use for framing confusion. +#[test] +fn sec_oversized_hostname_dropped_packet_stays_valid() { + let big = "A".repeat(300); + let p = test_packet( + 0xB001, + [1; 6], + vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::HostName(big), + ], + ); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() < 300, "oversized option must not bloat packet"); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.options.len(), 1, "only Discover survives"); + assert_eq!(q.message_type(), Ok(MessageType::Discover)); +} + +/// A 1500-byte MTU-full datagram of tiny options decodes without panic and +/// the option Vec stays bounded by the datagram (~419 entries); re-encoding +/// truncates back to the 300 cap with Discover first. No amplification past +/// the cap and no allocator blowup beyond one datagram. +#[test] +fn sec_1500_byte_option_bomb_decodes_bounded() { + let mut opts = vec![53, 1, 1]; // Discover + for _ in 0..418 { + opts.extend_from_slice(&[200, 1, 7]); + } + opts.push(255); // END + let raw = make_raw( + 1, 6, 0, 0xB002, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [2; 6], opts, + ); + assert!(raw.len() <= 1500); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 419, "1 Discover + 418 Unrecognized"); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() <= 300, "re-encode capped, no amplification"); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.message_type(), Ok(MessageType::Discover), "Discover kept first"); + assert_eq!(q.options.len(), 19, "Discover + 18 fillers + END accounting"); +} + +/// A 255-byte Parameter Request List (max `len` byte) decodes fully but can +/// never be re-encoded (240+2+255 >= 300), so it is dropped on reply — +// bounded in both directions, never wraps the length byte. +#[test] +fn sec_prl_255_bytes_decodes_but_encode_drops_quirk() { + let data = vec![1u8; 255]; + let mut wire = vec![55, 255]; + wire.extend_from_slice(&data); + match decode_option(&wire) { + Ok((rest, DhcpOption::ParameterRequestList(v))) => { + assert_eq!(v.len(), 255); + assert!(rest.is_empty()); + } + _ => panic!("255-byte PRL must decode"), + } + let p = test_packet( + 0xB003, + [3; 6], + vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::ParameterRequestList(data), + ], + ); + let mut buf = [0u8; 1500]; + let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec())); + assert_eq!(q.options.len(), 1, "giant PRL dropped, Discover kept"); +} + +// =========================================================================== +// B. Malformed-option desync: first error truncates the option list +// =========================================================================== + +/// An attacker-controlled malformed option (bad UTF-8 HostName) placed before +/// Server Identifier strips the identifier: decoded options end at the error +/// (plus one desync byte skipped), so `for_this_server`-style checks see +/// "no Server ID". The packet is still accepted as Discover. +#[test] +fn sec_bad_option_strips_following_server_id() { + let mut opts = vec![53, 1, 1]; // Discover + opts.extend_from_slice(&[12, 2, 0xFF, 0xFE]); // bad UTF-8 HostName + opts.extend_from_slice(&[54, 4, 10, 20, 30, 40]); // Server ID (lost) + opts.push(255); + let raw = make_raw( + 1, 6, 0, 0xB010, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [4; 6], opts, + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1, "list truncated at bad option"); + assert_eq!(p.message_type(), Ok(MessageType::Discover), "leading type survives"); + assert!(p.option(SERVER_IDENTIFIER).is_none(), "Server ID stripped"); +} + +/// Same desync with a bad message-type value as the *second* option: the +/// leading Discover survives while the bogus trailing type is dropped. +#[test] +fn sec_bad_trailing_msgtype_dropped_leading_kept() { + let raw = make_raw( + 1, 6, 0, 0xB011, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [5; 6], + vec![53, 1, 1, 53, 1, 9, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); +} + +/// Two option-53s in one datagram: `message_type()` returns the FIRST, the +/// second is silently ignored — an attacker cannot override the type by +/// appending, but a naive reader of `options[1]` would disagree. Locked. +#[test] +fn sec_duplicate_msgtype_first_wins_confusion() { + let raw = make_raw( + 1, 6, 0, 0xB012, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [6; 6], + vec![53, 1, 1, 53, 1, 3, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 2); + assert_eq!(p.message_type(), Ok(MessageType::Discover), "first wins"); + let raw2 = make_raw( + 1, 6, 0, 0xB013, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [6; 6], + vec![53, 1, 3, 53, 1, 1, 255], + ); + assert_eq!( + unwrap_packet(Packet::from(&raw2)).message_type(), + Ok(MessageType::Request), + "order matters, first wins" + ); +} + +/// Unknown message-type value (9): the datagram still decodes Ok with empty +/// options, so the server ignores it via the catch-all instead of erroring. +/// No crash, no reply — attacker learns nothing. +#[test] +fn sec_unknown_msgtype_decodes_but_handler_ignores() { + let raw = make_raw( + 1, 6, 0, 0xB014, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [7; 6], + vec![53, 1, 9, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert!(p.options.is_empty()); + assert!(p.message_type().is_err(), "example match falls to catch-all arm"); +} + +// =========================================================================== +// C. Trust boundaries: op, broadcast delivery, unvalidated echo fields +// =========================================================================== + +/// QUIRK (spoofing): `Server::serve` never checks `op` — a forged BOOTREPLY +/// (op=2, i.e. a "server" packet) from any peer is still dispatched to the +/// handler as if it were a client request. Locked so the missing validation +/// cannot change silently. +#[test] +fn sec_bootreply_still_dispatched_to_handler_quirk() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let (tx, rx) = std::sync::mpsc::channel::<(bool, u32)>(); + struct H { + tx: std::sync::mpsc::Sender<(bool, u32)>, + } + impl server::Handler for H { + fn handle_request(&mut self, _s: &server::Server, p: Packet) { + let _ = self.tx.send((p.reply, p.xid)); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(10, 0, 0, 255), + H { tx }, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + // forged server-to-server reply carrying a Discover option + let p = Packet { + reply: true, + hops: 0, + xid: 0xC001, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [8; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let mut buf = [0u8; 1500]; + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + (true, 0xC001), + "BOOTREPLY must reach handler (no op validation)" + ); +} + +/// The attacker-controlled broadcast flag decides unicast vs subnet +/// broadcast delivery: `false` answers only the peer, `true` sprays the +/// configured broadcast address (amplification primitive — locked). +#[test] +fn sec_broadcast_flag_controls_subnet_delivery() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 9, 10), + p, + ); + } + } + std::thread::spawn(move || { + // broadcast goes to 127.0.0.2: no listener there, peer gets nothing + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 9, 1), + Ipv4Addr::new(127, 0, 0, 2), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(400))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // unicast: reply returns to the peer + let p = test_packet(0xC002, [9; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("unicast reply"); + assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).yiaddr, Ipv4Addr::new(192, 168, 9, 10)); + // broadcast: reply sprays 127.0.0.2, this peer sees nothing + let mut raw = vec![0u8; 236]; + raw[0] = 1; + raw[1] = 1; + raw[2] = 6; + raw[4..8].copy_from_slice(&0xC003u32.to_be_bytes()); + raw[10] = 0; + raw[11] = 128; // low-byte 0x80 -> decodes broadcast=true (quirk) + raw[28..34].copy_from_slice(&[9; 6]); + raw.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]); + client.send_to(&raw, srv_addr).unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "broadcast reply must NOT reach the unicast peer" + ); +} + +/// Library performs NO validation that the offered address belongs to the +/// served subnet — whatever `offer_ip` the handler passes is echoed +/// verbatim. Caller (example: `available()`) is the only guard. Locked. +#[test] +fn sec_offer_ip_out_of_subnet_used_verbatim() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(8, 8, 8, 8), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 9, 1), + Ipv4Addr::new(192, 168, 9, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let p = test_packet(0xC004, [10; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("reply"); + assert_eq!( + unwrap_packet(Packet::from(&rbuf[..n])).yiaddr, + Ipv4Addr::new(8, 8, 8, 8), + "library echoes any offer_ip, even off-subnet" + ); +} + +/// `ciaddr` is echoed verbatim into non-NAK replies with no ownership check — +/// a spoofed `ciaddr` is reflected back (delivery still uses the peer +/// socket, so this is a reflection quirk, not a redirect). +#[test] +fn sec_ciaddr_spoof_echoed_in_ack() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply(MessageType::Ack, vec![], Ipv4Addr::new(192, 168, 9, 20), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 9, 1), + Ipv4Addr::new(192, 168, 9, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mut p = test_packet(0xC005, [11; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Request, + )]); + p.ciaddr = Ipv4Addr::new(1, 2, 3, 4); // victim address, not ours + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("reply"); + assert_eq!( + unwrap_packet(Packet::from(&rbuf[..n])).ciaddr, + Ipv4Addr::new(1, 2, 3, 4), + "ciaddr reflected without validation" + ); +} + +/// `xid` is echoed with no randomness/sequence check — edge values 0 and +/// MAX round-trip exactly, so xid alone authenticates nothing. +#[test] +fn sec_xid_edge_values_echoed_without_validation() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 9, 30), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 9, 1), + Ipv4Addr::new(192, 168, 9, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + for xid in [0u32, u32::MAX] { + let p = test_packet(xid, [12; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("reply"); + assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).xid, xid); + } +} + +// =========================================================================== +// D. Lease abuse: starvation, theft, release spoofing (example replica) +// =========================================================================== + +/// Classic DHCP starvation: 252 distinct spoofed `chaddr`s each REQUEST a +/// distinct pool IP. No rate limiting exists, so the pool fills and the next +/// legitimate client gets NAK (Request) / no offer (Discover). Locked. +#[test] +fn sec_starvation_via_distinct_chaddrs_fills_pool() { + let mut s = Replica::new(); + for i in 0..LEASE_NUM { + let mac = [(i >> 16) as u8, (i >> 8) as u8, i as u8, 0xAA, 0xBB, 0xCC]; + let ip: Ipv4Addr = (IP_START_NUM + i).into(); + assert_eq!(s.request(&mac, Some(ip), Ipv4Addr::UNSPECIFIED, true), Ok(ip)); + } + assert_eq!(s.leases.len(), LEASE_NUM as usize); + let legit = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]; + assert_eq!(s.discover(&legit), None, "pool exhausted: no offer"); + assert!( + s.request(&legit, Some(Ipv4Addr::new(192, 168, 2, 50)), Ipv4Addr::UNSPECIFIED, true).is_err(), + "pool exhausted: NAK path" + ); +} + +/// Infinite (file-reserved, `None` expiry) leases are stealable: `available` +/// reports them free to ANY mac, so a stranger's Request overwrites the +/// reservation with a timed lease. Integrity hole — locked. +#[test] +fn sec_infinite_lease_overwritten_by_stranger_request() { + let mut s = Replica::new(); + let victim = [0xF4, 0x5C, 0x19, 0xAF, 0x96, 0x8D]; + let reserved = Ipv4Addr::new(192, 168, 2, 90); + s.leases.insert(reserved, (victim, None)); // permanent reservation + let attacker = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]; + assert_eq!( + s.request(&attacker, Some(reserved), Ipv4Addr::UNSPECIFIED, true), + Ok(reserved), + "stranger Acked for reserved IP" + ); + let (owner, expiry) = &s.leases[&reserved]; + assert_eq!(*owner, attacker, "reservation now owned by attacker"); + assert!(expiry.is_some(), "permanent became timed"); +} + +/// Release/Decline only checks the 6 raw `chaddr` bytes — anyone spoofing the +/// victim MAC deletes its lease (no authentication). A wrong chaddr deletes +/// nothing and never panics. +#[test] +fn sec_release_requires_victim_chaddr_spoof() { + let mut s = Replica::new(); + let victim = [0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F]; + let ip = Ipv4Addr::new(192, 168, 2, 60); + assert_eq!(s.request(&victim, Some(ip), Ipv4Addr::UNSPECIFIED, true), Ok(ip)); + // wrong chaddr: no-op, lease kept + assert!(!s.release(&[9; 6], true)); + assert!(s.leases.contains_key(&ip)); + // spoofed victim chaddr: lease deleted + assert!(s.release(&victim, true)); + assert!(!s.leases.contains_key(&ip)); +} + +/// Release for an unknown chaddr is a silent no-op (returns false). +#[test] +fn sec_release_unknown_chaddr_noop() { + let mut s = Replica::new(); + assert!(!s.release(&[9; 6], true)); + assert!(s.leases.is_empty()); +} + +// =========================================================================== +// E. Config DoS: malformed `leases` line panics the example parser +// =========================================================================== + +/// QUIRK (startup DoS): `examples/server.rs` parses the IP with `.unwrap()`, +/// so ONE bad line with a valid MAC but garbage IP panics the whole server +/// at startup. (The `test_lease_logic` helper uses `.ok()?` instead and +/// hides this.) A single attacker/contributor-written line kills the daemon. +#[test] +#[should_panic] +fn sec_leases_file_bad_ip_panics_example_parser_quirk() { + // byte-for-byte replica of examples/server.rs main() parsing incl. unwrap + let line = String::from("aa:bb:cc:dd:ee:ff,not-an-ip"); + let parts: Vec<&str> = line.split(',').collect(); + assert_eq!(parts.len(), 2); + let mac_parts: Vec = parts[0] + .split(':') + .filter_map(|part| u8::from_str_radix(part, 16).ok()) + .collect(); + assert_eq!(mac_parts.len(), 6); + let mut mac = [0u8; 6]; + mac.copy_from_slice(&mac_parts); + let _mac = mac; + let _ip: Ipv4Addr = parts[1].trim().parse::().unwrap(); +} diff --git a/tests/test_security_dos.rs b/tests/test_security_dos.rs new file mode 100644 index 0000000..8aaf5a5 --- /dev/null +++ b/tests/test_security_dos.rs @@ -0,0 +1,528 @@ +//! DHCP security: denial-of-service behavior, part 6. +//! +//! Covers DOS-relevant behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, `test_security_edge.rs`, `test_security_wire.rs`, +//! `test_security_gates.rs`, `test_security_hijack.rs`, +//! `test_security_slowloris.rs`, or the RFC suites. All tests assert the +//! *current* behavior and pass against it; tests for known-unsafe behavior +//! end in `_quirk` and state the threat in the docs. +//! +//! Findings locked here: the server performs NO rate limiting (every +//! unsatisfiable Request earns a NAK, every Release/Inform is processed), +//! the single-threaded loop serializes parallel clients without drops, +//! replies bear no relation to request count, and both decode and encode +//! paths stay bounded (option count capped by datagram size on decode and +//! by the 300-byte wire cap on encode, including the reply path). +//! +//! Groups: +//! - A. No rate limiting: NAK storms, Release storms, Inform floods are all +//! processed; the server stays responsive throughout. +//! - B. Concurrency: parallel clients are serialized without drops, +//! cross-talk, or xid confusion. +//! - C. Allocation bounds: minimal-option bomb hits the theoretical decoder +//! maximum; a 500-option reply is capped to 300 bytes on the wire. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security_slowloris.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +// Exact replica of examples/server.rs lease state (see test_examples_extra). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl ExampleLike { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +fn discover(xid: u32, chaddr: [u8; 6]) -> Packet { + test_packet( + xid, + chaddr, + vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + ) +} + +fn request_selecting( + xid: u32, + chaddr: [u8; 6], + server_ip: Ipv4Addr, + wanted: Ipv4Addr, +) -> Packet { + test_packet( + xid, + chaddr, + vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(wanted), + ], + ) +} + +fn drain_replies(client: &UdpSocket, rbuf: &mut [u8; 1500], wait_ms: u64) -> usize { + client + .set_read_timeout(Some(Duration::from_millis(wait_ms))) + .unwrap(); + let mut count = 0; + loop { + match client.recv_from(rbuf) { + Ok(_) => count += 1, + Err(_) => break, + } + } + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + count +} + +// =========================================================================== +// A. No rate limiting: storms are fully processed, server stays responsive +// =========================================================================== + +/// QUIRK (NAK storm): 20 rapid Requests for a taken IP each earn a full NAK +/// — no suppression, no backoff — then the victim still holds the lease. +/// An attacker can elicit unbounded replies at roughly 1:1 cost. Locked. +#[test] +fn sec7_nak_storm_all_answered_no_suppression() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x71; 6]; + // victim takes .60 + let req = request_selecting(0xA001, victim, server_ip, Ipv4Addr::new(192, 168, 2, 60)); + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // storm: 20 distinct chaddrs hammer the taken IP, every one NAK'd + for i in 0..20u32 { + let ch = [0x70 + i as u8, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE]; + let grab = request_selecting(0xA100 + i, ch, server_ip, Ipv4Addr::new(192, 168, 2, 60)); + let rep = send_recv(&client, &srv_addr, &grab, &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Nak), "storm {}", i); + assert_eq!(rep.xid, 0xA100 + i, "per-request xid echo in storm"); + } + // victim still holds .60 afterwards + let still = send_recv(&client, &srv_addr, &discover(0xA002, victim), &mut buf, &mut rbuf); + assert_eq!(still.yiaddr, Ipv4Addr::new(192, 168, 2, 60)); +} + +/// 50 Releases for unknown chaddrs are silent no-ops that still cost a +/// decode + HashMap lookup each — then a valid Discover is answered, +/// proving the storm neither crashed the loop nor planted state. +#[test] +fn sec7_release_storm_unknown_chaddrs_no_state_no_crash() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + for i in 0..50u32 { + let ch = [0x80 + (i % 250) as u8, 0x11, 0x22, 0x33, 0x44, 0x55]; + let rel = Packet { + reply: false, hops: 0, xid: 0xB000 + i, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: ch, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(server_ip), + ], + }; + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + } + // no state planted: fresh client gets the first offer, server alive + let rep = send_recv(&client, &srv_addr, &discover(0xB100, [0x99; 6]), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); +} + +/// 20 INFORMs earn zero replies (each ignored) yet each is fully decoded — +/// pure inbound processing cost with no outbound traffic and no state. +/// Drain proves the count is exactly 0, then Discover proves liveness. +#[test] +fn sec7_inform_flood_zero_replies_no_state() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + for i in 0..20u32 { + let ch = [0x90 + i as u8, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE]; + let inf = Packet { + reply: false, hops: 0, xid: 0xC000 + i, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 2, 60), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: ch, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Inform), + DhcpOption::ParameterRequestList(vec![1, 15]), + ], + }; + client.send_to(&inf.encode(&mut buf).to_vec(), srv_addr).unwrap(); + } + assert_eq!(drain_replies(&client, &mut rbuf, 400), 0, "INFORM flood: no replies"); + let rep = send_recv(&client, &srv_addr, &discover(0xC100, [0x9A; 6]), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3), "no state planted"); +} + +// =========================================================================== +// B. Concurrency: parallel clients serialized without drops or cross-talk +// =========================================================================== + +/// Five threads × 8 Discovers (40 distinct identities) run concurrently +/// against the single-threaded loop: all 40 Offers arrive, xids echo +/// per-transaction, and all 40 yiaddrs are distinct pool addresses. +/// Locked: serialization is correct under parallel load. +#[test] +fn sec7_concurrent_clients_all_answered_distinct() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + // fixed offer allocator keyed off the full client identity: + // stateless, so concurrent order cannot affect correctness. + let n = p.chaddr[0] as u16 * 8 + p.chaddr[5] as u16; + let _ = s.reply( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 9, 10 + (n % 200) as u8), + p, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 9, 1), + Ipv4Addr::new(192, 168, 9, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let mut handles = Vec::new(); + for t in 0..5u32 { + let srv = srv_addr; + handles.push(std::thread::spawn(move || { + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mut out = Vec::new(); + for i in 0..8u32 { + // distinct chaddr per request; first octet unique per thread + let ch = [(10 + t) as u8, 0xAA, 0xBB, 0xCC, 0xDD, i as u8]; + let xid = 0xD000 + t * 100 + i; + let p = discover(xid, ch); + client.send_to(&p.encode(&mut buf).to_vec(), srv).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("concurrent reply"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + out.push((rep.xid, rep.yiaddr, rep.chaddr)); + } + out + })); + } + let mut xids = HashSet::new(); + let mut ips = HashSet::new(); + for h in handles { + for (xid, ip, ch) in h.join().expect("client thread") { + // xid echoed for its own transaction: full identity inverts + // cleanly from it, proving the reply matches its requester. + let expect_t = (xid - 0xD000) / 100; + let expect_i = (xid - 0xD000) % 100; + assert!(expect_t < 5 && expect_i < 8); + assert_eq!( + [10 + expect_t as u8, 0xAA, 0xBB, 0xCC, 0xDD, expect_i as u8], + ch, + "reply matches its requester" + ); + xids.insert(xid); + ips.insert(ip); + } + } + assert_eq!(xids.len(), 40, "every transaction answered exactly once"); + assert_eq!(ips.len(), 40, "no cross-talk between concurrent clients"); +} + +/// Same xid from two distinct chaddrs: both answered independently with the +/// same xid echoed and different yiaddrs — the server keys nothing on xid, +/// so collisions cannot confuse or suppress a peer. Locked. +#[test] +fn sec7_same_xid_distinct_chaddrs_independent() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let a = send_recv(&client, &srv_addr, &discover(0xD0D0, [0xA1; 6]), &mut buf, &mut rbuf); + let b = send_recv(&client, &srv_addr, &discover(0xD0D0, [0xA2; 6]), &mut buf, &mut rbuf); + assert_eq!(a.xid, 0xD0D0); + assert_eq!(b.xid, 0xD0D0); + assert_ne!(a.yiaddr, b.yiaddr, "distinct clients, distinct offers"); + assert_eq!(a.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + assert_eq!(b.yiaddr, Ipv4Addr::new(192, 168, 2, 4)); +} + +// =========================================================================== +// C. Allocation bounds: decoder maximum and reply-path cap +// =========================================================================== + +/// Minimal 2-byte options hit the theoretical decoder maximum: header+cookie +/// (240) + Discover (3) + 628×`[200,0]` (1256) + END (1) = exactly 1500. +/// All 629 decode without panic; re-encode caps at 300 with Discover first. +/// Tightens the 419-entry bomb test to the true bound. Locked. +#[test] +fn sec7_minimal_option_bomb_hits_decoder_maximum() { + let mut opts = vec![53, 1, 1]; + for _ in 0..628 { + opts.extend_from_slice(&[200, 0]); + } + opts.push(255); + let mut raw = vec![0u8; 236]; + raw[0] = 1; + raw[1] = 1; + raw[2] = 6; + raw[4..8].copy_from_slice(&0xE001u32.to_be_bytes()); + raw[28..34].copy_from_slice(&[0xE1; 6]); + raw.extend_from_slice(&[99, 130, 83, 99]); + raw.extend_from_slice(&opts); + assert_eq!(raw.len(), 1500, "construction must fill the MTU exactly"); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 629, "1 Discover + 628 minimal fillers"); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() <= 300, "re-encode capped"); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.message_type(), Ok(MessageType::Discover), "Discover kept first"); + assert_eq!(q.options.len(), 29, "Discover + 28 fillers + END accounting"); +} + +/// Reply path with 500 extra options still emits at most 300 bytes: the +/// encode cap applies to server replies too, so a greedy handler config +/// cannot bloat answers (no reply-side amplification past the cap). Locked. +#[test] +fn sec7_reply_with_hundreds_of_options_capped_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let mut extra = Vec::with_capacity(500); + for i in 0..500u16 { + extra.push(DhcpOption::Unrecognized(RawDhcpOption { + code: 200, + data: vec![(i & 0xFF) as u8], + })); + } + let _ = s.reply(MessageType::Offer, extra, Ipv4Addr::new(192, 168, 8, 10), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 8, 1), + Ipv4Addr::new(192, 168, 8, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let p = discover(0xE010, [0xE1; 6]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("capped reply"); + assert!(n <= 300, "reply capped at wire cap, got {}", n); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 8, 10)); +} diff --git a/tests/test_security_edge.rs b/tests/test_security_edge.rs new file mode 100644 index 0000000..4ce61ee --- /dev/null +++ b/tests/test_security_edge.rs @@ -0,0 +1,678 @@ +//! DHCP security edges, part 3. +//! +//! Covers security-relevant behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, or the RFC suites, so refactors cannot silently +//! change attacker-visible handling: +//! +//! - handler-state blind spots: Decline frees the *current* lease even when +//! it names someone else's IP; Release without Server ID is ignored live; +//! renew-via-`ciaddr` bypasses the (commented-out) server gate live +//! - allocation ignores `ciaddr`/Requested-IP on Discover and any Server ID +//! on Discover — offers stay round-robin +//! - example Offer wire shape with the real 4-option reply: Router fits on +//! the wire, DNS is truncated off it, decode keeps `[53,54,51,1]` +//! - framing: bare-`ciaddr` renew shape, out-of-pool Request NAKs cleanly, +//! max-length HostName decodes, holder asking for a victim IP keeps their +//! own lease (no NAK, victim untouched) +//! - lease-state hygiene: Discover inserts nothing; broadcast/zero `chaddr` +//! get leases (no MAC validation); infinite reservations surface via +//! Discover to strangers +//! +//! `*_quirk` tests lock currently-unsafe behavior for review, not approval. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::HashMap; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +// Replica of examples/server.rs lease state (bodies identical). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +struct Replica { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl Replica { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + fn discover(&mut self, chaddr: &[u8; 6]) -> Option { + if let Some(ip) = self.current_lease(chaddr) { + return Some(ip); + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(chaddr, &cand) { + return Some(cand); + } + } + None + } + fn request( + &mut self, + chaddr: &[u8; 6], + requested: Option, + ciaddr: Ipv4Addr, + _for_this: bool, + ) -> Result { + let req_ip = requested.unwrap_or(ciaddr); + if let Some(ip) = self.current_lease(chaddr) { + return Ok(ip); + } + if !self.available(chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + (chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))), + ); + Ok(req_ip) + } +} + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl ExampleLike { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +// =========================================================================== +// A. Decline / Release handler blind spots (live, example-faithful) +// =========================================================================== + +/// QUIRK: Decline frees the sender's *current* lease and never looks at the +/// named Requested IP — Decline for someone else's `.99` still deletes your +/// own `.60`. An attacker naming arbitrary IPs frees nothing extra, but the +/// named address is unauthenticated decoration. Locked live. +#[test] +fn sec2_decline_names_wrong_ip_still_frees_current() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x21; 6]; + let other = [0x22; 6]; + // victim takes .60 + let req = Packet { + reply: false, hops: 0, xid: 0xE001, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 60)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // victim Declines naming .99 (someone else's / nobody's address) + let decline = Packet { + reply: false, hops: 0, xid: 0xE002, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Decline), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 99)), + ], + }; + client + .send_to(&decline.encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + // victim's Discover no longer returns .60: it was freed anyway + let disc = Packet { + reply: false, hops: 0, xid: 0xE003, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let offer = send_recv(&client, &srv_addr, &disc, &mut buf, &mut rbuf); + assert_eq!(offer.message_type(), Ok(MessageType::Offer)); + assert_ne!( + offer.yiaddr, + Ipv4Addr::new(192, 168, 2, 60), + "current lease freed despite naming .99" + ); + // and .60 is back in the pool for anyone + let grab = Packet { + reply: false, hops: 0, xid: 0xE004, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: other, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 60)), + ], + }; + let ack = send_recv(&client, &srv_addr, &grab, &mut buf, &mut rbuf); + assert_eq!(ack.message_type(), Ok(MessageType::Ack)); + assert_eq!(ack.yiaddr, Ipv4Addr::new(192, 168, 2, 60)); +} + +/// Release WITHOUT Server Identifier is ignored live (`for_this_server` is +/// false) — the lease survives. Complements the Replica-level gate test with +/// the real wire path (missing option, not just `for_this=false`). +#[test] +fn sec2_release_missing_server_id_keeps_lease_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x23; 6]; + let req = Packet { + reply: false, hops: 0, xid: 0xE010, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 61)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // Release with NO Server Identifier option at all + let rel = Packet { + reply: false, hops: 0, xid: 0xE011, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![DhcpOption::DhcpMessageType(MessageType::Release)], + }; + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + // Discover still returns the kept lease .61 + let disc = Packet { + reply: false, hops: 0, xid: 0xE012, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let offer = send_recv(&client, &srv_addr, &disc, &mut buf, &mut rbuf); + assert_eq!(offer.message_type(), Ok(MessageType::Offer)); + assert_eq!(offer.yiaddr, Ipv4Addr::new(192, 168, 2, 61), "lease kept"); +} + +/// RENEWING shape (ciaddr set, no Server ID, no Requested IP) is Acked live +/// even though `for_this_server` is false — the commented-out gate applies +/// to the ciaddr fallback path too, not just Requested-IP selects. +#[test] +fn sec2_renew_without_server_id_acked_via_ciaddr_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mac = [0x24; 6]; + // establish .70 the normal way first + let req = Packet { + reply: false, hops: 0, xid: 0xE020, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: mac, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 70)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr, + Ipv4Addr::new(192, 168, 2, 70) + ); + // renew: ciaddr set, no Server ID at all + let renew = Packet { + reply: false, hops: 0, xid: 0xE021, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 2, 70), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: mac, + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + let ack = send_recv(&client, &srv_addr, &renew, &mut buf, &mut rbuf); + assert_eq!(ack.message_type(), Ok(MessageType::Ack)); + assert_eq!(ack.yiaddr, Ipv4Addr::new(192, 168, 2, 70)); +} + +// =========================================================================== +// B. Allocation ignores everything but chaddr on Discover +// =========================================================================== + +/// Discover carrying a Server ID plus a Requested IP is still offered +/// round-robin (.3 on a fresh server) — Discover ignores both options. +/// A spoofed Server ID / requested address steers nothing on this path. +#[test] +fn sec2_discover_ignores_server_id_and_requested_ip_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let disc = Packet { + reply: false, hops: 0, xid: 0xE030, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x25; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::ServerIdentifier(Ipv4Addr::new(10, 99, 99, 99)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 99)), + ], + }; + client.send_to(&disc.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("offer"); + let offer = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(offer.message_type(), Ok(MessageType::Offer)); + assert_eq!( + offer.yiaddr, + Ipv4Addr::new(192, 168, 2, 3), + "round-robin, not the requested .99" + ); + let _ = server_ip; +} + +/// Discover with a spoofed `ciaddr` is offered round-robin all the same — +/// `ciaddr` steers nothing on the Discover path (only Request reads it). +#[test] +fn sec2_discover_ignores_ciaddr_for_selection_live() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let disc = Packet { + reply: false, hops: 0, xid: 0xE031, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 2, 99), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x26; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + client.send_to(&disc.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("offer"); + assert_eq!( + unwrap_packet(Packet::from(&rbuf[..n])).yiaddr, + Ipv4Addr::new(192, 168, 2, 3) + ); +} + +/// The real 4-option example Offer on the wire: the full set fits the +/// 300-byte cap, decode keeps `[53,54,51,1]` (Router still poisons the +/// decoder loop). Locks the exact on-air shape of example Offers end to end. +#[test] +fn sec2_example_offer_full_wire_shape_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct FullExample; + impl server::Handler for FullExample { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + // byte-for-byte example reply() option set + let _ = s.reply( + MessageType::Offer, + vec![ + DhcpOption::IpAddressLeaseTime(86400), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 2, 1)]), + DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]), + ], + Ipv4Addr::new(192, 168, 2, 90), + p, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 2, 1), + Ipv4Addr::new(192, 168, 2, 255), + FullExample, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let disc = test_packet(0xE032, [0x27; 6], vec![DhcpOption::DhcpMessageType( + MessageType::Discover, + )]); + client.send_to(&disc.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("offer"); + assert!( + rbuf[..n].windows(6).any(|w| w == [3, 4, 192, 168, 2, 1]), + "router TLV on the wire" + ); + assert!( + rbuf[..n].windows(6).any(|w| w == [6, 4, 8, 8, 8, 8]), + "DNS TLV reaches the wire under the 300 cap" + ); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!( + rep.options.iter().map(|o| o.code()).collect::>(), + vec![53, 54, 51, 1] + ); +} + +// =========================================================================== +// C. Lease-state hygiene: insertion rules and identity laxity +// =========================================================================== + +/// Discover is read-only: offers create no table entry. Only Request inserts. +/// A Discover flood alone therefore cannot fill the pool — starvation needs +/// completed Requests. Locked at the state level. +#[test] +fn sec2_discover_read_only_no_insert() { + let mut s = Replica::new(); + let mac = [0x31; 6]; + assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(192, 168, 2, 3))); + assert!(s.leases.is_empty(), "Discover must not insert"); + assert_eq!( + s.request(&mac, Some(Ipv4Addr::new(192, 168, 2, 3)), Ipv4Addr::UNSPECIFIED, true), + Ok(Ipv4Addr::new(192, 168, 2, 3)) + ); + assert_eq!(s.leases.len(), 1, "only Request inserts"); +} + +/// No MAC validation at all: broadcast `ff:ff:ff:ff:ff:ff` and all-zero +/// `chaddr` are both offered distinct pool addresses like any client. +#[test] +fn sec2_broadcast_and_zero_chaddr_get_leases() { + let mut s = Replica::new(); + let bcast = s.discover(&[0xFF; 6]); + let zero = s.discover(&[0x00; 6]); + assert!(bcast.is_some() && zero.is_some()); + assert_ne!(bcast, zero, "distinct identities, distinct offers"); +} + +/// An infinite (file-reserved) lease also surfaces via Discover to a +/// stranger once the scan reaches it — the theft primitive is not +/// Request-specific. Fills the rest of the pool so `.90` is next. +#[test] +fn sec2_infinite_reservation_offered_to_stranger_via_discover() { + let mut s = Replica::new(); + let owner = [0x41; 6]; + let stranger = [0x42; 6]; + s.leases.insert(Ipv4Addr::new(192, 168, 2, 90), (owner, None)); + // fill every other pool address with timed stranger-held leases + for i in 0..LEASE_NUM { + let ip: Ipv4Addr = (IP_START_NUM + i).into(); + if ip == Ipv4Addr::new(192, 168, 2, 90) { + continue; + } + s.leases.insert( + ip, + ([0x43; 6], Some(Instant::now().add(Duration::from_secs(3600)))), + ); + } + assert_eq!(s.discover(&stranger), Some(Ipv4Addr::new(192, 168, 2, 90))); +} + +/// A lease holder asking for a victim's IP keeps their own lease with no +/// NAK and no theft in that call — the current-lease short-circuit runs +/// before any availability check. Locked. +#[test] +fn sec2_holder_asking_victim_ip_keeps_own_lease() { + let mut s = Replica::new(); + let holder = [0x51; 6]; + let victim = [0x52; 6]; + let victim_ip = Ipv4Addr::new(192, 168, 2, 80); + let holder_ip = Ipv4Addr::new(192, 168, 2, 81); + assert_eq!(s.request(&victim, Some(victim_ip), Ipv4Addr::UNSPECIFIED, true), Ok(victim_ip)); + assert_eq!(s.request(&holder, Some(holder_ip), Ipv4Addr::UNSPECIFIED, true), Ok(holder_ip)); + // holder now asks for the victim's IP: gets own lease back, no NAK + assert_eq!( + s.request(&holder, Some(victim_ip), Ipv4Addr::UNSPECIFIED, true), + Ok(holder_ip) + ); + assert_eq!(s.leases[&victim_ip].0, victim, "victim untouched"); +} + +/// Out-of-pool Requested IP NAKs with no table insert — 10.0.0.1 never +/// enters the map, no panic on the range check. +#[test] +fn sec2_out_of_pool_request_naks_without_insert() { + let mut s = Replica::new(); + assert!(s + .request(&[0x61; 6], Some(Ipv4Addr::new(10, 0, 0, 1)), Ipv4Addr::UNSPECIFIED, true) + .is_err()); + assert!(s.leases.is_empty()); +} + +// =========================================================================== +// D. Parser edges: max-length text, truncation accounting +// =========================================================================== + +/// 255-byte HostName (max `len` byte) of valid UTF-8 decodes exactly — +/// complements the 255-byte PRL test and proves text options use the full +/// length range. +#[test] +fn sec2_hostname_255_byte_max_decodes() { + let data = vec![0x41u8; 255]; + let mut wire = vec![12, 255]; + wire.extend_from_slice(&data); + match decode_option(&wire) { + Ok((rest, DhcpOption::HostName(s))) => { + assert_eq!(s.len(), 255); + assert!(rest.is_empty()); + } + _ => panic!("255-byte HostName must decode"), + } +} diff --git a/tests/test_security_extra.rs b/tests/test_security_extra.rs new file mode 100644 index 0000000..2df9439 --- /dev/null +++ b/tests/test_security_extra.rs @@ -0,0 +1,703 @@ +//! DHCP security gaps, part 2. +//! +//! Covers security-relevant behavior NOT locked by `test_security.rs` or the +//! RFC suites, so refactors cannot silently change attacker-visible handling: +//! +//! - wire-length lies (declared `len` past end of datagram) are swallowed, +//! keeping the prefix — no panic, but trailing options are lost +//! - all-zero option area (no END) panics the decoder thread (DoS primitive) +//! - a lone oversized option yields a bare END-only packet, never corrupt framing +//! - example handler blind spots: INFORM and forged Offer get no reply +//! - NAK wire shape: exact message text, zeroed addrs, Server ID present +//! - Router present on the wire but dropped on decode (both quirks compound) +//! - Request precedence: Requested IP beats `ciaddr`; bare Requests NAK cleanly +//! - config trust: out-of-pool reservations offered verbatim, duplicate file +//! lines last-win, sloppy MAC lines silently skipped +//! - wire lease time ignored: server always sends its fixed duration +//! +//! `*_quirk` tests lock currently-unsafe behavior for review, not approval. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::HashMap; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +// Replica of examples/server.rs lease state (bodies identical). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +struct Replica { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl Replica { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + fn discover(&mut self, chaddr: &[u8; 6]) -> Option { + if let Some(ip) = self.current_lease(chaddr) { + return Some(ip); + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(chaddr, &cand) { + return Some(cand); + } + } + None + } + fn request( + &mut self, + chaddr: &[u8; 6], + requested: Option, + ciaddr: Ipv4Addr, + _for_this: bool, + ) -> Result { + let req_ip = requested.unwrap_or(ciaddr); + if let Some(ip) = self.current_lease(chaddr) { + return Ok(ip); + } + if !self.available(chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + (chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))), + ); + Ok(req_ip) + } + #[allow(dead_code)] + fn release(&mut self, chaddr: &[u8; 6], for_this: bool) -> bool { + if !for_this { + return false; + } + if let Some(ip) = self.current_lease(chaddr) { + self.leases.remove(&ip); + return true; + } + false + } +} + +// Lenient leases-line parser (same shape as test_lease_logic helper). +fn parse_leases_line(line: &str) -> Option<([u8; 6], Ipv4Addr)> { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() != 2 { + return None; + } + let mac_parts: Vec = parts[0] + .split(':') + .filter_map(|part| u8::from_str_radix(part, 16).ok()) + .collect(); + if mac_parts.len() != 6 { + return None; + } + let mut mac = [0u8; 6]; + mac.copy_from_slice(&mac_parts); + let ip = parts[1].trim().parse::().ok()?; + Some((mac, ip)) +} + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else (Offer/Ack/Nak/Inform/errors) ignored. Mirrors +// examples/server.rs match arms for integration tests below. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, + #[allow(dead_code)] + server_ip: Ipv4Addr, +} + +impl ExampleLike { + fn new(server_ip: Ipv4Addr) -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + server_ip, + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + let handler = ExampleLike::new(server_ip); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + handler, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +// =========================================================================== +// A. Wire-length lies and hostile framing stay safe +// =========================================================================== + +/// Declared `len` 255 with 1 byte present: `decode_option` errors +/// `InvalidHlen`, `Packet::from` swallows it, keeps the valid prefix, and +/// never panics — a lying length cannot crash the decoder nor inject bytes. +#[test] +fn sec_truncated_len_past_end_swallowed_keeps_prefix() { + let raw = make_raw( + 1, 6, 0, 0xD001, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [1; 6], + vec![53, 1, 1, 200, 255, 7], // Discover + lying option, no END + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1, "valid prefix kept, lie dropped"); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); +} + +/// 100 zero bytes with no END: each `[0,0]` pair decodes as +/// `Unrecognized(0, [])`, then the exhausted buffer fails and the missing +/// END kills the thread via `split_at(1)`. Minimal DoS datagram — locked. +#[test] +#[should_panic] +fn sec_all_zero_options_no_end_panics_quirk() { + let raw = make_raw( + 1, 6, 0, 0xD002, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [2; 6], + vec![0u8; 100], // no END anywhere + ); + let _ = Packet::from(&raw); +} + +/// A lone oversized option (no fitting prefix) encodes to a bare END-only +/// packet (241 bytes) — never corrupt framing — but decoding bare-END +/// panics on `assert!(code != END)` (same known quirk as the empty-options +/// case), so such a datagram is a thread-killer on receipt. Locked. +#[test] +fn sec_only_giant_option_yields_bare_end_packet() { + let p = test_packet( + 0xD003, + [3; 6], + vec![DhcpOption::Unrecognized(RawDhcpOption { + code: 200, + data: vec![9u8; 300], + })], + ); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert_eq!(enc.len(), 241, "nothing fits: header+cookie+END only"); + assert_eq!(enc[240], 255); + let r = std::panic::catch_unwind(move || { + let _ = Packet::from(&enc); + }); + assert!(r.is_err(), "bare END must panic on decode (known quirk)"); +} + +// =========================================================================== +// B. Example-handler blind spots: silently ignored message types +// =========================================================================== + +/// DHCPINFORM gets NO reply from the example logic (the `_ => {}` arm; +/// upstream even has a TODO for it). A live INFORM with valid shape times +/// out — locked so adding Inform support later is a deliberate change. +#[test] +fn sec_inform_gets_no_reply_live() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(400))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let p = Packet { + reply: false, + hops: 0, + xid: 0xD010, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 2, 60), + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xAA; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Inform), + DhcpOption::ParameterRequestList(vec![1, 15]), + ], + }; + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "INFORM must get no reply from example logic" + ); +} + +/// A forged server Offer (op=2) IS dispatched (no op check) but the example +/// match ignores it — full-stack proof that spoofed server packets cost CPU +/// but earn no reply. +#[test] +fn sec_forged_offer_gets_no_reply_live() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(400))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let p = Packet { + reply: true, // forged BOOTREPLY + hops: 0, + xid: 0xD011, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xBB; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Offer)], + }; + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "forged Offer must get no reply" + ); +} + +// =========================================================================== +// C. NAK wire shape, Router-on-wire, Request precedence, bare Request +// =========================================================================== + +/// Live NAK carries zeroed addrs, Server ID, and — under the 300-byte cap — +/// the example's 26-byte explanation text (249+2+26 = 277 < 300), so NAK +/// clients now see a reason string. +#[test] +fn sec_nak_wire_message_text_and_addrs() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // Occupy .50 with another MAC so this request NAKs. + let squatter = [0xCC; 6]; + let victim = [0xDD; 6]; + let occupier = Packet { + reply: false, hops: 0, xid: 0xD020, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: squatter, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 50)), + ], + }; + client.send_to(&occupier.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("occupier Ack"); + assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).message_type(), Ok(MessageType::Ack)); + // Victim asks for the taken IP -> NAK with exact text. + let req = Packet { + reply: false, hops: 0, xid: 0xD021, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 50)), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("NAK"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Nak)); + assert_eq!(rep.ciaddr, Ipv4Addr::UNSPECIFIED); + assert_eq!(rep.yiaddr, Ipv4Addr::UNSPECIFIED); + assert!( + rep.option(MESSAGE) == Some(&DhcpOption::Message("Requested IP not available".to_string())), + "26-byte NAK text reaches the wire under the 300 cap (249+2+26 = 277)" + ); + assert_eq!( + rep.option(SERVER_IDENTIFIER), + Some(&DhcpOption::ServerIdentifier(server_ip)) + ); + // A short message DOES fit alongside 53+54: control proof the path works. + let short = DhcpOption::Message("no".to_string()).to_raw(); + assert_eq!(short.data.len(), 2); +} + +/// Router IS present on the wire of an example Offer (code 3, len 4) even +/// though this library's own decoder drops it — proves the server really +/// sends the gateway and only the local decode path is blind. +#[test] +fn sec_router_present_on_wire_but_dropped_on_decode() { + let p = Packet { + reply: true, + hops: 0, + xid: 1, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::new(192, 168, 2, 90), + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Offer), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + DhcpOption::IpAddressLeaseTime(86400), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 2, 1)]), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!( + enc.windows(6).any(|w| w == [3, 4, 192, 168, 2, 1]), + "router TLV must be on the wire" + ); + let q = unwrap_packet(Packet::from(&enc)); + assert!( + q.option(ROUTER).is_none(), + "local decode drops Router (known quirk)" + ); + assert_eq!( + q.options.iter().map(|o| o.code()).collect::>(), + vec![53, 54, 51, 1], + "decodable prefix kept in order" + ); +} + +/// When both Requested IP and `ciaddr` are set, Requested IP wins — `ciaddr` +/// is only a fallback (RENEWING). An attacker setting both cannot smuggle a +/// different `ciaddr` past the selection. +#[test] +fn sec_requested_ip_beats_ciaddr_when_both_set() { + let mut s = Replica::new(); + let mac = [0x11; 6]; + let got = s + .request( + &mac, + Some(Ipv4Addr::new(192, 168, 2, 80)), + Ipv4Addr::new(192, 168, 2, 70), + true, + ) + .unwrap(); + assert_eq!(got, Ipv4Addr::new(192, 168, 2, 80)); + assert!(s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 80))); + assert!(!s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 70))); +} + +/// A bare Request (no Requested IP, `ciaddr` 0.0.0.0, no current lease) NAKs +/// cleanly with NO insert — 0.0.0.0 never enters the lease table, no panic. +#[test] +fn sec_bare_request_naks_without_insert() { + let mut s = Replica::new(); + let mac = [0x22; 6]; + assert!(s + .request(&mac, None, Ipv4Addr::UNSPECIFIED, true) + .is_err()); + assert!(s.leases.is_empty(), "0.0.0.0 must never be leased"); +} + +// =========================================================================== +// D. Config trust: reservations, duplicates, sloppy lines +// =========================================================================== + +/// QUIRK: a `leases`-file reservation outside the pool is offered verbatim — +/// no validation that reserved IPs lie in `[START, START+NUM)`. A bad config +/// line makes the server offer off-subnet addresses. Locked. +#[test] +fn sec_out_of_pool_reservation_offered_verbatim_quirk() { + let mut s = Replica::new(); + let mac = [0x33; 6]; + s.leases.insert(Ipv4Addr::new(10, 0, 0, 5), (mac, None)); + assert_eq!(s.discover(&mac), Some(Ipv4Addr::new(10, 0, 0, 5))); +} + +/// Duplicate `leases` lines for one IP: last line wins silently (plain +/// `HashMap::insert`, no conflict error) — locked so rotation mistakes stay +/// visible in review rather than changing meaning. +#[test] +fn sec_duplicate_leases_ip_last_wins() { + let mut leases: HashMap)> = HashMap::new(); + for line in [ + "aa:bb:cc:dd:ee:01,192.168.2.10", + "aa:bb:cc:dd:ee:02,192.168.2.10", + ] { + let (mac, ip) = parse_leases_line(line).unwrap(); + leases.insert(ip, (mac, None)); + } + assert_eq!(leases.len(), 1); + assert_eq!( + leases[&Ipv4Addr::new(192, 168, 2, 10)].0, + [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02], + "second line overwrites first" + ); +} + +/// QUIRK: a trailing space in the MAC (`"...ee:ff ,ip"`) makes the last octet +/// fail `from_str_radix`, so `filter_map` drops it, the 6-octet check fails, +/// and the whole reservation line is silently skipped. Locked. +#[test] +fn sec_trailing_space_mac_line_skipped_quirk() { + assert_eq!(parse_leases_line("aa:bb:cc:dd:ee:ff ,192.168.2.10"), None); +} + +/// QUIRK: one non-hex octet (`zz`) is filtered out, leaving 5 octets, so the +/// line is silently skipped with no error — a typo can silently drop a +/// reservation. Locked. +#[test] +fn sec_bad_octet_mac_line_skipped() { + assert_eq!(parse_leases_line("aa:bb:cc:dd:ee:zz,192.168.2.10"), None); + assert_eq!(parse_leases_line("aa::cc:dd:ee:ff,192.168.2.10"), None); +} + +// =========================================================================== +// E. Server-authoritative lease time: wire value from client ignored +// =========================================================================== + +/// A client requesting lease 60s still gets an Ack for 86400s with a full +/// 86400s server-side expiry — the Request's option-51 value is never +/// consulted (example handler hard-codes the duration). Locks that clients +/// cannot shrink/extend their own lease. +#[test] +fn sec_request_lease_time_ignored_server_sends_fixed() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, + hops: 0, + xid: 0xD030, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x44; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 71)), + DhcpOption::IpAddressLeaseTime(60), // client asks short + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("Ack"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!( + rep.option(IP_ADDRESS_LEASE_TIME), + Some(&DhcpOption::IpAddressLeaseTime(86400)), + "server duration wins over requested 60s" + ); + // Replica-level: expiry is always full duration from now. + let mut s = Replica::new(); + let before = Instant::now(); + let mac = [0x45; 6]; + s.request(&mac, Some(Ipv4Addr::new(192, 168, 2, 72)), Ipv4Addr::UNSPECIFIED, true) + .unwrap(); + let (_, expiry) = &s.leases[&Ipv4Addr::new(192, 168, 2, 72)]; + let exp = expiry.expect("timed lease"); + assert!(exp >= before.add(Duration::from_secs(u64::from(LEASE_DURATION_SECS - 5)))); +} diff --git a/tests/test_security_gates.rs b/tests/test_security_gates.rs new file mode 100644 index 0000000..2017ba8 --- /dev/null +++ b/tests/test_security_gates.rs @@ -0,0 +1,620 @@ +//! DHCP security gates + filter/framing compositions, part 5. +//! +//! Covers security-relevant behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, `test_security_edge.rs`, `test_security_wire.rs` +//! or the RFC suites — mostly *compositions* of individually-tested quirks: +//! +//! - PRL filtering (not just the 300 cap) strips the NAK Message text live +//! - an RFC-valid PAD byte before END truncates the option list (desync via +//! spec-compliant bytes, unlike the malformed-option tests) +//! - zero-length option matrix: which empty options decode vs reject, +//! including the Router/DNS-empty quirk +//! - empty Router/DNS encode to `[3,0]`/`[6,0]` on the wire but decode-drop, +//! while empty PRL survives (asymmetry) +//! - garbage-datagram battery (empty/1-byte/short/bad-cookie) keeps the +//! server alive for the next valid Discover +//! - kernel-style truncation of an oversized datagram decodes without panic +//! - Request with a *wrong* (not just missing) Server ID is still honored +//! - Decline from an unknown chaddr is a silent no-op with no reply +//! - broadcast-flagged Release still frees when the gate passes +//! +//! `*_quirk` tests lock currently-unsafe behavior for review, not approval. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::HashMap; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security_wire.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +#[allow(clippy::too_many_arguments)] +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +// Replica of examples/server.rs lease state (bodies identical; only the +// methods needed here are included). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, + #[allow(dead_code)] + server_ip: Ipv4Addr, +} + +impl ExampleLike { + fn new(server_ip: Ipv4Addr) -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + server_ip, + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(server_ip), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +// =========================================================================== +// A. PRL filtering (not size) strips the NAK text; RFC-valid PAD desyncs +// =========================================================================== + +/// The NAK Message is dropped by *PRL filtering* here, not just the 300 cap: +/// unit-level, `[53,54,56]` filtered by req `[1]` keeps `[53,54]` although +/// the 7-byte packet would easily fit. Live, a victim Request carrying +/// PRL=`[1]` gets a textless NAK. Locked (both mechanisms coincide live). +#[test] +fn sec4_nak_message_dropped_by_prl_filter_not_size() { + // unit: short text fits size-wise, filter drops it anyway + let mut opts = vec![ + DhcpOption::DhcpMessageType(MessageType::Nak), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + DhcpOption::Message("no".to_string()), + ]; + server::filter_options_by_req(&mut opts, &[1]); + assert_eq!( + opts.iter().map(|o| o.code()).collect::>(), + vec![53, 54], + "PRL [1] drops Message even though it fits" + ); + // live: occupier takes .50, victim asks with PRL=[1] -> textless NAK + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let occupier = Packet { + reply: false, hops: 0, xid: 0xA001, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA0; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 50)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &occupier, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + let req = Packet { + reply: false, hops: 0, xid: 0xA002, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA1; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 50)), + DhcpOption::ParameterRequestList(vec![1]), + ], + }; + let rep = send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Nak)); + assert!(rep.option(MESSAGE).is_none(), "PRL filter strips NAK text"); + assert_eq!( + rep.option(SERVER_IDENTIFIER), + Some(&DhcpOption::ServerIdentifier(server_ip)) + ); +} + +/// RFC 2132 says receivers MUST handle PAD octets, but here a single +/// spec-compliant `0x00` before END truncates the option list exactly like a +/// malformed option: Server ID after the PAD is stripped, leading Discover +/// survives. Desync with fully valid bytes — locked. +#[test] +fn sec4_rfc_pad_byte_before_end_truncates_list() { + let raw = make_raw( + 1, 6, 0, 0xA010, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0xA2; 6], + vec![53, 1, 1, 0, 54, 4, 10, 20, 30, 40, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1, "PAD truncates like a bad option"); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); + assert!(p.option(SERVER_IDENTIFIER).is_none(), "Server ID stripped"); +} + +// =========================================================================== +// B. Zero-length option matrix (decode edges per code) +// =========================================================================== + +/// Zero-length options split by kind: variable-length/text codes accept +/// empty (`55`/`12`/`56`/unrecognized incl. PAD-as-TLV `0`), while every +/// fixed-size code rejects — including Router/DNS, which fail even empty +/// (same `custom_many0` quirk as populated ones). Exact errors locked. +#[test] +fn sec4_zero_length_option_matrix() { + // accepted as empty + match decode_option(&[55, 0]) { + Ok((_, DhcpOption::ParameterRequestList(v))) => assert!(v.is_empty()), + _ => panic!("[55,0] must decode to empty PRL"), + } + match decode_option(&[12, 0]) { + Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, ""), + _ => panic!("[12,0] must decode to empty HostName"), + } + match decode_option(&[56, 0]) { + Ok((_, DhcpOption::Message(s))) => assert_eq!(s, ""), + _ => panic!("[56,0] must decode to empty Message"), + } + match decode_option(&[99, 0]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 99); + assert!(r.data.is_empty()); + } + _ => panic!("[99,0] must decode to empty Unrecognized"), + } + match decode_option(&[0, 0]) { + Ok((_, DhcpOption::Unrecognized(r))) => { + assert_eq!(r.code, 0); + assert!(r.data.is_empty()); + } + _ => panic!("[0,0] must decode to empty Unrecognized"), + } + // rejected: fixed-size codes need payload bytes + for wire in [ + vec![53, 0], // message type needs 1 byte + vec![54, 0], // server id needs 4 + vec![50, 0], // requested ip needs 4 + vec![1, 0], // subnet mask needs 4 + vec![51, 0], // lease time needs 4 + ] { + assert!( + matches!(decode_option(&wire), Err(CustomErr::InvalidHlen)), + "{:?} must be InvalidHlen", + wire + ); + } + // rejected quirk: Router/DNS fail even with empty data + for wire in [vec![3, 0], vec![6, 0]] { + assert!( + matches!(decode_option(&wire), Err(CustomErr::InvalidHlen)), + "{:?} must be InvalidHlen (many0 quirk)", + wire + ); + } +} + +/// Empty Router/DNS encode to `[3,0]`/`[6,0]` on the wire but decode-drop, +/// while empty PRL survives the round-trip — the asymmetry, end to end. +#[test] +fn sec4_empty_router_dns_dropped_empty_prl_kept() { + let p = Packet { + reply: false, hops: 0, xid: 0xA020, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA3; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + DhcpOption::ParameterRequestList(vec![]), + DhcpOption::Router(vec![]), + DhcpOption::DomainNameServer(vec![]), + ], + }; + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + for needle in [vec![55u8, 0], vec![3u8, 0], vec![6u8, 0]] { + assert!( + enc.windows(2).any(|w| w == needle.as_slice()), + "{:?} must be on the wire", + needle + ); + } + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!( + q.options.iter().map(|o| o.code()).collect::>(), + vec![53, 55], + "empty Router/DNS dropped, empty PRL kept" + ); +} + +// =========================================================================== +// C. Garbage survival + kernel-style truncation (live + unit) +// =========================================================================== + +/// Battery of garbage datagrams (empty, 1 byte, short header, bad cookie) +/// keeps the server alive: the next valid Discover still gets Offer `.3`. +/// Malformed input never kills the loop when the END-skip lands non-empty. +#[test] +fn sec4_garbage_datagram_battery_server_survives_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 7, 10), + p, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 7, 1), + Ipv4Addr::new(192, 168, 7, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // each of these decodes to Err (no panic: tiny inputs fail before split) + client.send_to(&[], srv_addr).unwrap(); + client.send_to(&[0u8], srv_addr).unwrap(); + client.send_to(&[0u8; 10], srv_addr).unwrap(); + client.send_to(&[0u8; 235], srv_addr).unwrap(); + let mut bad_cookie = vec![0u8; 236]; + bad_cookie[0] = 1; + bad_cookie[1] = 1; + bad_cookie[2] = 6; + bad_cookie.extend_from_slice(&[9, 9, 9, 9, 53, 1, 1, 255]); + client.send_to(&bad_cookie, srv_addr).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + // server still alive and answering + let disc = Packet { + reply: false, hops: 0, xid: 0xA030, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA4; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + client.send_to(&disc.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("server survived garbage"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 7, 10)); +} + +/// An 1844-byte datagram sliced to 1500 (kernel-style truncation) still +/// decodes: the cut lands mid-TLV, the swallow skips one byte, options stay +/// bounded, and re-encode caps at 300 with Discover first. No panic. +#[test] +fn sec4_kernel_style_truncation_decodes_without_panic() { + let mut opts = vec![53, 1, 1]; + for _ in 0..400 { + opts.extend_from_slice(&[200, 2, 7, 8]); + } + opts.push(255); + let raw = make_raw( + 1, 6, 0, 0xA031, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0xA5; 6], opts, + ); + assert_eq!(raw.len(), 1844); + let truncated = &raw[..1500]; // what recv_into-1500 would deliver + let p = unwrap_packet(Packet::from(truncated)); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); + assert!(p.options.len() < 402, "truncated tail lost, prefix kept"); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() <= 300, "re-encode capped"); +} + +// =========================================================================== +// D. Gate compositions: wrong-ID Request honored; Decline no-lease silent +// =========================================================================== + +/// A Request naming a *wrong* (but well-formed) server is still Acked — +/// complements the missing-ID renew test: the gate is disabled for values +/// too, not just absence. Locked live. +#[test] +fn sec4_wrong_id_request_honored_live() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, hops: 0, xid: 0xA040, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA6; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(10, 99, 99, 99)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 73)), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("Ack despite wrong ID"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 73)); +} + +/// Decline from an unknown chaddr (no lease anywhere) is a silent no-op with +/// no reply and no state change — documents the quiet failure mode live. +#[test] +fn sec4_decline_no_lease_no_reply_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(400))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let dec = Packet { + reply: false, hops: 0, xid: 0xA041, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA7; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Decline), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 74)), + ], + }; + client.send_to(&dec.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "Decline with no lease must get no reply" + ); +} + +// =========================================================================== +// E. Broadcast Release still gated only on server ID, not flags +// =========================================================================== + +/// A broadcast-flagged Release with the correct Server ID still frees the +/// lease — flags play no role in the gate; only the identifier matters. +/// Locked live (precondition asserts the flag survived decode). +#[test] +fn sec4_broadcast_release_still_gated_only_on_id_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0xA8; 6]; + // occupy .75 + let req = Packet { + reply: false, hops: 0, xid: 0xA050, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 75)), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("Ack"); + assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).message_type(), Ok(MessageType::Ack)); + // broadcast Release naming the right server: freed (flag irrelevant) + let raw = make_raw( + 1, 6, 0, 0xA051, 0, [0, 128], // low-byte 0x80 -> broadcast=true + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + victim, + vec![53, 1, 7, 54, 4, 192, 168, 2, 1, 255], + ); + assert!(unwrap_packet(Packet::from(&raw)).broadcast, "precondition"); + client.send_to(&raw, srv_addr).unwrap(); + // prove freed: a stranger can now take .75 + let stranger = Packet { + reply: false, hops: 0, xid: 0xA052, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0xA9; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 75)), + ], + }; + client.send_to(&stranger.encode(&mut buf).to_vec(), srv_addr).unwrap(); + // Release itself gets no reply; the stranger's unicast Ack proves the + // Release freed .75 regardless of the broadcast flag. + let (n, _) = client.recv_from(&mut rbuf).expect("stranger Ack"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 75), "freed by broadcast Release"); +} diff --git a/tests/test_security_hijack.rs b/tests/test_security_hijack.rs new file mode 100644 index 0000000..b59b558 --- /dev/null +++ b/tests/test_security_hijack.rs @@ -0,0 +1,656 @@ +//! DHCP security: slowloris-style resilience + lease hijacking, part 5. +//! +//! Covers security-relevant behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, `test_security_edge.rs`, `test_security_wire.rs`, +//! `test_security_gates.rs`, or the RFC suites: +//! +//! - A. Slowloris analogues for a datagram server: dribbled 1-byte sends hold +//! no server state (each `recv_from` is independent — there is nothing to +//! keep half-open); short/partial-cookie datagrams are rejected without +//! panic at exact size boundaries; a burst of legitimate clients is all +//! answered with per-transaction xids; one datagram over the 1500-byte +//! recv buffer kills the server thread (recv error returns from `serve`). +//! - B. Lease hijacking end-to-end (live): identity is the raw 6-byte +//! `chaddr` with no authentication, so spoofing the victim `chaddr` in +//! Discover recons the victim IP, in Request takes it over, and in a +//! ciaddr-only renew it Acks — while a stranger's NAK leaves the victim +//! lease intact and a spoofed Release frees it for claiming. +//! +//! `*_quirk` tests lock currently-unsafe behavior for review, not approval. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security_wire.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +#[allow(clippy::too_many_arguments)] +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl ExampleLike { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn discover(xid: u32, chaddr: [u8; 6]) -> Packet { + Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + } +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +// =========================================================================== +// A. Slowloris analogues: dribbles, short datagrams, bursts, truncation +// =========================================================================== + +/// Slowloris adaptation to UDP: 100 one-byte "trickled" datagrams hold no +/// server state (each `recv_from` is independent — there is nothing to keep +/// half-open), are all ignored, and the next valid Discover is answered. +/// Locked: dribble is pure noise, not resource retention. +#[test] +fn sec5_dribbled_single_bytes_hold_no_state() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + for _ in 0..100 { + client.send_to(&[0x99], srv_addr).unwrap(); + } + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let rep = send_recv(&client, &srv_addr, &discover(0xD101, [0x11; 6]), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); +} + +/// Size boundaries around the 236-byte header + 4-byte cookie: header-only +/// (236) and partial-cookie (237–239) datagrams are rejected with errors and +/// never panic; only the cookie-complete-but-optionless 240-byte form panics +/// (covered by the next test). Locks exactly where Err ends and panic begins. +#[test] +fn sec5_short_and_partial_cookie_rejected_without_panic() { + let mut header_only = vec![0u8; 236]; + header_only[0] = 1; + header_only[1] = 1; + header_only[2] = 6; + assert!(matches!(Packet::from(&header_only), Err(CustomErr::NomError(_)))); + for len in [237usize, 238, 239] { + let mut v = header_only.clone(); + v.extend_from_slice(&[99, 130, 83, 99][..len - 236]); + assert!( + matches!(Packet::from(&v), Err(CustomErr::NomError(_))), + "len {} must be NomError, not panic", + len + ); + } + assert!(matches!(Packet::from(&[0u8; 10]), Err(CustomErr::InvalidHlen))); + assert!(matches!(Packet::from(&[] as &[u8]), Err(CustomErr::InvalidHlen))); +} + +/// The 240-byte cookie-complete, optionless datagram panics on +/// `rest.split_at(1)` — the smallest single-packet server-thread killer. +/// Locked with `should_panic` (live kill covered by the serve-panic tests). +#[test] +#[should_panic] +fn sec5_cookie_only_no_options_panics_quirk() { + let mut raw = vec![0u8; 236]; + raw[0] = 1; + raw[1] = 1; + raw[2] = 6; + raw.extend_from_slice(&[99, 130, 83, 99]); + assert_eq!(raw.len(), 240); + let _ = Packet::from(&raw); +} + +/// Overwhelm resilience: 30 back-to-back Discovers from distinct clients are +/// all answered with per-transaction xids and distinct pool IPs, and a +/// follow-up still works — the single-threaded loop keeps up, no drops or +/// cross-talk on loopback. Locked. +#[test] +fn sec5_burst_30_discovers_all_answered() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mut seen_xids = HashSet::new(); + let mut seen_ips = HashSet::new(); + for i in 0..30u32 { + let ch = [i as u8, 0xAA, 0xBB, 0xCC, 0xDD, (i >> 8) as u8]; + let xid = 0xE000 + i; + client + .send_to(&discover(xid, ch).encode(&mut buf).to_vec(), srv_addr) + .unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("burst reply"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.xid, xid, "per-transaction xid echo"); + seen_xids.insert(rep.xid); + assert!(seen_ips.insert(rep.yiaddr), "distinct offers"); + } + assert_eq!(seen_xids.len(), 30); + assert_eq!(seen_ips.len(), 30); + // still responsive afterwards + let rep = send_recv(&client, &srv_addr, &discover(0xE099, [0x99; 6]), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); +} + +/// QUIRK (single-packet kill): a datagram larger than the 1500-byte recv +/// buffer makes `recv_from` fail on Windows, and `serve` returns on ANY recv +/// error — so one 1844-byte datagram shuts the whole server down (the thread +/// exits; follow-up clients get connection-reset, not replies). A 1498-byte +/// datagram works fine, pinning the boundary at the buffer size. The decoder +/// itself would have handled the truncated bytes (see the local assert), so +/// the kill happens at the socket layer, not in parsing. Locked. +#[test] +fn sec5_oversized_datagram_kills_server_thread_quirk() { + let mut opts = vec![53, 1, 1]; + for _ in 0..400 { + opts.extend_from_slice(&[200, 2, 7, 8]); + } + opts.push(255); + let raw = make_raw( + 1, 6, 0, 0xC101, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0x77; 6], opts, + ); + assert_eq!(raw.len(), 1844); + // the decoder itself is fine with the truncated prefix (cut lands + // mid-TLV, the swallow skips one byte) — proving the kill is at recv: + let cut = unwrap_packet(Packet::from(&raw[..1500])); + assert_eq!(cut.message_type(), Ok(MessageType::Discover)); + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 9, 10), + p, + ); + } + } + let handle = std::thread::spawn(move || { + server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 9, 1), + Ipv4Addr::new(192, 168, 9, 255), + H, + ) + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // baseline: a near-buffer-size (1498 B) datagram is answered normally + let mut big_opts = vec![53, 1, 1]; + for _ in 0..418 { + big_opts.extend_from_slice(&[200, 1, 7]); + } + big_opts.push(255); + let big = make_raw( + 1, 6, 0, 0xC102, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0x78; 6], big_opts, + ); + assert_eq!(big.len(), 1498); + client.send_to(&big, srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("baseline reply"); + assert_eq!(unwrap_packet(Packet::from(&rbuf[..n])).xid, 0xC102); + // one oversized datagram: no reply, server thread exits + client.send_to(&raw, srv_addr).unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "oversized datagram gets no reply" + ); + std::thread::sleep(Duration::from_millis(200)); + assert!(handle.is_finished(), "serve loop must have exited"); + // server stays dead: follow-up valid Discover gets no Offer + let p = Packet { + reply: false, hops: 0, xid: 0xC103, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x79; 6], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!( + client.recv_from(&mut rbuf).is_err(), + "dead server answers nothing" + ); +} + +// =========================================================================== +// B. Lease hijacking end-to-end (live): chaddr-only identity, no auth +// =========================================================================== + +/// Full hijack chain, step 1 (recon): attacker sends Discover spoofing the +/// victim `chaddr` and is offered the victim's *current* IP — identity is +/// chaddr bytes only, so the offer itself leaks the victim's address. +#[test] +fn sec5_hijack_discover_as_victim_offered_victim_ip_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x51; 6]; + // victim legitimately takes .60 + let req = Packet { + reply: false, hops: 0, xid: 0xF001, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 60)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr, + Ipv4Addr::new(192, 168, 2, 60) + ); + // attacker replays victim chaddr in Discover -> offered victim's .60 + let rep = send_recv(&client, &srv_addr, &discover(0xF002, victim), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!( + rep.yiaddr, + Ipv4Addr::new(192, 168, 2, 60), + "spoofed Discover recons victim IP" + ); +} + +/// Full hijack chain, step 2 (takeover): attacker sends Request spoofing the +/// victim `chaddr` and is Acked for the victim's IP with zero credentials — +/// same bytes any observer saw on the wire. Locked live. +#[test] +fn sec5_hijack_request_as_victim_acked_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x52; 6]; + let req = Packet { + reply: false, hops: 0, xid: 0xF010, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 61)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr, + Ipv4Addr::new(192, 168, 2, 61) + ); + // "attacker" knows only public bytes: victim chaddr + IP from recon + let hijack = Packet { + reply: false, hops: 0, xid: 0xF011, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, // spoofed + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 61)), + ], + }; + let rep = send_recv(&client, &srv_addr, &hijack, &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 61), "takeover Acked"); +} + +/// Renew-path hijack: attacker sends ciaddr-only Request (no Requested IP, +/// no Server ID) spoofing victim `chaddr` and is Acked — the RENEWING +/// fallback needs no options at all. Locked live. +#[test] +fn sec5_hijack_renew_via_ciaddr_spoof_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x53; 6]; + let req = Packet { + reply: false, hops: 0, xid: 0xF020, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 62)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr, + Ipv4Addr::new(192, 168, 2, 62) + ); + // renew-shaped hijack: only ciaddr + spoofed chaddr + let renew = Packet { + reply: false, hops: 0, xid: 0xF021, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 2, 62), yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, // spoofed + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + let rep = send_recv(&client, &srv_addr, &renew, &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 62), "renew hijack Acked"); +} + +/// Failed hijack attempt changes nothing: a stranger's Request for the taken +/// `.63` gets NAK, and the victim's lease is intact (still offered `.63`). +/// Locks NAK-without-side-effects live. +#[test] +fn sec5_hijack_nak_leaves_victim_lease_intact_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x54; 6]; + let stranger = [0x55; 6]; + let req = Packet { + reply: false, hops: 0, xid: 0xF030, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 63)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr, + Ipv4Addr::new(192, 168, 2, 63) + ); + // stranger grabs at .63 -> NAK + let grab = Packet { + reply: false, hops: 0, xid: 0xF031, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: stranger, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 63)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &grab, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Nak) + ); + // victim still holds .63 + let rep = send_recv(&client, &srv_addr, &discover(0xF032, victim), &mut buf, &mut rbuf); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 63), "victim intact after NAK"); +} + +/// Release-hijack end state: spoofed Release frees `.64`, and a stranger +/// immediately claims it — full free-then-takeover chain over the wire. +#[test] +fn sec5_hijack_release_spoof_frees_then_attacker_claims_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x56; 6]; + let attacker = [0x57; 6]; + let req = Packet { + reply: false, hops: 0, xid: 0xF040, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 64)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).yiaddr, + Ipv4Addr::new(192, 168, 2, 64) + ); + // spoofed Release (victim chaddr, correct server ID): no reply, freed + let rel = Packet { + reply: false, hops: 0, xid: 0xF041, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, // spoofed + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(server_ip), + ], + }; + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + // attacker (own chaddr) claims the freed .64 + let claim = Packet { + reply: false, hops: 0, xid: 0xF042, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: attacker, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 64)), + ], + }; + let rep = send_recv(&client, &srv_addr, &claim, &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 64), "freed IP claimed"); +} diff --git a/tests/test_security_races.rs b/tests/test_security_races.rs new file mode 100644 index 0000000..9ad2f45 --- /dev/null +++ b/tests/test_security_races.rs @@ -0,0 +1,639 @@ +//! DHCP security: timing attacks and race conditions. +//! +//! Covers timing/race behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, `test_security_edge.rs`, `test_security_wire.rs`, +//! `test_security_gates.rs`, `test_security_hijack.rs`, +//! `test_security_slowloris.rs`, or the RFC suites. All tests assert the +//! *current* behavior and pass against it; tests for known-unsafe behavior +//! end in `_quirk` and state the threat in the docs. +//! +//! A note on "timing attacks" here: this stack has no wall-clock branches +//! except lease expiry (`Instant::now().gt(&exp)`), so there are no +//! timing oracles to measure — the timing-relevant guarantees are structural +//! instead: retransmits are idempotent, races converge to exactly one winner, +//! failed paths mutate nothing, and expiry uses strict `>` (an expiry equal +//! to `now` is still held; only strictly-past expiries are reusable, which +//! is why the zero-duration test below sleeps before asserting reuse). +//! +//! Groups: +//! - A. Retransmit / storm idempotency: identical repeats converge, NAK path +//! inserts nothing, zero-duration leases are immediately reusable. +//! - B. Races with order-independent outcomes: last-free-IP race has exactly +//! one winner; concurrent same-chaddr Acks agree; pipelined dual-DORA has +//! no cross-talk; spurious Release mid-handshake is a no-op. +//! - C. Release/reacquire lifecycle over the wire. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::HashMap; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security_slowloris.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +// Minimal replica of examples/server.rs lease state (same method bodies as +// test_examples_extra.rs; only the methods needed here are included). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +struct Replica { + leases: HashMap)>, + lease_duration: Duration, +} + +impl Replica { + fn with_duration(lease_duration: Duration) -> Self { + Self { + leases: HashMap::new(), + lease_duration, + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + fn request( + &mut self, + chaddr: &[u8; 6], + requested: Option, + ciaddr: Ipv4Addr, + _for_this: bool, + ) -> Result { + let req_ip = requested.unwrap_or(ciaddr); + if let Some(ip) = self.current_lease(chaddr) { + return Ok(ip); + } + if !self.available(chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + (chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))), + ); + Ok(req_ip) + } +} + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl ExampleLike { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +fn discover(xid: u32, chaddr: [u8; 6]) -> Packet { + test_packet( + xid, + chaddr, + vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + ) +} + +fn request_selecting( + xid: u32, + chaddr: [u8; 6], + server_ip: Ipv4Addr, + wanted: Ipv4Addr, +) -> Packet { + test_packet( + xid, + chaddr, + vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(wanted), + ], + ) +} + +// =========================================================================== +// A. Retransmit / storm idempotency and NAK purity +// =========================================================================== + +/// Retransmission storm: 20 byte-identical Requests earn 20 identical Acks +/// for one single table entry — repeats never duplicate, fork, or NAK. +/// Locked live. +#[test] +fn sec8_retransmit_storm_same_chaddr_single_lease_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mac = [0x71; 6]; + for i in 0..20u32 { + let rep = send_recv( + &client, + &srv_addr, + &request_selecting(0xB000 + i, mac, server_ip, Ipv4Addr::new(192, 168, 2, 60)), + &mut buf, + &mut rbuf, + ); + assert_eq!(rep.message_type(), Ok(MessageType::Ack), "storm {}", i); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 60), "storm {}", i); + } + // still exactly the one lease: a stranger is NAK'd for it afterwards + let stranger = send_recv( + &client, + &srv_addr, + &request_selecting(0xB100, [0x72; 6], server_ip, Ipv4Addr::new(192, 168, 2, 60)), + &mut buf, + &mut rbuf, + ); + assert_eq!(stranger.message_type(), Ok(MessageType::Nak)); +} + +/// NAK path is mutation-free: stranger denied, table size and victim mapping +/// unchanged. Locks that denial performs no insert (the availability check +/// has no write side effects). +#[test] +fn sec8_nak_path_performs_no_insert_replica() { + let mut s = Replica::with_duration(Duration::from_secs(3600)); + let victim = [0x73; 6]; + let stranger = [0x74; 6]; + let ip = Ipv4Addr::new(192, 168, 2, 61); + assert_eq!(s.request(&victim, Some(ip), Ipv4Addr::UNSPECIFIED, true), Ok(ip)); + assert_eq!(s.leases.len(), 1); + assert_eq!( + s.request(&stranger, Some(ip), Ipv4Addr::UNSPECIFIED, true), + Err("Requested IP not available") + ); + assert_eq!(s.leases.len(), 1, "NAK inserted nothing"); + assert_eq!(s.leases[&ip].0, victim, "victim mapping intact"); + assert_eq!(s.current_lease(&stranger), None); +} + +/// Zero-duration leases expire immediately: after a short sleep a stranger +/// finds the address reusable. Documents the `gt` (strictly-past) boundary +/// without asserting the racy exact-now instant itself. +#[test] +fn sec8_zero_duration_lease_immediately_reusable_replica() { + let mut s = Replica::with_duration(Duration::from_secs(0)); + let holder = [0x75; 6]; + let stranger = [0x76; 6]; + let ip = Ipv4Addr::new(192, 168, 2, 62); + assert_eq!(s.request(&holder, Some(ip), Ipv4Addr::UNSPECIFIED, true), Ok(ip)); + std::thread::sleep(Duration::from_millis(10)); + assert!( + s.available(&stranger, &ip), + "zero-duration lease must already read expired" + ); + assert_eq!(s.request(&stranger, Some(ip), Ipv4Addr::UNSPECIFIED, true), Ok(ip)); + assert_eq!(s.leases[&ip].0, stranger, "reuse transfers ownership"); +} + +// =========================================================================== +// B. Races with order-independent outcomes +// =========================================================================== + +/// Race for the last free IP: pool filled except `.253` over the wire, then +/// two Requests for `.253` sent back-to-back. Exactly one Ack and one Nak +/// arrive (asserted as a multiset, so loopback ordering cannot flake it), +/// and exactly one of the two racers holds `.253` afterwards (XOR). +/// Locked live. +#[test] +fn sec8_last_free_ip_race_one_ack_one_nak_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + // fill every pool address except .253 (START+251) + for i in 0..251u32 { + let ch = [ + (i >> 16) as u8, + (i >> 8) as u8, + i as u8, + 0xAA, + 0xBB, + 0xCC, + ]; + let ip: Ipv4Addr = (IP_START_NUM + i).into(); + let rep = send_recv( + &client, + &srv_addr, + &request_selecting(0xC000 + i, ch, server_ip, ip), + &mut buf, + &mut rbuf, + ); + assert_eq!(rep.message_type(), Ok(MessageType::Ack), "fill {}", i); + } + // two racers, same target, back-to-back (no recv between sends) + let racer_a = [0xD0; 6]; + let racer_b = [0xD1; 6]; + let target = Ipv4Addr::new(192, 168, 2, 253); + client + .send_to( + &request_selecting(0xC100, racer_a, server_ip, target) + .encode(&mut buf) + .to_vec(), + srv_addr, + ) + .unwrap(); + client + .send_to( + &request_selecting(0xC101, racer_b, server_ip, target) + .encode(&mut buf) + .to_vec(), + srv_addr, + ) + .unwrap(); + let mut kinds = Vec::new(); + for _ in 0..2 { + let (n, _) = client.recv_from(&mut rbuf).expect("race reply"); + kinds.push(unwrap_packet(Packet::from(&rbuf[..n])).message_type().unwrap()); + } + kinds.sort_by_key(|m| *m as u8); + assert_eq!(kinds, vec![MessageType::Ack, MessageType::Nak]); + // exactly one racer holds .253 now (XOR over follow-up Discovers). + // NOTE: the pool is completely full, so the loser's Discover gets NO + // reply at all — use timeouts, not send_recv (which expects a reply). + client + .set_read_timeout(Some(Duration::from_millis(400))) + .unwrap(); + let mut holds = |chaddr: [u8; 6], xid: u32| -> bool { + let d = discover(xid, chaddr); + client.send_to(&d.encode(&mut buf).to_vec(), srv_addr).unwrap(); + match client.recv_from(&mut rbuf) { + Ok((n, _)) => unwrap_packet(Packet::from(&rbuf[..n])).yiaddr == target, + Err(_) => false, + } + }; + let holds_a = holds(racer_a, 0xC102); + let holds_b = holds(racer_b, 0xC103); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + assert!(holds_a ^ holds_b, "exactly one winner holds {:?}", target); +} + +/// Concurrent same-`chaddr` Requests from two threads: both Ack the same IP +/// (current-lease short-circuit), no duplicate entries possible. Locked. +#[test] +fn sec8_concurrent_same_chaddr_both_ack_same_ip_live() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let mac = [0xD2; 6]; + let handles: Vec<_> = [0xD200u32, 0xD201] + .iter() + .map(|&xid| { + std::thread::spawn(move || { + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: mac, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 70)), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("concurrent reply"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.xid, xid, "per-transaction xid echo"); + (rep.message_type().unwrap(), rep.yiaddr) + }) + }) + .collect(); + let mut results = Vec::new(); + for h in handles { + results.push(h.join().expect("client thread")); + } + assert_eq!(results.len(), 2); + for (ty, ip) in &results { + assert_eq!(*ty, MessageType::Ack); + assert_eq!(*ip, Ipv4Addr::new(192, 168, 2, 70), "both agree on one IP"); + } +} + +/// Pipelined dual-DORA: A and B interleave Discover/Request with no +/// cross-talk — A gets `.3`, B gets `.4`, Acks echo each xid with the +/// matching yiaddr. Locked live. +#[test] +fn sec8_pipelined_dual_dora_no_crosstalk_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let a = [0xE1; 6]; + let b = [0xE2; 6]; + let oa = send_recv(&client, &srv_addr, &discover(0xE001, a), &mut buf, &mut rbuf); + let ob = send_recv(&client, &srv_addr, &discover(0xE002, b), &mut buf, &mut rbuf); + assert_eq!(oa.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + assert_eq!(ob.yiaddr, Ipv4Addr::new(192, 168, 2, 4)); + let aa = send_recv( + &client, + &srv_addr, + &request_selecting(0xE003, a, server_ip, Ipv4Addr::new(192, 168, 2, 3)), + &mut buf, + &mut rbuf, + ); + let ab = send_recv( + &client, + &srv_addr, + &request_selecting(0xE004, b, server_ip, Ipv4Addr::new(192, 168, 2, 4)), + &mut buf, + &mut rbuf, + ); + assert_eq!((aa.message_type(), aa.yiaddr, aa.xid), (Ok(MessageType::Ack), Ipv4Addr::new(192, 168, 2, 3), 0xE003)); + assert_eq!((ab.message_type(), ab.yiaddr, ab.xid), (Ok(MessageType::Ack), Ipv4Addr::new(192, 168, 2, 4), 0xE004)); +} + +/// Spurious Release between a victim's Discover and Request is a no-op: +/// victim holds nothing yet, so there is nothing to free, and the follow-up +/// Request still commits the offered address. Locked live. +#[test] +fn sec8_spurious_release_between_offer_and_ack_no_effect_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0xE5; 6]; + let offer = send_recv(&client, &srv_addr, &discover(0xE010, victim), &mut buf, &mut rbuf); + assert_eq!(offer.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + // attacker (and victim) Release before anything is held: silent no-ops + for (xid, ch) in [(0xE011u32, [0xE6; 6]), (0xE012, victim)] { + let rel = Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: ch, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(server_ip), + ], + }; + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + } + // victim completes the handshake with the offered address regardless + let ack = send_recv( + &client, + &srv_addr, + &request_selecting(0xE013, victim, server_ip, Ipv4Addr::new(192, 168, 2, 3)), + &mut buf, + &mut rbuf, + ); + assert_eq!(ack.message_type(), Ok(MessageType::Ack)); + assert_eq!(ack.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); +} + +// =========================================================================== +// C. Release/reacquire lifecycle over the wire +// =========================================================================== + +/// Release then immediate re-Request re-acquires the same IP: the freed +/// address is available again and the pool slot is reusable, proving no +/// tombstoning. Locked live. +#[test] +fn sec8_release_then_reacquire_same_ip_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mac = [0xE8; 6]; + let ip = Ipv4Addr::new(192, 168, 2, 66); + let ack1 = send_recv( + &client, + &srv_addr, + &request_selecting(0xE020, mac, server_ip, ip), + &mut buf, + &mut rbuf, + ); + assert_eq!(ack1.yiaddr, ip); + // Release (correct server ID): no reply by design, lease freed + let rel = release_for(0xE021, mac, server_ip); + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + // immediate re-Request gets the very same address back + let ack2 = send_recv( + &client, + &srv_addr, + &request_selecting(0xE022, mac, server_ip, ip), + &mut buf, + &mut rbuf, + ); + assert_eq!(ack2.message_type(), Ok(MessageType::Ack)); + assert_eq!(ack2.yiaddr, ip, "freed slot immediately reusable"); +} + +fn release_for(xid: u32, chaddr: [u8; 6], server_ip: Ipv4Addr) -> Packet { + test_packet( + xid, + chaddr, + vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(server_ip), + ], + ) +} diff --git a/tests/test_security_slowloris.rs b/tests/test_security_slowloris.rs new file mode 100644 index 0000000..3a48679 --- /dev/null +++ b/tests/test_security_slowloris.rs @@ -0,0 +1,522 @@ +//! DHCP security: slowloris-style resilience + lease-hijacking chains. +//! +//! Covers security-relevant behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, `test_security_edge.rs`, `test_security_wire.rs`, +//! `test_security_gates.rs`, `test_security_hijack.rs`, or the RFC suites. +//! All tests assert the *current* behavior and pass against it; tests for +//! known-unsafe behavior end in `_quirk` and state the threat in the docs. +//! +//! Threat model: untrusted bytes arrive via UDP (`Server::serve`); any peer +//! can spoof `chaddr`/`xid`/`ciaddr`/options — there is no authentication +//! (RFC 3118 is not implemented) and lease identity is the raw 6-byte +//! `chaddr`. Lease state lives in the example `MyServer` replica here +//! (same bodies as `examples/server.rs`). +//! +//! Groups: +//! - A. Slowloris analogues for a datagram server: dribbled 1-byte sends and +//! garbage interleaved mid-handshake hold no state; retransmitted +//! Discovers rotate offers (not idempotent); flood traffic shifts other +//! clients' offers via the shared `last_lease` counter. +//! - B. Lease-hijacking chains (live): front-running an offer then NAKing the +//! victim; replaying a victim Request; Release spoofed with the wrong +//! chaddr (kept) vs the victim chaddr via Decline naming a junk IP; +//! NAK-vs-Ack oracle pool enumeration; Inform spoofing plants no state. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security_edge.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, + hops: 0, + xid, + secs: 0, + broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, + yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, + giaddr: Ipv4Addr::UNSPECIFIED, + chaddr, + options: opts, + } +} + +// Exact replica of examples/server.rs lease state (see test_examples_extra). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, +} + +impl ExampleLike { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +fn discover(xid: u32, chaddr: [u8; 6]) -> Packet { + test_packet( + xid, + chaddr, + vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + ) +} + +fn request_selecting( + xid: u32, + chaddr: [u8; 6], + server_ip: Ipv4Addr, + wanted: Ipv4Addr, +) -> Packet { + test_packet( + xid, + chaddr, + vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(wanted), + ], + ) +} + +// =========================================================================== +// A. Slowloris analogues: dribble / interleaved noise / rotation / flood +// =========================================================================== + +/// Slowloris adaptation: 100 one-byte dribbles arrive as 100 independent +/// datagrams, each rejected without panic and without retaining state — +/// there is nothing half-open to hold. The next valid Discover is answered +/// normally. Locked: dribble is pure noise here, unlike TCP slowloris. +#[test] +fn sec6_dribble_then_valid_exchange_succeeds() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + for _ in 0..100 { + client.send_to(&[0x99], srv_addr).unwrap(); + } + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let rep = send_recv(&client, &srv_addr, &discover(0xD101, [0x11; 6]), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); +} + +/// Garbage interleaved mid-handshake (between victim Discover and Request) +/// changes nothing: decoding is per-datagram and stateless, so the victim +/// flow completes with the offered address. Locked live. +#[test] +fn sec6_garbage_interleaved_handshake_completes() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x12; 6]; + let offer = send_recv(&client, &srv_addr, &discover(0xD110, victim), &mut buf, &mut rbuf); + assert_eq!(offer.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + // noise burst between the handshake steps: short, bad cookie, zeros + client.send_to(&[], srv_addr).unwrap(); + client.send_to(&[0u8; 10], srv_addr).unwrap(); + client.send_to(&[0u8; 235], srv_addr).unwrap(); + let mut bad_cookie = vec![0u8; 236]; + bad_cookie[0] = 1; + bad_cookie[1] = 1; + bad_cookie[2] = 6; + bad_cookie.extend_from_slice(&[9, 9, 9, 9, 53, 1, 1, 255]); + client.send_to(&bad_cookie, srv_addr).unwrap(); + for _ in 0..20 { + client.send_to(&[0xAB], srv_addr).unwrap(); + } + std::thread::sleep(Duration::from_millis(100)); + // victim completes the handshake with the offered address + let req = Packet { + reply: false, hops: 0, xid: 0xD111, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 3)), + ], + }; + let ack = send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf); + assert_eq!(ack.message_type(), Ok(MessageType::Ack)); + assert_eq!(ack.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); +} + +/// Retransmitted (byte-identical, same xid) Discovers are NOT idempotent: +/// each one advances the shared `last_lease` pointer, so three identical +/// sends yield `.3`, `.4`, `.5`. A retrying client — or anyone replaying a +/// capture — gets a fresh offer per attempt. Locked live. +#[test] +fn sec6_retransmit_same_discover_rotates_offer() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let mut got = Vec::new(); + for _ in 0..3 { + let rep = send_recv( + &client, + &srv_addr, + &discover(0xD120, [0x13; 6]), + &mut buf, + &mut rbuf, + ); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + got.push(rep.yiaddr); + } + assert_eq!( + got, + vec![ + Ipv4Addr::new(192, 168, 2, 3), + Ipv4Addr::new(192, 168, 2, 4), + Ipv4Addr::new(192, 168, 2, 5), + ], + "identical retransmits must rotate, proving no per-client reservation" + ); +} + +/// Flood interference: other clients' Discovers shift this client's offer via +/// the shared `last_lease` counter — offers are predictable and disruptable. +/// Victim `.3`, then 5 attacker Discovers, then victim again gets `.9`. +/// Locked live. +#[test] +fn sec6_flood_shifts_victim_offer_shared_counter() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x14; 6]; + let first = send_recv(&client, &srv_addr, &discover(0xD130, victim), &mut buf, &mut rbuf); + assert_eq!(first.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + for i in 0..5u32 { + let ch = [0xE0 + i as u8, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE]; + let rep = send_recv(&client, &srv_addr, &discover(0xD140 + i, ch), &mut buf, &mut rbuf); + assert_eq!(rep.message_type(), Ok(MessageType::Offer)); + } + let second = send_recv(&client, &srv_addr, &discover(0xD150, victim), &mut buf, &mut rbuf); + assert_eq!( + second.yiaddr, + Ipv4Addr::new(192, 168, 2, 9), + "shared counter advanced by flood: .3 -> .9" + ); +} + +// =========================================================================== +// B. Lease-hijacking chains (live): front-run, replay, spoofed free, oracle +// =========================================================================== + +/// Front-running (the core hijack): victim is Offered `.3` but hasn't +/// Requested yet; the attacker Requests `.3` first and gets the Ack; the +/// victim's follow-up Request for `.3` gets NAK, and a re-Discover offers +/// `.4` — proving the victim never held `.3`. Locked live. +#[test] +fn sec6_hijack_frontrun_offer_then_victim_naks_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x61; 6]; + let attacker = [0x62; 6]; + // victim offered .3 (not yet committed — Discover inserts nothing) + let offer = send_recv(&client, &srv_addr, &discover(0xF001, victim), &mut buf, &mut rbuf); + assert_eq!(offer.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + // attacker front-runs the handshake for .3 + let steal = send_recv( + &client, + &srv_addr, + &request_selecting(0xF002, attacker, server_ip, Ipv4Addr::new(192, 168, 2, 3)), + &mut buf, + &mut rbuf, + ); + assert_eq!(steal.message_type(), Ok(MessageType::Ack)); + assert_eq!(steal.yiaddr, Ipv4Addr::new(192, 168, 2, 3)); + // victim's late Request for the same IP is NAK'd ... + let late = send_recv( + &client, + &srv_addr, + &request_selecting(0xF003, victim, server_ip, Ipv4Addr::new(192, 168, 2, 3)), + &mut buf, + &mut rbuf, + ); + assert_eq!(late.message_type(), Ok(MessageType::Nak)); + // ... and the victim holds nothing: re-Discover offers the next free IP + let again = send_recv(&client, &srv_addr, &discover(0xF004, victim), &mut buf, &mut rbuf); + assert_eq!(again.message_type(), Ok(MessageType::Offer)); + assert_eq!(again.yiaddr, Ipv4Addr::new(192, 168, 2, 4)); +} + +/// Replay safety (positive): replaying a victim Request byte-for-byte yields +/// the same Ack twice with no duplicate state — idempotent, no double +/// insert, victim still holds the lease afterwards. Locked live. +#[test] +fn sec6_hijack_replay_identical_request_double_ack_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x63; 6]; + let req = request_selecting(0xF010, victim, server_ip, Ipv4Addr::new(192, 168, 2, 60)); + let first = send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf); + assert_eq!(first.message_type(), Ok(MessageType::Ack)); + // exact replay (same xid too): same Ack, no state corruption + let second = send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf); + assert_eq!(second.message_type(), Ok(MessageType::Ack)); + assert_eq!(second.yiaddr, Ipv4Addr::new(192, 168, 2, 60)); + assert_eq!(second.xid, 0xF010); + // victim still holds .60 afterwards + let still = send_recv(&client, &srv_addr, &discover(0xF011, victim), &mut buf, &mut rbuf); + assert_eq!(still.yiaddr, Ipv4Addr::new(192, 168, 2, 60)); +} + +/// Release spoofed with the *wrong* chaddr frees nothing: victim holds `.61`, +/// attacker Releases with its own chaddr, victim still holds `.61`. +/// Complements the missing/wrong-ID gate tests with the chaddr dimension. +#[test] +fn sec6_hijack_release_spoof_wrong_chaddr_keeps_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x64; 6]; + let attacker = [0x65; 6]; + let req = request_selecting(0xF020, victim, server_ip, Ipv4Addr::new(192, 168, 2, 61)); + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // attacker Releases naming itself (correct server ID, wrong chaddr) + let rel = Packet { + reply: false, hops: 0, xid: 0xF021, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: attacker, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(server_ip), + ], + }; + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + // victim still holds .61 + let still = send_recv(&client, &srv_addr, &discover(0xF022, victim), &mut buf, &mut rbuf); + assert_eq!(still.yiaddr, Ipv4Addr::new(192, 168, 2, 61)); +} + +/// NAK-vs-Ack oracle: a stranger learns pool occupancy remotely — Request +/// for taken `.63` gets NAK, Request for free `.64` gets Ack — with no +/// credentials. Enumeration primitive. Locked live. +#[test] +fn sec6_hijack_nak_ack_oracle_enumerates_pool_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x66; 6]; + let stranger = [0x67; 6]; + let req = request_selecting(0xF030, victim, server_ip, Ipv4Addr::new(192, 168, 2, 63)); + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // probe taken -> Nak + let taken = send_recv( + &client, + &srv_addr, + &request_selecting(0xF031, stranger, server_ip, Ipv4Addr::new(192, 168, 2, 63)), + &mut buf, + &mut rbuf, + ); + assert_eq!(taken.message_type(), Ok(MessageType::Nak)); + // probe free -> Ack (and it really got inserted: discover shows it) + let free = send_recv( + &client, + &srv_addr, + &request_selecting(0xF032, stranger, server_ip, Ipv4Addr::new(192, 168, 2, 64)), + &mut buf, + &mut rbuf, + ); + assert_eq!(free.message_type(), Ok(MessageType::Ack)); + assert_eq!(free.yiaddr, Ipv4Addr::new(192, 168, 2, 64)); + // cross-check locally that distinct holders own distinct IPs + let mut seen = HashSet::new(); + seen.insert(free.yiaddr); + assert_eq!(seen.len(), 1); +} diff --git a/tests/test_security_wire.rs b/tests/test_security_wire.rs new file mode 100644 index 0000000..30ed8b8 --- /dev/null +++ b/tests/test_security_wire.rs @@ -0,0 +1,572 @@ +//! DHCP security wire + gate composition, part 4. +//! +//! Covers security-relevant behavior NOT locked by `test_security.rs`, +//! `test_security_extra.rs`, `test_security_edge.rs`, or the RFC suites: +//! +//! - malformed Server ID on a Release is stripped by the decoder, so the +//! `for_this_server` gate fails closed and the lease survives (live) +//! - wrong (but well-formed) Server ID on Release likewise keeps the lease +//! - malformed Requested IP falls back to `ciaddr` per the example match +//! - framing is purely length-delimited: fake cookies, fake option headers +//! and END bytes inside option data never resync parsing; END bytes in +//! `chaddr` don't confuse framing either +//! - oversized inputs stay bounded (4096-byte datagram decodes, re-encodes +//! capped); empty PRL still yields the required defaults live +//! - critical-address Requests (server IP, broadcast, 0.0.0.0) NAK cleanly +//! with no table insert; max `secs`/`hops` decode exactly +//! +//! `*_quirk` tests lock currently-unsafe behavior for review, not approval. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::collections::HashMap; +use std::net::{Ipv4Addr, UdpSocket}; +use std::ops::Add; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// helpers (same patterns as test_security.rs) +// --------------------------------------------------------------------------- + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +#[allow(clippy::too_many_arguments)] +fn make_raw( + op: u8, + hlen: u8, + hops: u8, + xid: u32, + secs: u16, + flags: [u8; 2], + ci: [u8; 4], + yi: [u8; 4], + si: [u8; 4], + gi: [u8; 4], + chaddr: [u8; 6], + opts_after_cookie: Vec, +) -> Vec { + let mut v = vec![0u8; 236]; + v[0] = op; + v[1] = 1; + v[2] = hlen; + v[3] = hops; + v[4..8].copy_from_slice(&xid.to_be_bytes()); + v[8..10].copy_from_slice(&secs.to_be_bytes()); + v[10] = flags[0]; + v[11] = flags[1]; + v[12..16].copy_from_slice(&ci); + v[16..20].copy_from_slice(&yi); + v[20..24].copy_from_slice(&si); + v[24..28].copy_from_slice(&gi); + v[28..34].copy_from_slice(&chaddr); + v.extend_from_slice(&[99, 130, 83, 99]); + v.extend_from_slice(&opts_after_cookie); + v +} + +// Replica of examples/server.rs lease state (bodies identical; only the +// methods needed here are included). +const IP_START: [u8; 4] = [192, 168, 2, 2]; +const IP_START_NUM: u32 = u32::from_be_bytes(IP_START); +const LEASE_NUM: u32 = 252; +const LEASE_DURATION_SECS: u32 = 86400; + +struct Replica { + leases: HashMap)>, + #[allow(dead_code)] + last_lease: u32, + lease_duration: Duration, +} + +impl Replica { + fn new() -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } + fn request( + &mut self, + chaddr: &[u8; 6], + requested: Option, + ciaddr: Ipv4Addr, + _for_this: bool, + ) -> Result { + let req_ip = requested.unwrap_or(ciaddr); + if let Some(ip) = self.current_lease(chaddr) { + return Ok(ip); + } + if !self.available(chaddr, &req_ip) { + return Err("Requested IP not available"); + } + self.leases.insert( + req_ip, + (chaddr.to_owned(), Some(Instant::now().add(self.lease_duration))), + ); + Ok(req_ip) + } +} + +// Example-like handler: Discover->Offer, Request->Ack, Release|Decline gated, +// everything else ignored. Mirrors examples/server.rs match arms. +struct ExampleLike { + leases: HashMap)>, + last_lease: u32, + lease_duration: Duration, + #[allow(dead_code)] + server_ip: Ipv4Addr, +} + +impl ExampleLike { + fn new(server_ip: Ipv4Addr) -> Self { + Self { + leases: HashMap::new(), + last_lease: 0, + lease_duration: Duration::new(LEASE_DURATION_SECS as u64, 0), + server_ip, + } + } + fn available(&self, chaddr: &[u8; 6], addr: &Ipv4Addr) -> bool { + let pos: u32 = (*addr).into(); + pos >= IP_START_NUM + && pos < IP_START_NUM + LEASE_NUM + && match self.leases.get(addr) { + Some((mac, expiry)) => { + *mac == *chaddr || expiry.map_or(true, |exp| Instant::now().gt(&exp)) + } + None => true, + } + } + fn current_lease(&self, chaddr: &[u8; 6]) -> Option { + for (i, v) in &self.leases { + if v.0 == *chaddr { + return Some(*i); + } + } + None + } +} + +impl server::Handler for ExampleLike { + fn handle_request(&mut self, server: &server::Server, in_packet: Packet) { + match in_packet.message_type() { + Ok(MessageType::Discover) => { + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + for _ in 0..LEASE_NUM { + self.last_lease = (self.last_lease + 1) % LEASE_NUM; + let cand: Ipv4Addr = (IP_START_NUM + self.last_lease).into(); + if self.available(&in_packet.chaddr, &cand) { + let _ = server.reply( + MessageType::Offer, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + cand, + in_packet, + ); + break; + } + } + } + Ok(MessageType::Request) => { + // NOTE: mirrors the example — the for_this_server gate is + // commented out upstream, so Requests are honored regardless. + let req_ip = match in_packet.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => in_packet.ciaddr, + }; + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + ip, + in_packet, + ); + return; + } + if !self.available(&in_packet.chaddr, &req_ip) { + let _ = server.reply( + MessageType::Nak, + vec![DhcpOption::Message("Requested IP not available".to_string())], + Ipv4Addr::UNSPECIFIED, + in_packet, + ); + return; + } + self.leases.insert( + req_ip, + ( + in_packet.chaddr, + Some(Instant::now().add(self.lease_duration)), + ), + ); + let _ = server.reply( + MessageType::Ack, + vec![DhcpOption::IpAddressLeaseTime(LEASE_DURATION_SECS)], + req_ip, + in_packet, + ); + } + Ok(MessageType::Release) | Ok(MessageType::Decline) => { + if !server.for_this_server(&in_packet) { + return; + } + if let Some(ip) = self.current_lease(&in_packet.chaddr) { + self.leases.remove(&ip); + } + } + _ => {} + } + } +} + +fn serve_example_like() -> (std::net::SocketAddr, Ipv4Addr) { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + server_ip, + Ipv4Addr::new(192, 168, 2, 255), + ExampleLike::new(server_ip), + ); + }); + std::thread::sleep(Duration::from_millis(100)); + (srv_addr, server_ip) +} + +fn send_recv( + client: &UdpSocket, + srv_addr: &std::net::SocketAddr, + p: &Packet, + buf: &mut [u8; 1500], + rbuf: &mut [u8; 1500], +) -> Packet { + client.send_to(&p.encode(buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(rbuf).expect("expected reply"); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +// =========================================================================== +// A. Malformed vs wrong Server ID on Release: both fail closed (live) +// =========================================================================== + +/// A Release whose Server Identifier is truncated on the wire (`len` 3) +/// decodes to *no* Server ID at all, so `for_this_server` is false and the +/// lease survives. Wire-levelzott malformation fails closed for Release. +/// Locked live. +#[test] +fn sec3_truncated_server_id_release_keeps_lease_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x31; 6]; + // occupy .61 with a well-formed Request + let req = Packet { + reply: false, hops: 0, xid: 0xF001, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 61)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // Release with truncated Server ID [54,3,10,20,30]: stripped on decode + let raw = make_raw( + 1, 6, 0, 0xF002, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + victim, vec![53, 1, 7, 54, 3, 10, 20, 30, 255], + ); + // sanity: decoder really drops the identifier (fail-closed precondition) + let decoded = unwrap_packet(Packet::from(&raw)); + assert!(decoded.option(SERVER_IDENTIFIER).is_none()); + client.send_to(&raw, srv_addr).unwrap(); + // lease kept: Discover still offers .61 + let disc = Packet { + reply: false, hops: 0, xid: 0xF003, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let offer = send_recv(&client, &srv_addr, &disc, &mut buf, &mut rbuf); + assert_eq!(offer.message_type(), Ok(MessageType::Offer)); + assert_eq!(offer.yiaddr, Ipv4Addr::new(192, 168, 2, 61), "lease kept"); +} + +/// A well-formed Release naming the WRONG server is likewise ignored live — +/// the lease survives and Discover still offers it. Complements the +/// missing-ID case with a wrong-value case. +#[test] +fn sec3_wrong_server_id_release_keeps_lease_live() { + let (srv_addr, _) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let victim = [0x32; 6]; + let req = Packet { + reply: false, hops: 0, xid: 0xF010, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 2, 1)), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 62)), + ], + }; + assert_eq!( + send_recv(&client, &srv_addr, &req, &mut buf, &mut rbuf).message_type(), + Ok(MessageType::Ack) + ); + // Release naming a different server: ignored + let rel = Packet { + reply: false, hops: 0, xid: 0xF011, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Release), + DhcpOption::ServerIdentifier(Ipv4Addr::new(10, 99, 99, 99)), + ], + }; + client.send_to(&rel.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let disc = Packet { + reply: false, hops: 0, xid: 0xF012, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: victim, + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let offer = send_recv(&client, &srv_addr, &disc, &mut buf, &mut rbuf); + assert_eq!(offer.yiaddr, Ipv4Addr::new(192, 168, 2, 62), "lease kept"); +} + +// =========================================================================== +// B. Malformed Requested IP falls back to ciaddr (no crash, no bypass) +// =========================================================================== + +/// A Requested-IP option truncated to 3 bytes is stripped by the decoder, so +/// the example `match option(50) / _ => ciaddr` falls back to `ciaddr`. +/// The fallback address (.71) is Acked and inserted — never the truncated +/// bytes, never a panic. Locked. +#[test] +fn sec3_malformed_requested_ip_falls_back_to_ciaddr() { + let raw = make_raw( + 1, 6, 0, 0xF020, 0, [0, 0], + [192, 168, 2, 71], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0x33; 6], + vec![53, 1, 3, 50, 3, 10, 0, 0, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.message_type(), Ok(MessageType::Request)); + assert!(p.option(REQUESTED_IP_ADDRESS).is_none(), "truncated option stripped"); + // byte-for-byte example selection: RequestedIp else ciaddr + let req_ip = match p.option(REQUESTED_IP_ADDRESS) { + Some(DhcpOption::RequestedIpAddress(x)) => *x, + _ => p.ciaddr, + }; + assert_eq!(req_ip, Ipv4Addr::new(192, 168, 2, 71), "falls back to ciaddr"); + let mut s = Replica::new(); + assert_eq!( + s.request(&[0x33; 6], p.option(REQUESTED_IP_ADDRESS).and_then(|o| match o { + DhcpOption::RequestedIpAddress(x) => Some(*x), + _ => None, + }), p.ciaddr, true), + Ok(Ipv4Addr::new(192, 168, 2, 71)) + ); + assert!(s.leases.contains_key(&Ipv4Addr::new(192, 168, 2, 71))); +} + +// =========================================================================== +// C. Framing is purely length-delimited (no sentinel scanning) +// =========================================================================== + +/// Fake magic cookie, fake option header and END *byte values* inside option +/// data never resync parsing: decode consumes exactly `len` bytes, so the +/// real Offer option after them still decodes and `message_type()` finds it. +#[test] +fn sec3_embedded_cookie_and_end_bytes_ignored_in_data() { + let mut opts = vec![200, 6, 99, 130, 83, 99, 255, 53]; // data: cookie+END+53 + opts.extend_from_slice(&[53, 1, 2, 255]); // real Offer + let raw = make_raw( + 1, 6, 0, 0xF030, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0x34; 6], opts, + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 2); + match &p.options[0] { + DhcpOption::Unrecognized(r) => { + assert_eq!(r.code, 200); + assert_eq!(r.data, vec![99, 130, 83, 99, 255, 53]); + } + _ => panic!("first option must be the Unrecognized carrier"), + } + assert_eq!(p.message_type(), Ok(MessageType::Offer)); +} + +/// END bytes (255) in `chaddr` don't confuse framing: the 6-byte field is +/// positional, so `chaddr == [255; 6]` round-trips exactly with options intact. +#[test] +fn sec3_end_bytes_in_chaddr_no_confusion() { + let raw = make_raw( + 1, 6, 0, 0xF031, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [255; 6], + vec![53, 1, 1, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.chaddr, [255; 6]); + assert_eq!(p.message_type(), Ok(MessageType::Discover)); +} + +// =========================================================================== +// D. Oversized inputs stay bounded; empty PRL still yields defaults +// =========================================================================== + +/// A 4096-byte datagram (past the 1500 MTU buffers) decodes with the option +/// Vec bounded by the input (~1285 entries, no amplification) and re-encodes +/// capped at 300 with Discover first. No panic at either size. +#[test] +fn sec3_4096_byte_datagram_decodes_bounded() { + let mut opts = vec![53, 1, 1]; + for _ in 0..1284 { + opts.extend_from_slice(&[200, 1, 7]); + } + opts.push(255); + let raw = make_raw( + 1, 6, 0, 0xF040, 0, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0x35; 6], opts, + ); + assert_eq!(raw.len(), 4096); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.options.len(), 1285, "1 Discover + 1284 fillers"); + let mut buf = [0u8; 1500]; + let enc = p.encode(&mut buf).to_vec(); + assert!(enc.len() <= 300, "re-encode capped"); + let q = unwrap_packet(Packet::from(&enc)); + assert_eq!(q.message_type(), Ok(MessageType::Discover)); + assert_eq!(q.options.len(), 19, "Discover + 18 fillers"); +} + +/// Empty PRL (`len` 0) is valid and live-filters to the required defaults: +/// a Request asking for nothing extra still gets `[53,54,51]`. An attacker +/// cannot use an empty list to strip Server ID / lease time. Locked live. +#[test] +fn sec3_empty_prl_returns_defaults_live() { + let (srv_addr, server_ip) = serve_example_like(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, hops: 0, xid: 0xF041, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, + siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, + chaddr: [0x36; 6], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 2, 72)), + DhcpOption::ParameterRequestList(vec![]), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = client.recv_from(&mut rbuf).expect("Ack"); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type(), Ok(MessageType::Ack)); + assert_eq!( + rep.options.iter().map(|o| o.code()).collect::>(), + vec![53, 54, 51], + "empty PRL keeps required + defaults" + ); +} + +// =========================================================================== +// E. Critical-address Requests NAK cleanly; secs/hops extremes preserved +// =========================================================================== + +/// Requests for the server's own IP, the subnet broadcast, and explicit +/// 0.0.0.0 all NAK with no table insert — critical identities can never be +/// handed out, and 0.0.0.0 never enters the map. Locked. +#[test] +fn sec3_critical_and_unspecified_requests_nak_without_insert() { + let mut s = Replica::new(); + for ip in [ + Ipv4Addr::new(192, 168, 2, 1), // server identity + Ipv4Addr::new(192, 168, 2, 255), // subnet broadcast + Ipv4Addr::new(0, 0, 0, 0), // unspecified + ] { + assert!( + s.request(&[0x37; 6], Some(ip), Ipv4Addr::UNSPECIFIED, true).is_err(), + "must NAK {}", + ip + ); + } + assert!(s.leases.is_empty(), "nothing critical/unspecified may be leased"); +} + +/// Max `secs` (0xFFFF) and `hops` 255 decode exactly — no clamping that could +/// desync retransmission/loop accounting between peers. Locked. +#[test] +fn sec3_secs_hops_max_preserved_on_decode() { + let raw = make_raw( + 1, 6, 255, 0xF050, 0xFFFF, [0, 0], + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], + [0x38; 6], + vec![53, 1, 1, 255], + ); + let p = unwrap_packet(Packet::from(&raw)); + assert_eq!(p.secs, 0xFFFF); + assert_eq!(p.hops, 255); +} diff --git a/tests/test_server.rs b/tests/test_server.rs new file mode 100644 index 0000000..b72e146 --- /dev/null +++ b/tests/test_server.rs @@ -0,0 +1,728 @@ +//! Exact-functionality regression tests for `dhcp4r::server`. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::sync::mpsc; +use std::time::Duration; + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok Packet"), + } +} + +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr, options: opts, + } +} + +fn codes(opts: &[DhcpOption]) -> Vec { + opts.iter().map(|o| o.code()).collect() +} + +// --------------------------------------------------------------------------- +// filter_options_by_req: pure function, exact ordering/truncation locked +// --------------------------------------------------------------------------- + +#[test] +fn filter_empty_opts_stays_empty() { + let mut opts: Vec = vec![]; + server::filter_options_by_req(&mut opts, &[1, 3, 6]); + assert!(opts.is_empty()); + let mut opts: Vec = vec![]; + server::filter_options_by_req(&mut opts, &[]); + assert!(opts.is_empty()); +} + +#[test] +fn filter_no_req_keeps_only_default_set_in_default_order() { + // h = [53,54,1,51,6,3] + let mut opts = vec![ + DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1)]), // 3 + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), // 1 + DhcpOption::Message("hi".to_string()), // 56 not in h + DhcpOption::DhcpMessageType(MessageType::Offer), // 53 + ]; + server::filter_options_by_req(&mut opts, &[]); + // 56 dropped; rest ordered by h: 53,1,3 (54,51,6 absent) + assert_eq!(codes(&opts), vec![53, 1, 3]); +} + +#[test] +fn filter_req_order_comes_first_then_defaults() { + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), // 1 + DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1)]), // 3 + DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]), // 6 + DhcpOption::IpAddressLeaseTime(86400), // 51 + DhcpOption::DhcpMessageType(MessageType::Offer), // 53 + DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)), // 54 + ]; + server::filter_options_by_req(&mut opts, &[6, 1]); + // req [6,1] first, then remaining h in h-order [53,54,51,3] + assert_eq!(codes(&opts), vec![6, 1, 53, 54, 51, 3]); +} + +#[test] +fn filter_drops_options_not_in_req_or_defaults() { + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::Message("extra".to_string()), // 56 never kept + DhcpOption::HostName("h".to_string()), // 12 never kept + DhcpOption::ParameterRequestList(vec![1]), // 55 never kept (not in h) + ]; + server::filter_options_by_req(&mut opts, &[]); + assert_eq!(codes(&opts), vec![1]); + + let mut opts = vec![DhcpOption::Message("hi".to_string())]; + server::filter_options_by_req(&mut opts, &[1]); + assert!(opts.is_empty(), "nothing matched -> truncated to 0"); +} + +#[test] +fn filter_unknown_req_codes_ignored() { + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::DhcpMessageType(MessageType::Offer), + ]; + server::filter_options_by_req(&mut opts, &[200, 201]); + // unknown req adds nothing; defaults still apply: [53,1] + // (order: req finds nothing, h finds 53 then 1) + assert_eq!(codes(&opts), vec![53, 1]); +} + +#[test] +fn filter_duplicate_req_does_not_duplicate() { + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::DhcpMessageType(MessageType::Offer), + ]; + server::filter_options_by_req(&mut opts, &[1, 1, 53, 53]); + assert_eq!(codes(&opts), vec![1, 53]); + assert_eq!(opts.len(), 2); +} + +#[test] +fn filter_does_not_invent_missing_options() { + let mut opts = vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))]; + server::filter_options_by_req(&mut opts, &[1, 3, 6, 53, 54, 51]); + // only 1 present + assert_eq!(codes(&opts), vec![1]); +} + +// --------------------------------------------------------------------------- +// integration helpers: real UDP loopback, Server via serve thread +// --------------------------------------------------------------------------- + +fn recv_with_timeout(sock: &UdpSocket, buf: &mut [u8]) -> (usize, std::net::SocketAddr) { + sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + sock.recv_from(buf).expect("timed out waiting for reply") +} + +#[test] +fn for_this_server_true_only_on_matching_id() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 20, 30, 40); + let (tx, rx) = mpsc::channel::>(); + + struct H { + tx: mpsc::Sender>, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + // For xid 4 we also check a manually-built packet holding + // Unrecognized(54): wire code 54 ALWAYS decodes as ServerIdentifier, + // so only a manual (non-decoded) Unrecognized hits the `_ => false` branch. + if p.xid == 4 { + let wire = s.for_this_server(&p); + let manual = Packet { + reply: false, hops: 0, xid: 4, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), + yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), + giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [0; 6], + options: vec![DhcpOption::Unrecognized(RawDhcpOption { + code: 54, + data: Ipv4Addr::new(10, 20, 30, 40).octets().to_vec(), + })], + }; + let manual_res = s.for_this_server(&manual); + let _ = self.tx.send(vec![wire, manual_res]); + } else { + let _ = self.tx.send(vec![s.for_this_server(&p)]); + } + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 20, 30, 255), H { tx }); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + + // matching + let p = test_packet(1, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + ]); + let enc = p.encode(&mut buf).to_vec(); + client.send_to(&enc, srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), vec![true]); + + // different IP + let p = test_packet(2, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(Ipv4Addr::new(1, 1, 1, 1)), + ]); + let enc = p.encode(&mut buf).to_vec(); + client.send_to(&enc, srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), vec![false]); + + // missing option + let p = test_packet(3, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + ]); + let enc = p.encode(&mut buf).to_vec(); + client.send_to(&enc, srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), vec![false]); + + // Unrecognized with code 54 on the WIRE decodes as ServerIdentifier, + // so for_this_server is true (quirk locked). A manual Unrecognized(54) + // that never went through decode hits `_ => false`. + let p = test_packet(4, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::Unrecognized(RawDhcpOption { code: 54, data: server_ip.octets().to_vec() }), + ]); + let enc = p.encode(&mut buf).to_vec(); + client.send_to(&enc, srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), vec![true, false]); +} + +#[test] +fn reply_preserves_xid_chaddr_giaddr_and_sets_fixed_fields() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(192, 168, 2, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + // use only decodable options so client can verify all survive + let _ = s.reply( + MessageType::Offer, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(86400), + ], + Ipv4Addr::new(192, 168, 2, 100), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(192, 168, 2, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + + let req = Packet { + reply: false, hops: 5, xid: 0xAABBCCDD, secs: 77, broadcast: false, + ciaddr: Ipv4Addr::new(192, 168, 2, 50), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(192, 168, 2, 254), + chaddr: [9, 8, 7, 6, 5, 4], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + let enc = req.encode(&mut buf).to_vec(); + client.send_to(&enc, srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + + assert!(rep.reply, "reply flag must be true"); + assert_eq!(rep.hops, 0, "reply hops forced to 0"); + assert_eq!(rep.xid, 0xAABBCCDD, "xid echoed"); + assert_eq!(rep.secs, 0, "reply secs forced to 0"); + assert_eq!(rep.chaddr, [9, 8, 7, 6, 5, 4]); + assert_eq!(rep.giaddr, Ipv4Addr::new(192, 168, 2, 254), "giaddr echoed"); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 2, 100)); + assert_eq!(rep.siaddr, Ipv4Addr::new(0, 0, 0, 0), "siaddr forced to 0.0.0.0"); + assert_eq!(rep.ciaddr, Ipv4Addr::new(192, 168, 2, 50), "ciaddr echoed for non-Nak"); + assert_eq!(rep.message_type().unwrap(), MessageType::Offer); + // auto-added MsgType + ServerId first, then additional in order + assert_eq!(codes(&rep.options), vec![53, 54, 1, 51]); + assert_eq!(rep.options[1], DhcpOption::ServerIdentifier(server_ip)); +} + +#[test] +fn reply_nak_zeroes_ciaddr_and_yiaddr_is_offer_ip() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Nak, + vec![DhcpOption::Message("no".to_string())], + Ipv4Addr::new(0, 0, 0, 0), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(10, 0, 0, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, hops: 0, xid: 0x2222, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(10, 0, 0, 99), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [1, 1, 1, 1, 1, 1], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.message_type().unwrap(), MessageType::Nak); + assert_eq!(rep.ciaddr, Ipv4Addr::new(0, 0, 0, 0), "Nak forces ciaddr 0"); + assert_eq!(rep.yiaddr, Ipv4Addr::new(0, 0, 0, 0)); +} + +#[test] +fn reply_filters_by_prl_shows_exact_order() { + // PRL [6,1] with additional [Subnet, Lease] plus auto [Msg,Server]: + // filter keeps req order then h-order. Raw bytes must show + // DNS? No DNS in additional here, so result is [1,53,54,51] filtered? + // Use additional with Subnet+Lease+Message: req [1] -> [1,53,54,51], Message dropped. + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Ack, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::IpAddressLeaseTime(3600), + DhcpOption::Message("should be dropped when PRL present".to_string()), + ], + Ipv4Addr::new(10, 0, 0, 50), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + + // Request asks only for Subnet(1). Message(56) not in req nor h -> dropped. + let req = Packet { + reply: false, hops: 0, xid: 0x9999, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [5, 5, 5, 5, 5, 5], + options: vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::ParameterRequestList(vec![1]), + ], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + // req [1] first, then h remainder [53,54,51] (6,3 absent). Message dropped. + assert_eq!(codes(&rep.options), vec![1, 53, 54, 51]); +} + +#[test] +fn reply_without_prl_keeps_all_additional_in_order() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + // NOTE: Message must be short to fit the packet. + // 240 + 3(Msg) + 6(Server) + 6(Subnet) + (2+len) + 1(END) < 300 + // => len < 43. "hi" fits; longer strings trigger truncation. + let _ = s.reply( + MessageType::Ack, + vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(255, 0, 0, 0)), + DhcpOption::Message("hi".to_string()), + ], + Ipv4Addr::new(10, 0, 0, 60), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = test_packet(0x7777, [6, 6, 6, 6, 6, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + ]); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(codes(&rep.options), vec![53, 54, 1, 56]); +} + +#[test] +fn send_unicast_goes_to_request_source() { + // broadcast=false + real src IP -> reply sent to src (client receives). + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))], + Ipv4Addr::new(192, 168, 1, 10), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 1, 1), + Ipv4Addr::new(192, 168, 1, 255), + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = test_packet(0x5555, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, src) = recv_with_timeout(&client, &mut rbuf); + assert!(n > 240); + // src must be the server socket (reply came from server) + assert_eq!(src.ip(), srv_addr.ip()); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 1, 10)); +} + +#[test] +fn send_broadcast_flag_routes_to_broadcast_ip() { + // To set broadcast=true on the server side, the REQUEST must decode + // with broadcast=true, which requires low-byte 0x80 ([0,128]) due to + // the `flags & 128` quirk. [128,0] (correct RFC 0x8000) decodes false. + // Server is configured with broadcast_ip=127.0.0.1 so the reply still + // reaches this test client (same port, loopback IP). + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + assert!(in_packet.broadcast, "test precondition: req must decode broadcast=true"); + let _ = s.reply( + MessageType::Offer, + vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))], + Ipv4Addr::new(192, 168, 1, 11), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(192, 168, 1, 1), + Ipv4Addr::new(127, 0, 0, 1), // loopback so test can receive + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut rbuf = [0u8; 1500]; + + // hand-crafted raw with flags [0,128] -> decodes broadcast=true + let mut raw = vec![0u8; 236]; + raw[0] = 1; raw[1] = 1; raw[2] = 6; raw[3] = 0; + raw[4..8].copy_from_slice(&0xBEEFu32.to_be_bytes()); + raw[10] = 0; raw[11] = 128; + raw[28..34].copy_from_slice(&[7, 7, 7, 7, 7, 7]); + raw.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]); + client.send_to(&raw, srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 1, 11)); +} + +#[test] +fn serve_ignores_malformed_and_handles_valid() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let (tx, rx) = mpsc::channel::(); + + struct H { + tx: mpsc::Sender, + } + impl server::Handler for H { + fn handle_request(&mut self, _s: &server::Server, p: Packet) { + let _ = self.tx.send(p.xid); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve( + srv_sock, + Ipv4Addr::new(1, 1, 1, 1), + Ipv4Addr::new(1, 1, 1, 255), + H { tx }, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + + // malformed: bad cookie -> must be ignored (no handler call) + let mut bad = vec![0u8; 236]; + bad[0] = 1; bad[1] = 1; bad[2] = 6; + bad.extend_from_slice(&[0, 0, 0, 0, 53, 1, 1, 255]); + client.send_to(&bad, srv_addr).unwrap(); + assert!(rx.recv_timeout(Duration::from_millis(300)).is_err(), "bad packet must be ignored"); + + // truncated header -> ignored + client.send_to(&[1u8; 10], srv_addr).unwrap(); + assert!(rx.recv_timeout(Duration::from_millis(300)).is_err(), "short packet must be ignored"); + + // valid -> handled + let p = test_packet(0xABCD, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), 0xABCD); +} + +#[test] +fn serve_with_broadcasts_fans_out_to_each_address() { + // Broadcast-routed replies go to EVERY listed address (unicast still + // goes once to the peer). Two loopback clients on 127.0.0.1 and + // 127.0.0.2 must each receive the same Offer. + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + assert!(in_packet.broadcast, "test precondition: req must decode broadcast=true"); + let _ = s.reply( + MessageType::Offer, + vec![DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0))], + Ipv4Addr::new(192, 168, 1, 12), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve_with_broadcasts( + srv_sock, + Ipv4Addr::new(192, 168, 1, 1), + vec![Ipv4Addr::new(127, 0, 0, 1), Ipv4Addr::new(127, 0, 0, 2)], + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client_a = UdpSocket::bind("127.0.0.1:0").unwrap(); + // Same port number on the second loopback IP: the server fans out to + // :, so B must listen on A's port. + let port_a = client_a.local_addr().unwrap().port(); + let client_b = UdpSocket::bind(("127.0.0.2", port_a)).unwrap(); + let mut rbuf = [0u8; 1500]; + + // hand-crafted raw with flags [0,128] -> decodes broadcast=true + let mut raw = vec![0u8; 236]; + raw[0] = 1; raw[1] = 1; raw[2] = 6; raw[3] = 0; + raw[4..8].copy_from_slice(&0xF00Du32.to_be_bytes()); + raw[10] = 0; raw[11] = 128; + raw[28..34].copy_from_slice(&[8, 8, 8, 8, 8, 8]); + raw.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]); + client_a.send_to(&raw, srv_addr).unwrap(); + + let (n_a, _) = recv_with_timeout(&client_a, &mut rbuf); + let rep_a = unwrap_packet(Packet::from(&rbuf[..n_a])); + assert_eq!(rep_a.yiaddr, Ipv4Addr::new(192, 168, 1, 12)); + let (n_b, _) = recv_with_timeout(&client_b, &mut rbuf); + let rep_b = unwrap_packet(Packet::from(&rbuf[..n_b])); + assert_eq!(rep_b.yiaddr, Ipv4Addr::new(192, 168, 1, 12)); + assert_eq!(rep_a.xid, rep_b.xid, "both copies are the same reply"); +} + +#[test] +fn serve_with_broadcasts_empty_list_falls_back_without_panic() { + // Empty list falls back to the limited broadcast address and must not + // panic; the server must stay alive for later unicast requests. (No + // delivery assertion: limited broadcast is not loopback-reliable, and + // the test socket lacks SO_BROADCAST so the send itself errors out.) + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, in_packet: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![], + Ipv4Addr::new(192, 168, 1, 13), + in_packet, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve_with_broadcasts( + srv_sock, + Ipv4Addr::new(192, 168, 1, 1), + vec![], + H, + ); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(300))) + .unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + + // broadcast-flagged Discover: routed to 255.255.255.255, must not panic. + let mut raw = vec![0u8; 236]; + raw[0] = 1; raw[1] = 1; raw[2] = 6; raw[3] = 0; + raw[4..8].copy_from_slice(&0xBEEFu32.to_be_bytes()); + raw[10] = 0; raw[11] = 128; + raw[28..34].copy_from_slice(&[9, 9, 9, 9, 9, 9]); + raw.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]); + client.send_to(&raw, srv_addr).unwrap(); + let _ = client.recv_from(&mut rbuf); // delivered or not; must not hang the server + + // server still alive: plain unicast Discover gets its Offer. + let req = test_packet(0x7777, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv_with_timeout(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.yiaddr, Ipv4Addr::new(192, 168, 1, 13)); +} + +// --------------------------------------------------------------------------- +// route_dests: RFC 2131 s4.1 delivery plan (pure, no sockets) +// --------------------------------------------------------------------------- + +fn peer(ip: Ipv4Addr) -> std::net::SocketAddr { + std::net::SocketAddr::new(std::net::IpAddr::V4(ip), 68) +} + +#[test] +fn route_sourced_peer_unicasts_once() { + // Renewing client with a real source address: unicast back, even with + // yiaddr set and broadcasts configured (no fan-out, no unicast copy). + let dsts = server::Server::route_dests( + peer(Ipv4Addr::new(192, 168, 2, 50)), + false, + Ipv4Addr::new(192, 168, 2, 50), + &[Ipv4Addr::new(192, 168, 2, 255), Ipv4Addr::BROADCAST], + ); + assert_eq!(dsts, vec![peer(Ipv4Addr::new(192, 168, 2, 50))]); +} + +#[test] +fn route_broadcast_flag_fans_out_without_unicast_copy() { + // Flag set means broadcast-only, even with a usable yiaddr: the client + // announced it cannot take unicast. + let dsts = server::Server::route_dests( + peer(Ipv4Addr::UNSPECIFIED), + true, + Ipv4Addr::new(192, 168, 2, 3), + &[Ipv4Addr::new(192, 168, 2, 255), Ipv4Addr::BROADCAST], + ); + assert_eq!( + dsts, + vec![ + std::net::SocketAddr::new( + std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 2, 255)), + 68 + ), + std::net::SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::BROADCAST), 68), + ] + ); +} + +#[test] +fn route_unspecified_no_flag_adds_unicast_yiaddr_copy() { + // The dnsmasq case: flag clear, source 0.0.0.0, offered address present. + // Broadcast copies go out AND a unicast copy to yiaddr:port. + let dsts = server::Server::route_dests( + peer(Ipv4Addr::UNSPECIFIED), + false, + Ipv4Addr::new(192, 168, 2, 71), + &[Ipv4Addr::new(192, 168, 2, 255), Ipv4Addr::BROADCAST], + ); + assert_eq!( + dsts, + vec![ + std::net::SocketAddr::new( + std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 2, 255)), + 68 + ), + std::net::SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::BROADCAST), 68), + std::net::SocketAddr::new( + std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 2, 71)), + 68 + ), + ] + ); +} + +#[test] +fn route_nak_has_no_unicast_target() { + // yiaddr 0.0.0.0 (NAK): broadcast fan-out only, no 0.0.0.0 copy. + let dsts = server::Server::route_dests( + peer(Ipv4Addr::UNSPECIFIED), + false, + Ipv4Addr::UNSPECIFIED, + &[Ipv4Addr::new(192, 168, 2, 255)], + ); + assert_eq!( + dsts, + vec![std::net::SocketAddr::new( + std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 2, 255)), + 68 + )] + ); +} diff --git a/tests/test_server_extra.rs b/tests/test_server_extra.rs new file mode 100644 index 0000000..7115c92 --- /dev/null +++ b/tests/test_server_extra.rs @@ -0,0 +1,423 @@ +//! Extra server coverage: filter value preservation and non-default codes, +//! reply variants beyond Offer/Ack/Nak, duplicate auto-options, port routing, +//! sequential packets and the panic-packet server-kill quirk. + +use dhcp4r::options::*; +use dhcp4r::packet::*; +use dhcp4r::server; +use std::net::{Ipv4Addr, UdpSocket}; +use std::time::Duration; + +fn unwrap_packet(r: Result>) -> Packet { + match r { + Ok(p) => p, + Err(_) => panic!("expected Ok"), + } +} +fn test_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> Packet { + Packet { + reply: false, hops: 0, xid, secs: 0, broadcast: false, + ciaddr: Ipv4Addr::new(0, 0, 0, 0), yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr, options: opts, + } +} +fn codes(o: &[DhcpOption]) -> Vec { + o.iter().map(|x| x.code()).collect() +} +fn recv(sock: &UdpSocket, buf: &mut [u8]) -> (usize, std::net::SocketAddr) { + sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + sock.recv_from(buf).expect("timeout") +} + +// --------------------------------------------------------------------------- +// filter: values and non-default requested codes +// --------------------------------------------------------------------------- + +#[test] +fn filter_requesting_message_keeps_it_first() { + let mut opts = vec![ + DhcpOption::Message("m".to_string()), + DhcpOption::HostName("h".to_string()), + DhcpOption::ParameterRequestList(vec![1]), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::DhcpMessageType(MessageType::Offer), + ]; + server::filter_options_by_req(&mut opts, &[56]); + assert_eq!(codes(&opts), vec![56, 53, 1]); + assert_eq!(opts[0], DhcpOption::Message("m".to_string())); +} + +#[test] +fn filter_requesting_hostname_keeps_it_first() { + let mut opts = vec![ + DhcpOption::Message("m".to_string()), + DhcpOption::HostName("h".to_string()), + DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), + DhcpOption::DhcpMessageType(MessageType::Offer), + ]; + server::filter_options_by_req(&mut opts, &[12]); + assert_eq!(codes(&opts), vec![12, 53, 1]); +} + +#[test] +fn filter_full_default_set_orders_as_h() { + let mut opts = vec![ + DhcpOption::Router(vec![Ipv4Addr::new(1, 1, 1, 1)]), + DhcpOption::DomainNameServer(vec![Ipv4Addr::new(2, 2, 2, 2)]), + DhcpOption::IpAddressLeaseTime(1), + DhcpOption::SubnetMask(Ipv4Addr::new(3, 3, 3, 3)), + DhcpOption::ServerIdentifier(Ipv4Addr::new(4, 4, 4, 4)), + DhcpOption::DhcpMessageType(MessageType::Offer), + ]; + server::filter_options_by_req(&mut opts, &[]); + assert_eq!(codes(&opts), vec![53, 54, 1, 51, 6, 3]); +} + +#[test] +fn filter_duplicate_subnet_keeps_second_value_quirk() { + // Two Subnets: first is swapped away and truncated, second survives. + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(1, 1, 1, 1)), + DhcpOption::SubnetMask(Ipv4Addr::new(2, 2, 2, 2)), + DhcpOption::DhcpMessageType(MessageType::Offer), + ]; + server::filter_options_by_req(&mut opts, &[]); + assert_eq!(codes(&opts), vec![53, 1]); + assert_eq!(opts[1], DhcpOption::SubnetMask(Ipv4Addr::new(2, 2, 2, 2))); +} + +#[test] +fn filter_preserves_values_not_just_codes() { + let mut opts = vec![ + DhcpOption::SubnetMask(Ipv4Addr::new(10, 20, 30, 40)), + DhcpOption::IpAddressLeaseTime(12345), + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::ServerIdentifier(Ipv4Addr::new(9, 9, 9, 9)), + ]; + server::filter_options_by_req(&mut opts, &[]); + assert_eq!( + opts, + vec![ + DhcpOption::DhcpMessageType(MessageType::Ack), + DhcpOption::ServerIdentifier(Ipv4Addr::new(9, 9, 9, 9)), + DhcpOption::SubnetMask(Ipv4Addr::new(10, 20, 30, 40)), + DhcpOption::IpAddressLeaseTime(12345), + ] + ); +} + +// --------------------------------------------------------------------------- +// for_this_server: first ServerIdentifier wins +// --------------------------------------------------------------------------- + +#[test] +fn for_this_server_uses_first_identifier_when_duplicated() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let server_ip = Ipv4Addr::new(10, 0, 0, 1); + let other = Ipv4Addr::new(10, 0, 0, 2); + let (tx, rx) = std::sync::mpsc::channel::(); + struct H { + tx: std::sync::mpsc::Sender, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = self.tx.send(s.for_this_server(&p)); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, server_ip, Ipv4Addr::new(10, 0, 0, 255), H { tx }); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + // first matches -> true even though second differs. Note: two ServerIds + // both survive encode (small packet) and decode preserves order. + let p = test_packet(1, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(server_ip), + DhcpOption::ServerIdentifier(other), + ]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!(rx.recv_timeout(Duration::from_secs(2)).unwrap()); + + // first differs -> false even though second matches. + let p = test_packet(2, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Request), + DhcpOption::ServerIdentifier(other), + DhcpOption::ServerIdentifier(server_ip), + ]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!(!rx.recv_timeout(Duration::from_secs(2)).unwrap()); +} + +// --------------------------------------------------------------------------- +// reply: non-Offer/Ack/Nak types preserve ciaddr; Nak with non-zero offer +// --------------------------------------------------------------------------- + +fn reply_once( + msg: MessageType, + additional: Vec, + offer: Ipv4Addr, + req_xid: u32, + req_ci: Ipv4Addr, +) -> Packet { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H { + msg: MessageType, + add: Option>, + offer: Ipv4Addr, + } + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let add = self.add.take().unwrap(); + let _ = s.reply(self.msg, add, self.offer, p); + } + } + let h = H { msg, add: Some(additional), offer }; + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 255), h); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, hops: 0, xid: req_xid, secs: 0, broadcast: false, + ciaddr: req_ci, yiaddr: Ipv4Addr::new(0, 0, 0, 0), + siaddr: Ipv4Addr::new(0, 0, 0, 0), giaddr: Ipv4Addr::new(0, 0, 0, 0), + chaddr: [1, 1, 1, 1, 1, 1], + options: vec![DhcpOption::DhcpMessageType(MessageType::Request)], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv(&client, &mut rbuf); + unwrap_packet(Packet::from(&rbuf[..n])) +} + +#[test] +fn reply_non_nak_types_preserve_ciaddr() { + for mt in [MessageType::Decline, MessageType::Release, MessageType::Inform, MessageType::Discover, MessageType::Request] { + let rep = reply_once(mt, vec![], Ipv4Addr::new(1, 2, 3, 4), 0x100, Ipv4Addr::new(7, 7, 7, 7)); + assert_eq!(rep.message_type().unwrap(), mt); + assert_eq!(rep.ciaddr, Ipv4Addr::new(7, 7, 7, 7), "ciaddr for {:?}", mt); + assert_eq!(rep.yiaddr, Ipv4Addr::new(1, 2, 3, 4)); + } +} + +#[test] +fn reply_nak_yiaddr_is_offer_ip_not_forced_zero() { + let rep = reply_once(MessageType::Nak, vec![], Ipv4Addr::new(9, 9, 9, 9), 0x101, Ipv4Addr::new(7, 7, 7, 7)); + assert_eq!(rep.ciaddr, Ipv4Addr::new(0, 0, 0, 0)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(9, 9, 9, 9), "Nak only zeroes ciaddr"); +} + +#[test] +fn reply_ignores_incoming_siaddr_yiaddr_hops_secs() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(5, 5, 5, 5), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = Packet { + reply: false, hops: 9, xid: 0x102, secs: 99, broadcast: false, + ciaddr: Ipv4Addr::new(1, 1, 1, 1), yiaddr: Ipv4Addr::new(2, 2, 2, 2), + siaddr: Ipv4Addr::new(3, 3, 3, 3), giaddr: Ipv4Addr::new(4, 4, 4, 4), + chaddr: [2, 2, 2, 2, 2, 2], + options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], + }; + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + assert_eq!(rep.hops, 0); + assert_eq!(rep.secs, 0); + assert_eq!(rep.siaddr, Ipv4Addr::new(0, 0, 0, 0)); + assert_eq!(rep.yiaddr, Ipv4Addr::new(5, 5, 5, 5)); + assert_eq!(rep.giaddr, Ipv4Addr::new(4, 4, 4, 4)); +} + +#[test] +fn reply_with_duplicate_auto_options_keeps_both_when_no_prl() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply( + MessageType::Offer, + vec![ + DhcpOption::DhcpMessageType(MessageType::Inform), + DhcpOption::SubnetMask(Ipv4Addr::new(1, 1, 1, 1)), + ], + Ipv4Addr::new(5, 5, 5, 5), + p, + ); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = test_packet(0x103, [3, 3, 3, 3, 3, 3], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (n, _) = recv(&client, &mut rbuf); + let rep = unwrap_packet(Packet::from(&rbuf[..n])); + // auto Offer + ServerId, then duplicate Inform + Subnet (no PRL => no filter) + assert_eq!(codes(&rep.options), vec![53, 54, 53, 1]); + assert_eq!(rep.message_type().unwrap(), MessageType::Offer, "first MsgType wins"); +} + +// --------------------------------------------------------------------------- +// send: port preserved; sequential packets; panic-packet kills server +// --------------------------------------------------------------------------- + +#[test] +fn send_preserves_client_port() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + struct H; + impl server::Handler for H { + fn handle_request(&mut self, s: &server::Server, p: Packet) { + let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(1, 1, 1, 1), p); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(1, 1, 1, 255), H); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let client_port = client.local_addr().unwrap().port(); + let mut buf = [0u8; 1500]; + let mut rbuf = [0u8; 1500]; + let req = test_packet(0x200, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap(); + let (_, src) = recv(&client, &mut rbuf); + assert_eq!(src.ip(), srv_addr.ip()); + // reply destination was client port; implicit by receipt, but also check + // server did not send to a different port by verifying we got it at all. + assert_ne!(client_port, 0); +} + +#[test] +fn serve_handles_sequential_valid_packets() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let (tx, rx) = std::sync::mpsc::channel::(); + struct H { + tx: std::sync::mpsc::Sender, + } + impl server::Handler for H { + fn handle_request(&mut self, _s: &server::Server, p: Packet) { + let _ = self.tx.send(p.xid); + } + } + std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(1, 1, 1, 255), H { tx }); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + for xid in [0xA1u32, 0xA2, 0xA3] { + let p = test_packet(xid, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), xid); + } +} + +#[test] +fn serve_panic_packet_kills_server_thread_quirk() { + // END-first (and missing-END exact) cause Packet::from to PANIC, not Err. + // serve uses `if let Ok`, so the panic unwinds and kills the server loop + // (DoS quirk locked). Subsequent valid packets are never handled. + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let (tx, rx) = std::sync::mpsc::channel::(); + struct H { + tx: std::sync::mpsc::Sender, + } + impl server::Handler for H { + fn handle_request(&mut self, _s: &server::Server, p: Packet) { + let _ = self.tx.send(p.xid); + } + } + let handle = std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(1, 1, 1, 255), H { tx }); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut buf = [0u8; 1500]; + let p = test_packet(0xAAAA, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&p.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(2)).unwrap(), 0xAAAA); + + // panic packet: valid header + cookie + END first + let mut panic_raw = vec![0u8; 236]; + panic_raw[0] = 1; + panic_raw[1] = 1; + panic_raw[2] = 6; + panic_raw.extend_from_slice(&[99, 130, 83, 99, 255, 0, 0]); + client.send_to(&panic_raw, srv_addr).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert!(handle.is_finished(), "server thread must have panicked"); + + // server dead: further valid packet gets no handler call (sender disconnected) + let p2 = test_packet(0xBBBB, [1, 2, 3, 4, 5, 6], vec![ + DhcpOption::DhcpMessageType(MessageType::Discover), + ]); + client.send_to(&p2.encode(&mut buf).to_vec(), srv_addr).unwrap(); + assert!( + rx.recv_timeout(Duration::from_millis(500)).is_err(), + "dead server must not handle packets" + ); +} + +#[test] +fn serve_missing_end_exact_packet_also_panics_quirk() { + let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let srv_addr = srv_sock.local_addr().unwrap(); + let (tx, _rx) = std::sync::mpsc::channel::(); + struct H { + tx: std::sync::mpsc::Sender, + } + impl server::Handler for H { + fn handle_request(&mut self, _s: &server::Server, p: Packet) { + let _ = self.tx.send(p.xid); + } + } + let handle = std::thread::spawn(move || { + let _ = server::Server::serve(srv_sock, Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(1, 1, 1, 255), H { tx }); + }); + std::thread::sleep(Duration::from_millis(100)); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + // one valid option, no END, exact consumption -> split_at panic + let mut raw = vec![0u8; 236]; + raw[0] = 1; + raw[1] = 1; + raw[2] = 6; + raw.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1]); + client.send_to(&raw, srv_addr).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert!(handle.is_finished(), "missing-END exact packet must panic server"); +}