From 15c7557399ceb4ebd75614f6d39f8390edcae4d7 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Mon, 28 Sep 2026 17:33:55 +0100 Subject: [PATCH 1/2] Serve DHCP directly from Softnet and use predictable vmnet reservations --- .cargo/config.toml | 3 + .goreleaser.yml | 2 +- Cargo.lock | 214 +++++++++--------- Cargo.toml | 4 +- README.md | 9 +- lib/dhcp_server/mod.rs | 389 +++++++++++++++++++++++++++++++++ lib/dhcp_snooper.rs | 182 --------------- lib/host.rs | 74 ++++--- lib/mod.rs | 3 +- lib/proxy/flows/mod.rs | 4 - lib/proxy/host.rs | 156 ++----------- lib/proxy/mod.rs | 95 ++++---- lib/proxy/port_forwarder.rs | 99 --------- lib/proxy/udp_packet_helper.rs | 66 ------ lib/proxy/vm.rs | 253 +++++---------------- lib/subnet_finder/mod.rs | 84 +++++++ 16 files changed, 750 insertions(+), 887 deletions(-) create mode 100644 lib/dhcp_server/mod.rs delete mode 100644 lib/dhcp_snooper.rs delete mode 100644 lib/proxy/port_forwarder.rs delete mode 100644 lib/proxy/udp_packet_helper.rs create mode 100644 lib/subnet_finder/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index c7f2528..f10ff1b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ +[env] +MACOSX_DEPLOYMENT_TARGET = "26.0" + [target.aarch64-apple-darwin] runner = 'sudo -E' diff --git a/.goreleaser.yml b/.goreleaser.yml index 0b09cd8..79ed8f9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -38,5 +38,5 @@ brews: skip_upload: auto custom_block: | on_macos do - depends_on :macos => :sequoia + depends_on :macos => :tahoe end diff --git a/Cargo.lock b/Cargo.lock index f474cb6..03764e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "bytes", "futures-core", "futures-sink", @@ -30,7 +30,7 @@ dependencies = [ "actix-service", "actix-utils", "base64", - "bitflags 2.9.4", + "bitflags 2.13.2", "bytes", "bytestring", "derive_more", @@ -287,15 +287,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -306,6 +300,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -385,7 +388,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] @@ -397,7 +400,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.6", ] [[package]] @@ -506,9 +509,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.14.4" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -516,27 +519,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.14.4" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", - "strsim 0.10.0", - "syn 1.0.109", + "strsim", + "syn 3.0.6", ] [[package]] name = "darling_macro" -version = "0.14.4" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 1.0.109", + "syn 3.0.6", ] [[package]] @@ -701,18 +703,18 @@ dependencies = [ [[package]] name = "enum-iterator" -version = "1.5.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fd242f399be1da0a5354aa462d57b4ab2b4ee0683cc552f7c007d2d12d36e94" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" dependencies = [ "enum-iterator-derive", ] [[package]] name = "enum-iterator-derive" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", @@ -1551,13 +1553,25 @@ dependencies = [ "tempfile", ] +[[package]] +name = "network-interface" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" +dependencies = [ + "cc", + "libc", + "thiserror 2.0.19", + "winapi", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1570,7 +1584,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1582,7 +1596,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1604,49 +1618,49 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_enum" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" -dependencies = [ - "num_enum_derive 0.5.11", -] - [[package]] name = "num_enum" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ - "num_enum_derive 0.7.6", + "num_enum_derive", "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.5.11" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] -name = "num_enum_derive" -version = "0.7.6" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "proc-macro-crate 3.2.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "objc2-encode", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "object" version = "0.36.4" @@ -1678,7 +1692,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -1860,23 +1874,13 @@ dependencies = [ "nix 0.30.1", ] -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - [[package]] name = "proc-macro-crate" version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" dependencies = [ - "toml_edit 0.22.22", + "toml_edit", ] [[package]] @@ -1985,7 +1989,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", ] [[package]] @@ -2081,7 +2085,7 @@ version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.14", @@ -2094,7 +2098,7 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.9.4", @@ -2161,7 +2165,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2284,7 +2288,7 @@ version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9553a2f0f75bc8550137ebc753e8d2161af63ff780ca59983935f8df8f01655" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "log", "sentry-core", ] @@ -2305,7 +2309,7 @@ version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8aa7d8e0db4cccddbac01ddabd5344ad03b7c2f954b3ff850cecb1d9cf5d4758" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "sentry-backtrace", "sentry-core", "tracing-core", @@ -2356,7 +2360,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.6", ] [[package]] @@ -2406,7 +2410,7 @@ checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.6", ] [[package]] @@ -2516,12 +2520,14 @@ dependencies = [ "libc", "log", "mac_address", + "network-interface", "nix 0.31.3", - "num_enum 0.7.6", + "num_enum", "oslog", "polling", "prefix-trie", "privdrop", + "rand 0.10.1", "sentry", "sentry-anyhow", "serde", @@ -2539,12 +2545,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" @@ -2558,7 +2558,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -2575,9 +2574,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2610,7 +2609,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "501336eb7ba9e417300a6a0fa985721065467aa83a6dcf0422a8e43e4c0328fa" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "core-foundation 0.10.1", "system-configuration-sys", ] @@ -2675,7 +2674,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.6", ] [[package]] @@ -2780,17 +2779,6 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow 0.5.40", -] - [[package]] name = "toml_edit" version = "0.22.22" @@ -2799,7 +2787,7 @@ checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "toml_datetime", - "winnow 0.6.20", + "winnow", ] [[package]] @@ -2823,7 +2811,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.1.0", @@ -3028,32 +3016,35 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vmnet" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03f0531c358eba83803f9a46c165b8d1e9747758bf45c41df1499c1017d43f8c" +checksum = "9b7a1fba175b6ff0e54e30ead88d7ae7a4c6939bb81a2228e8af7e0897d38061" dependencies = [ - "bitflags 1.3.2", - "block", + "bitflags 2.13.2", + "block2", "enum-iterator", "hexdump", "lazy_static", "libc", - "num_enum 0.5.11", - "thiserror 1.0.64", + "num_enum", + "objc2", + "objc2-core-foundation", + "serial_test", + "thiserror 2.0.19", "uuid", "vmnet-derive", ] [[package]] name = "vmnet-derive" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cbf3c6928db44bb3757ac38ed65d25460205be9f6e50f61840c1d350c0e9c" +checksum = "805fcba7fb9fa3dbf5ff2680916f9797321eed33f91cd618f93a98f84b3144bd" dependencies = [ "darling", "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.6", ] [[package]] @@ -3202,7 +3193,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "hashbrown 0.15.2", "indexmap", "semver", @@ -3457,15 +3448,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.6.20" @@ -3507,7 +3489,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", ] [[package]] @@ -3548,7 +3530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.13.2", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index df840e6..eef6741 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ smoltcp = "0" libc = "0" polling = "3" dhcproto = { git = "https://github.com/bluecatengineering/dhcproto.git", branch = "master" } -vmnet = "0.5.1" +vmnet = "0.7.0" clap = { version = "4", features = ["derive"] } mac_address = "1" privdrop = "0" @@ -28,8 +28,10 @@ num_enum = "0" sentry = { version = "0", features = ["debug-images"] } sentry-anyhow = { version = "0", features = ["backtrace"] } nix = { version = "0", features = ["signal", "socket"] } +network-interface = "2" prefix-trie = "0" ipnet = "2" +rand = "0.10" oslog = "0.2.0" log = "0.4.29" serial_test = "4" diff --git a/README.md b/README.md index 5893bca..579dc74 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,7 @@ Softnet solves two problems: 2. DHCP exhaustion * macOS built-in DHCP-server allocates a `/24` subnet with 86400 seconds lease time by default, which only allows for ~253 VMs a day (or 1 VM every ~6 minutes) to be spawned without causing a denial-of-service, which is pretty limiting for CI services like Cirrus CI -And assumes that: - -1. Tart gives it's VMs unique MAC-addresses -2. macOS built-in DHCP-server won't re-use the IP-addresses from it's pool until their lease expire - -...otherwise it's possible for two VMs to receive an identical IP-address from the macOS built-in DHCP-server (even in the presence of Softnet's packet filtering) and thus bypass the protections offered by Softnet. +Each VM gets a separate [`vmnet` network](https://developer.apple.com/documentation/vmnet/vmnet_network_create(_:_:)) with a private `/30` subnet selected to avoid overlap with existing host interface subnets. Softnet reserves an IP-address for the VM's MAC-address, delivers it through its own DHCP-server, and uses the reserved address for packet filtering. ### Stateful flow authorization @@ -59,6 +54,8 @@ For ICMP, stateful flow authorization supports only echo requests and replies. ## Installing +Softnet requires macOS 26 or newer; building also requires the macOS 26 SDK or newer. + For proper functioning, Softnet binary requires two things: * a [SUID-bit](https://en.wikipedia.org/wiki/Setuid#SUID) to be set on the binary or a [passwordless sudo](https://serverfault.com/questions/160581/how-to-setup-passwordless-sudo-on-linux) to be configured, which effectively gives the binary `root` privileges diff --git a/lib/dhcp_server/mod.rs b/lib/dhcp_server/mod.rs new file mode 100644 index 0000000..1e44cb2 --- /dev/null +++ b/lib/dhcp_server/mod.rs @@ -0,0 +1,389 @@ +use anyhow::Result; +use dhcproto::v4::{ + CLIENT_PORT, DhcpOption, Flags, HType, Message, MessageType, Opcode, OptionCode, SERVER_PORT, +}; +use dhcproto::{Decodable, Encodable}; +use smoltcp::phy::ChecksumCapabilities; +use smoltcp::wire::{ + ETHERNET_HEADER_LEN, EthernetAddress, EthernetFrame, EthernetProtocol, IpAddress, IpProtocol, + Ipv4Address, Ipv4Packet, Ipv4Repr, UDP_HEADER_LEN, UdpPacket, UdpRepr, +}; + +pub(crate) const DHCP_SERVER_MAC: EthernetAddress = EthernetAddress([0x02, 0, 0, 0, 0, 1]); + +pub(crate) struct DhcpServer { + vm_mac: EthernetAddress, + vm_ip: Ipv4Address, + subnet_mask: Ipv4Address, + gateway_ip: Ipv4Address, +} + +impl DhcpServer { + /// Creates a DHCP server for the VM's reserved IPv4 configuration. + pub fn new( + vm_mac: EthernetAddress, + vm_ip: Ipv4Address, + subnet_mask: Ipv4Address, + gateway_ip: Ipv4Address, + ) -> Self { + Self { + vm_mac, + vm_ip, + subnet_mask, + gateway_ip, + } + } + + /// Returns a reply for a request sent to the DHCP server port. + pub fn receive_vm( + &self, + ip: &Ipv4Packet<&[u8]>, + udp: &UdpPacket<&[u8]>, + ) -> Result>> { + // Accept only intact DHCP client datagrams with valid checksums + if udp.src_port() != CLIENT_PORT + || !ip.verify_checksum() + || ip.more_frags() + || ip.frag_offset() != 0 + || UdpRepr::parse( + udp, + &IpAddress::Ipv4(ip.src_addr()), + &IpAddress::Ipv4(ip.dst_addr()), + &ChecksumCapabilities::default(), + ) + .is_err() + { + return Ok(None); + } + + // Only accept broadcasts or requests addressed to our gateway + if !(ip.dst_addr().is_broadcast() || ip.dst_addr() == self.gateway_ip) { + return Ok(None); + } + + // Decode the DHCP request + let Ok(request) = Message::from_bytes(udp.payload()) else { + return Ok(None); + }; + + // Allow REQUESTs from an obsolete address so the VM can receive a NAK + if request.opts().msg_type() != Some(MessageType::Request) + && !ip.src_addr().is_unspecified() + && self.vm_ip != ip.src_addr() + { + return Ok(None); + } + + // Build a reply if the request is supported + if let Some(reply) = self.receive(&request) { + // Broadcast NAKs and requested broadcasts; otherwise unicast to the VM + let (destination_mac, destination_ip) = + if reply.opts().msg_type() == Some(MessageType::Nak) || request.flags().broadcast() + { + (EthernetAddress::BROADCAST, Ipv4Address::BROADCAST) + } else if !request.ciaddr().is_unspecified() { + (self.vm_mac, request.ciaddr()) + } else { + (self.vm_mac, reply.yiaddr()) + }; + + // Encode the reply for delivery to the VM + let packet = encode_packet( + &reply, + DHCP_SERVER_MAC, + destination_mac, + self.gateway_ip, + destination_ip, + SERVER_PORT, + CLIENT_PORT, + )?; + return Ok(Some(packet)); + } + + Ok(None) + } + + /// Handles a DHCP request from the attached VM. + fn receive(&self, request: &Message) -> Option { + // Only accept direct Ethernet requests from the attached VM + if request.opcode() != Opcode::BootRequest + || request.htype() != HType::Eth + || request.hlen() != self.vm_mac.0.len() as u8 + || request.chaddr() != self.vm_mac.0 + || !request.giaddr().is_unspecified() + { + return None; + } + + // Ignore requests that select another DHCP server + let server_id = match request.opts().get(OptionCode::ServerIdentifier) { + Some(DhcpOption::ServerIdentifier(address)) => Some(*address), + _ => None, + }; + if server_id.is_some_and(|address| address != self.gateway_ip) { + return None; + } + + // Read the requested address, if supplied + let requested_ip = match request.opts().get(OptionCode::RequestedIpAddress) { + Some(DhcpOption::RequestedIpAddress(address)) => Some(*address), + _ => None, + }; + + // Offer the reserved address or validate a request for it + match request.opts().msg_type()? { + MessageType::Discover if request.ciaddr().is_unspecified() && server_id.is_none() => { + Some(self.address_reply(request, MessageType::Offer)) + } + MessageType::Request => { + // Use option 50 for SELECTING/INIT-REBOOT and ciaddr for RENEWING/REBINDING + let address = match (server_id, requested_ip, request.ciaddr().is_unspecified()) { + (_, Some(address), true) => address, + (None, None, false) => request.ciaddr(), + _ => return None, + }; + + // Reject requests for any address other than the VM's reservation + if address != self.vm_ip { + return Some(self.reply(request, MessageType::Nak)); + } + + // Acknowledge the reserved address + Some(self.address_reply(request, MessageType::Ack)) + } + _ => None, + } + } + + /// Builds an OFFER or ACK with the VM's reserved address and network configuration. + fn address_reply(&self, request: &Message, message_type: MessageType) -> Message { + // Include the reserved address and network configuration with an infinite lease + let mut reply = self.reply(request, message_type); + reply.set_yiaddr(self.vm_ip); + let options = reply.opts_mut(); + options.insert(DhcpOption::SubnetMask(self.subnet_mask)); + options.insert(DhcpOption::Router(vec![self.gateway_ip])); + options.insert(DhcpOption::DomainNameServer(vec![self.gateway_ip])); + options.insert(DhcpOption::AddressLeaseTime(u32::MAX)); + + reply + } + + /// Builds a DHCP reply with the request's transaction and client identifiers. + fn reply(&self, request: &Message, message_type: MessageType) -> Message { + // Create a reply for the same client and transaction + let nak = message_type == MessageType::Nak; + let mut reply = Message::default(); + reply + .set_xid(request.xid()) + .set_chaddr(request.chaddr()) + .set_opcode(Opcode::BootReply); + + // Broadcast NAKs; otherwise retain the client's address and flags + if nak { + reply.set_flags(Flags::default().set_broadcast()); + } else { + reply + .set_ciaddr(request.ciaddr()) + .set_flags(request.flags()); + } + + // Identify the reply type and this DHCP server + let options = reply.opts_mut(); + options.insert(DhcpOption::MessageType(message_type)); + options.insert(DhcpOption::ServerIdentifier(self.gateway_ip)); + + // RFC 6842 requires echoing the client's identifier in every reply + if let Some(identifier) = request.opts().get(OptionCode::ClientIdentifier) { + options.insert(identifier.clone()); + } + + reply + } +} + +/// Encodes a DHCP message in an Ethernet frame with IPv4 and UDP headers. +fn encode_packet( + message: &Message, + source_mac: EthernetAddress, + destination_mac: EthernetAddress, + source_ip: Ipv4Address, + destination_ip: Ipv4Address, + source_port: u16, + destination_port: u16, +) -> Result> { + // Pad the encoded message to the minimum BOOTP size, including short NAKs + let mut payload = message.to_vec()?; + payload.resize(payload.len().max(dhcproto::v4::MIN_PACKET_SIZE), 0); + + // Allocate space for Ethernet, IPv4, UDP, and the DHCP payload + let ip_repr = Ipv4Repr { + src_addr: source_ip, + dst_addr: destination_ip, + next_header: IpProtocol::Udp, + payload_len: UDP_HEADER_LEN + payload.len(), + hop_limit: 64, + }; + let mut bytes = vec![0; ETHERNET_HEADER_LEN + ip_repr.buffer_len() + ip_repr.payload_len]; + + // Write the Ethernet header + let mut ethernet = EthernetFrame::new_unchecked(bytes.as_mut_slice()); + ethernet.set_src_addr(source_mac); + ethernet.set_dst_addr(destination_mac); + ethernet.set_ethertype(EthernetProtocol::Ipv4); + + // Write the IPv4 header and checksum + let mut ip = Ipv4Packet::new_unchecked(ethernet.payload_mut()); + ip_repr.emit(&mut ip, &ChecksumCapabilities::default()); + + // Write the UDP header, DHCP payload, and checksum + let mut udp = UdpPacket::new_unchecked(ip.payload_mut()); + UdpRepr { + src_port: source_port, + dst_port: destination_port, + } + .emit( + &mut udp, + &IpAddress::Ipv4(source_ip), + &IpAddress::Ipv4(destination_ip), + payload.len(), + |buffer| buffer.copy_from_slice(&payload), + &ChecksumCapabilities::default(), + ); + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{DHCP_SERVER_MAC, DhcpServer, encode_packet}; + use dhcproto::Decodable; + use dhcproto::v4::{ + CLIENT_PORT, DhcpOption, Message, MessageType, Opcode, OptionCode, SERVER_PORT, + }; + use smoltcp::phy::ChecksumCapabilities; + use smoltcp::wire::{ + EthernetAddress, EthernetFrame, Ipv4Address, Ipv4Packet, Ipv4Repr, UdpPacket, UdpRepr, + }; + + const VM: EthernetAddress = EthernetAddress([2, 0, 0, 0, 0, 2]); + const ADDRESS: Ipv4Address = Ipv4Address::new(192, 168, 1, 2); + const GATEWAY: Ipv4Address = Ipv4Address::new(192, 168, 1, 1); + const MASK: Ipv4Address = Ipv4Address::new(255, 255, 255, 252); + const CLIENT_ID: &[u8] = &[1, 2, 0, 0, 0, 0, 2]; + + #[test] + fn assigns_reserved_address() { + // Create a server and a client request + let dhcp = DhcpServer::new(VM, ADDRESS, MASK, GATEWAY); + let mut message = request(MessageType::Discover); + + // Discover the reserved address + let (offer, destination) = exchange(&dhcp, &message, Ipv4Address::BROADCAST); + assert_eq!(offer.opts().msg_type(), Some(MessageType::Offer)); + assert_eq!(offer.yiaddr(), ADDRESS); + assert_eq!(destination, ADDRESS); + + // Request the offered address + message + .opts_mut() + .insert(DhcpOption::MessageType(MessageType::Request)); + message + .opts_mut() + .insert(DhcpOption::RequestedIpAddress(offer.yiaddr())); + message + .opts_mut() + .insert(DhcpOption::ServerIdentifier(GATEWAY)); + let (ack, destination) = exchange(&dhcp, &message, Ipv4Address::BROADCAST); + + // Check the address and configuration in the acknowledgement + assert_eq!(ack.opcode(), Opcode::BootReply); + assert_eq!(ack.opts().msg_type(), Some(MessageType::Ack)); + assert_eq!(ack.xid(), message.xid()); + assert_eq!(ack.chaddr(), VM.0); + assert_eq!(ack.yiaddr(), ADDRESS); + assert_eq!(destination, ADDRESS); + for option in [ + DhcpOption::ServerIdentifier(GATEWAY), + DhcpOption::SubnetMask(MASK), + DhcpOption::Router(vec![GATEWAY]), + DhcpOption::DomainNameServer(vec![GATEWAY]), + DhcpOption::AddressLeaseTime(u32::MAX), + DhcpOption::ClientIdentifier(CLIENT_ID.to_vec()), + ] { + assert_eq!(ack.opts().get(OptionCode::from(&option)), Some(&option)); + } + } + + #[test] + fn renews_reserved_address_and_rejects_stale_address() { + // Renew the reserved address + let dhcp = DhcpServer::new(VM, ADDRESS, MASK, GATEWAY); + let mut message = request(MessageType::Request); + message.set_ciaddr(ADDRESS); + let (ack, destination) = exchange(&dhcp, &message, GATEWAY); + assert_eq!(ack.opts().msg_type(), Some(MessageType::Ack)); + assert_eq!(ack.yiaddr(), ADDRESS); + assert_eq!(destination, ADDRESS); + + // Broadcast a NAK when the client renews an old address + message.set_ciaddr(Ipv4Address::new(192, 168, 2, 2)); + let (nak, destination) = exchange(&dhcp, &message, GATEWAY); + assert_eq!(nak.opts().msg_type(), Some(MessageType::Nak)); + assert!(nak.yiaddr().is_unspecified()); + assert_eq!(destination, Ipv4Address::BROADCAST); + } + + fn request(kind: MessageType) -> Message { + let mut message = Message::default(); + message.set_xid(42).set_chaddr(&VM.0); + message.opts_mut().insert(DhcpOption::MessageType(kind)); + message + .opts_mut() + .insert(DhcpOption::ClientIdentifier(CLIENT_ID.to_vec())); + message + } + + fn exchange( + dhcp: &DhcpServer, + request: &Message, + destination: Ipv4Address, + ) -> (Message, Ipv4Address) { + // Encode the client request + let frame = encode_packet( + request, + VM, + EthernetAddress::BROADCAST, + request.ciaddr(), + destination, + CLIENT_PORT, + SERVER_PORT, + ) + .unwrap(); + + // Pass the packet to the DHCP server + let ethernet = EthernetFrame::new_checked(frame.as_slice()).unwrap(); + let ip = Ipv4Packet::new_checked(ethernet.payload()).unwrap(); + let udp = UdpPacket::new_unchecked(ip.payload()); + let frame = dhcp + .receive_vm(&ip, &udp) + .unwrap() + .expect("expected a DHCP reply"); + + // Decode and validate the reply + let ethernet = EthernetFrame::new_checked(frame.as_slice()).unwrap(); + let ip = Ipv4Packet::new_unchecked(ethernet.payload()); + Ipv4Repr::parse(&ip, &ChecksumCapabilities::default()).unwrap(); + let udp = UdpPacket::new_unchecked(ip.payload()); + UdpRepr::parse( + &udp, + &ip.src_addr().into(), + &ip.dst_addr().into(), + &ChecksumCapabilities::default(), + ) + .unwrap(); + assert_eq!(ethernet.src_addr(), DHCP_SERVER_MAC); + assert_eq!(ip.src_addr(), GATEWAY); + (Message::from_bytes(udp.payload()).unwrap(), ip.dst_addr()) + } +} diff --git a/lib/dhcp_snooper.rs b/lib/dhcp_snooper.rs deleted file mode 100644 index 1845071..0000000 --- a/lib/dhcp_snooper.rs +++ /dev/null @@ -1,182 +0,0 @@ -use dhcproto::Decodable; -use dhcproto::v4::{DhcpOption, HType, Message, MessageType, Opcode, OptionCode}; -use smoltcp::wire::Ipv4Address; -use std::collections::HashSet; -use std::time::Duration; - -#[derive(Default)] -pub struct DhcpSnooper { - vm_mac_address: [u8; 6], - vm_lease: Option, - uncertainty_duration: Duration, -} - -impl DhcpSnooper { - pub fn new(uncertainty_duration: Duration, vm_mac_address: [u8; 6]) -> Self { - DhcpSnooper { - vm_mac_address, - uncertainty_duration, - ..Default::default() - } - } - - pub fn register_dhcp_reply(&mut self, dhcp_packet: &[u8]) { - let mut decoder = dhcproto::v4::Decoder::new(dhcp_packet); - - let message = match dhcproto::v4::Message::decode(&mut decoder) { - Ok(message) => message, - Err(_) => return, - }; - - // Decoded DHCP replies may be broadcast[1], so additionally validate the BOOTP client - // hardware address to avoid acting on another VM's lease transition - // - // [1]: https://datatracker.ietf.org/doc/html/rfc2131#section-4.1 - if !message_matches_bootp_client(&message, Opcode::BootReply, self.vm_mac_address) { - return; - } - - match message.opts().msg_type() { - Some(MessageType::Ack) => { - let lease_time = match message.opts().get(OptionCode::AddressLeaseTime) { - Some(DhcpOption::AddressLeaseTime(lease_time)) => lease_time, - _ => return, - }; - - let dns_ips = match message.opts().get(OptionCode::DomainNameServer) { - Some(DhcpOption::DomainNameServer(dns_ips)) => { - HashSet::from_iter(dns_ips.iter().cloned()) - } - _ => HashSet::new(), - }; - - let mut lease_duration = Duration::from_secs(*lease_time as u64); - - // Adjust for uncertainty caused by using a coarse clock - lease_duration = lease_duration.saturating_sub(self.uncertainty_duration); - - self.vm_lease = Some(Lease::new(message.yiaddr(), lease_duration, dns_ips)) - } - Some(MessageType::Nak) => { - self.vm_lease = None; - } - _ => {} - }; - } - - #[cfg(test)] - pub(crate) fn set_lease(&mut self, vm_lease: Option) { - self.vm_lease = vm_lease - } - - pub fn lease(&self) -> &Option { - &self.vm_lease - } - - pub(crate) fn address_and_dns_ips(&self) -> Option<(Ipv4Address, HashSet)> { - let lease = self.vm_lease.as_ref().filter(|lease| lease.valid())?; - Some((lease.address(), lease.dns_ips.clone())) - } - - pub fn valid_dns_target(&self, addr: &Ipv4Address) -> bool { - if let Some(lease) = &self.vm_lease { - return lease.dns_ips.contains(addr); - } - - false - } -} - -#[derive(Debug)] -pub struct Lease { - address: Ipv4Address, - valid_until: coarsetime::Instant, - dns_ips: HashSet, -} - -impl Lease { - pub fn new(address: Ipv4Address, lease_time: Duration, dns_ips: HashSet) -> Lease { - Lease { - address, - valid_until: coarsetime::Instant::recent() + lease_time.into(), - dns_ips, - } - } - - pub fn address(&self) -> Ipv4Address { - self.address - } - - pub fn valid(&self) -> bool { - coarsetime::Instant::recent() < self.valid_until - } - - pub fn is_valid_for(&self, address: Ipv4Address) -> bool { - self.address == address && self.valid() - } -} - -pub(crate) fn message_matches_bootp_client( - message: &Message, - opcode: Opcode, - mac: [u8; 6], -) -> bool { - message.opcode() == opcode - && message.htype() == HType::Eth - && message.hlen() == mac.len() as u8 - && message.chaddr() == mac -} - -#[cfg(test)] -mod tests { - use super::{DhcpSnooper, Lease}; - use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode}; - use dhcproto::{Encodable, Encoder}; - use smoltcp::wire::Ipv4Address; - use std::collections::HashSet; - use std::time::Duration; - - const VM_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; - const OTHER_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02]; - const OLD_ADDRESS: Ipv4Address = Ipv4Address::new(192, 168, 64, 2); - - #[test] - fn processes_replies_only_for_matching_client() { - // Start with an active lease - let mut snooper = DhcpSnooper::new(Duration::ZERO, VM_MAC); - snooper.set_lease(Some(Lease::new( - OLD_ADDRESS, - Duration::from_secs(600), - HashSet::new(), - ))); - - // Ignore a NAK for another client - let mut message = Message::new( - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - &OTHER_MAC, - ); - message.set_opcode(Opcode::BootReply); - message - .opts_mut() - .insert(DhcpOption::MessageType(MessageType::Nak)); - - let mut encoded = Vec::new(); - message.encode(&mut Encoder::new(&mut encoded)).unwrap(); - - snooper.register_dhcp_reply(&encoded); - - assert_eq!(snooper.lease().as_ref().unwrap().address(), OLD_ADDRESS); - - // Process a NAK for the matching client - message.set_chaddr(&VM_MAC); - encoded.clear(); - message.encode(&mut Encoder::new(&mut encoded)).unwrap(); - - snooper.register_dhcp_reply(&encoded); - - assert!(snooper.lease().is_none()); - } -} diff --git a/lib/host.rs b/lib/host.rs index 7b2ee66..a06dce5 100644 --- a/lib/host.rs +++ b/lib/host.rs @@ -1,15 +1,16 @@ +use crate::subnet_finder; use anyhow::{Context, Result, anyhow}; use clap::ValueEnum; use log::info; +use smoltcp::wire::EthernetAddress; use std::net::Ipv4Addr; use std::os::unix::io::{AsRawFd, RawFd}; use std::os::unix::net::UnixDatagram; -use std::str::FromStr; use std::sync::mpsc::{SyncSender, sync_channel}; -use vmnet::mode::Mode; +use vmnet::network::Mode; use vmnet::parameters::{Parameter, ParameterKind}; use vmnet::port_forwarding::{AddressFamily, Protocol}; -use vmnet::{Batch, Events, Options}; +use vmnet::{Batch, Events, Network, NetworkConfiguration, Options}; #[derive(ValueEnum, Clone, Debug)] pub enum NetType { @@ -27,6 +28,8 @@ pub struct Host { interface: vmnet::Interface, new_packets_rx: UnixDatagram, callback_can_continue_tx: SyncSender<()>, + pub vm_ip: Ipv4Addr, + pub subnet_mask: Ipv4Addr, pub gateway_ip: smoltcp::wire::Ipv4Address, pub max_packet_size: u64, pub read_max_packets: u64, @@ -34,31 +37,49 @@ pub struct Host { } impl Host { - pub fn new(vm_net_type: NetType, enable_isolation: bool) -> Result { - // Initialize a vmnet.framework NAT or Host interface with isolation enabled - let mut interface = vmnet::Interface::new( - match vm_net_type { - NetType::Nat => Mode::Shared(Default::default()), - NetType::Host => Mode::Host(Default::default()), - }, + pub fn new( + vm_net_type: NetType, + vm_mac: EthernetAddress, + enable_isolation: bool, + ) -> Result { + // Find an unused /30 subnet for the gateway and VM + let (gateway_ip, vm_ip, subnet) = subnet_finder::find_available_subnet(30)?; + + // Create vmnet's network configuration + let mut configuration = NetworkConfiguration::new(match vm_net_type { + NetType::Nat => Mode::Shared, + NetType::Host => Mode::Host, + }) + .context("failed to create vmnet network configuration")?; + + // Configure the gateway address and subnet mask + let subnet_mask = subnet.netmask(); + configuration + .set_ipv4_subnet(gateway_ip, subnet_mask) + .context("failed to configure IPv4 subnet")?; + + // Reserve the VM's address for its MAC + configuration + .add_dhcp_reservation(vm_mac.0, vm_ip) + .context("failed to add DHCP address reservation")?; + + // Create vmnet's network + let network = Network::new(&configuration) + .with_context(|| format!("failed to create vmnet network {subnet}"))?; + + // Instantiate vmnet's interface + // + // The interface retains the network reservation until it is stopped + let mut interface = vmnet::Interface::with_network( + &network, Options { + allocate_mac_address: Some(false), enable_isolation: Some(enable_isolation), ..Default::default() }, ) .context("failed to initialize vmnet interface")?; - // Retrieve first IP (gateway) used for this interface - let Some(Parameter::StartAddress(gateway_ip)) = - interface.parameters().get(ParameterKind::StartAddress) - else { - return Err(anyhow!( - "failed to retrieve vmnet's interface start address" - )); - }; - let gateway_ip = Ipv4Addr::from_str(&gateway_ip) - .context("failed to parse vmnet's interface start address")?; - // Retrieve max packet size for this interface let Some(Parameter::MaxPacketSize(max_packet_size)) = interface.parameters().get(ParameterKind::MaxPacketSize) @@ -104,6 +125,8 @@ impl Host { interface, new_packets_rx, callback_can_continue_tx, + vm_ip, + subnet_mask, gateway_ip, max_packet_size, read_max_packets, @@ -135,15 +158,6 @@ impl Host { .map_err(|err| anyhow!("failed to add port forwarding rule {details}: {err}")) } - pub fn port_forwarding_remove_rule(&mut self, external_port: u16) -> Result<()> { - let details = format!("external_port={external_port}"); - - self.interface - .port_forwarding_rule_remove(AddressFamily::Ipv4, Protocol::Tcp, external_port) - .map(|_| info!("removed port forwarding rule {details}")) - .map_err(|err| anyhow!("failed to remove port forwarding rule {details}: {err}")) - } - pub fn read(&mut self, batch: &mut Batch, bufs: &mut [Vec]) -> vmnet::Result { // Dequeue dummy datagram from the socket (if any) // to free up buffer space and reduce false-positives diff --git a/lib/mod.rs b/lib/mod.rs index 838640f..8051f75 100644 --- a/lib/mod.rs +++ b/lib/mod.rs @@ -1,6 +1,7 @@ -mod dhcp_snooper; +mod dhcp_server; mod host; pub use host::NetType; mod poller; pub mod proxy; +mod subnet_finder; mod vm; diff --git a/lib/proxy/flows/mod.rs b/lib/proxy/flows/mod.rs index d023863..21586af 100644 --- a/lib/proxy/flows/mod.rs +++ b/lib/proxy/flows/mod.rs @@ -152,10 +152,6 @@ impl FlowTable { true } - pub(crate) fn clear(&mut self) { - self.flows.clear(); - } - fn sweep_if_due(&mut self, now: Instant) { if now < self.next_sweep { return; diff --git a/lib/proxy/host.rs b/lib/proxy/host.rs index b0be52c..7c6e1f6 100644 --- a/lib/proxy/host.rs +++ b/lib/proxy/host.rs @@ -1,19 +1,9 @@ -use crate::dhcp_snooper::message_matches_bootp_client; use crate::proxy::flows::{FlowDirection, FlowMatch}; -use crate::proxy::udp_packet_helper::UdpPacketHelper; use crate::proxy::{Direction, PolicyDecision, Proxy}; use anyhow::{Context, Result}; -use dhcproto::Decodable; -use dhcproto::v4::Opcode; +use dhcproto::v4::{CLIENT_PORT, SERVER_PORT}; use smoltcp::phy::ChecksumCapabilities; -use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, Ipv4Repr, UdpPacket}; - -/// DhcpResponseDisposition distinguishes non-DHCP traffic from allowed and rejected DHCP replies. -enum DhcpResponseDisposition { - NotDhcp, - Allow, - Reject, -} +use smoltcp::wire::{EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Packet, Ipv4Repr, UdpPacket}; impl Proxy<'_> { pub(crate) fn process_frame_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Result<()> { @@ -22,13 +12,11 @@ impl Proxy<'_> { return Ok(()); } - // Snoop bootpd(8) replies from the host to - // figure out the IP assigned to the VM - if frame.dst_addr() == self.vm_mac_address || frame.dst_addr().is_broadcast() { - self.snoop(frame); - } + self.write_to_vm(frame.as_ref()) + } - match self.vm.write(frame.as_ref()) { + pub(super) fn write_to_vm(&mut self, packet: &[u8]) -> Result<()> { + match self.vm.write(packet) { Ok(_) => Ok(()), Err(err) => { if let Some(libc::ENOBUFS) = err.raw_os_error() { @@ -62,10 +50,15 @@ impl Proxy<'_> { } pub(super) fn allowed_from_host_ipv4(&mut self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> Option<()> { - match self.dhcp_response_disposition(ipv4_pkt) { - DhcpResponseDisposition::NotDhcp => { /* Fall through to generic policy */ } - DhcpResponseDisposition::Allow => return Some(()), - DhcpResponseDisposition::Reject => return None, + // Drop external DHCP replies, including malformed and fragmented replies + if ipv4_pkt.next_header() == IpProtocol::Udp + && ipv4_pkt.frag_offset() == 0 + && ipv4_pkt.payload().len() >= 4 + { + let udp = UdpPacket::new_unchecked(ipv4_pkt.payload()); + if udp.src_port() == SERVER_PORT && udp.dst_port() == CLIENT_PORT { + return None; + } } // Backwards compatibility with Softnet consumers that only use stateless rules @@ -75,12 +68,7 @@ impl Proxy<'_> { // Consult the flow table before evaluating inbound policy // so established flows are not treated as new traffic - let pending = if self - .dhcp_snooper - .lease() - .as_ref() - .is_some_and(|lease| lease.is_valid_for(ipv4_pkt.dst_addr())) - { + let pending = if self.host.vm_ip == ipv4_pkt.dst_addr() { match self .flows .as_mut()? @@ -116,116 +104,4 @@ impl Proxy<'_> { } } } - - fn snoop(&mut self, frame: &EthernetFrame<&[u8]>) { - if frame.ethertype() != EthernetProtocol::Ipv4 { - return; - } - - let ipv4_pkt = match Ipv4Packet::new_checked(frame.payload()) { - Ok(ipv4_pkt) => ipv4_pkt, - _ => return, - }; - - match self.dhcp_response_disposition(&ipv4_pkt) { - DhcpResponseDisposition::Allow => { /* Continue snooping */ } - DhcpResponseDisposition::NotDhcp | DhcpResponseDisposition::Reject => return, - } - - let udp_pkt = match UdpPacket::new_checked(ipv4_pkt.payload()) { - Ok(udp_pkt) => udp_pkt, - Err(_) => return, - }; - - let address_and_dns_ips_saved = self.dhcp_snooper.address_and_dns_ips(); - self.dhcp_snooper.register_dhcp_reply(udp_pkt.payload()); - if address_and_dns_ips_saved != self.dhcp_snooper.address_and_dns_ips() - && let Some(flows) = &mut self.flows - { - flows.clear(); - } - } - - fn dhcp_response_disposition(&self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> DhcpResponseDisposition { - if ipv4_pkt.src_addr() != self.host.gateway_ip - || ipv4_pkt.next_header() != smoltcp::wire::IpProtocol::Udp - { - return DhcpResponseDisposition::NotDhcp; - } - - let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else { - return DhcpResponseDisposition::NotDhcp; - }; - - // Require the standard DHCP server and client ports - if !udp_pkt.is_dhcp_response() { - return DhcpResponseDisposition::NotDhcp; - } - - // Require the BOOTP client hardware address to match this VM - // (symmetric with is_allowed_dhcp_request / #191 on the VM→host path) - let mut decoder = dhcproto::v4::Decoder::new(udp_pkt.payload()); - let Ok(message) = dhcproto::v4::Message::decode(&mut decoder) else { - return DhcpResponseDisposition::Reject; - }; - - if message_matches_bootp_client(&message, Opcode::BootReply, self.vm_mac_address.0) { - DhcpResponseDisposition::Allow - } else { - DhcpResponseDisposition::Reject - } - } -} - -#[cfg(test)] -mod tests { - use crate::dhcp_snooper::message_matches_bootp_client; - use dhcproto::Decodable; - use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode}; - use dhcproto::{Encodable, Encoder}; - use smoltcp::wire::Ipv4Address; - - const VM_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; - const OTHER_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02]; - - #[test] - fn dhcp_boot_reply_chaddr_must_match_vm() { - let own = encode_boot_reply(VM_MAC); - let foreign = encode_boot_reply(OTHER_MAC); - - let mut dec = dhcproto::v4::Decoder::new(&own); - let own_msg = Message::decode(&mut dec).unwrap(); - let mut dec = dhcproto::v4::Decoder::new(&foreign); - let foreign_msg = Message::decode(&mut dec).unwrap(); - - assert!(message_matches_bootp_client( - &own_msg, - Opcode::BootReply, - VM_MAC - )); - assert!(!message_matches_bootp_client( - &foreign_msg, - Opcode::BootReply, - VM_MAC - )); - } - - fn encode_boot_reply(chaddr: [u8; 6]) -> Vec { - let mut message = Message::new( - Ipv4Address::UNSPECIFIED, - Ipv4Address::new(192, 168, 64, 2), - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - &chaddr, - ); - message.set_opcode(Opcode::BootReply); - message - .opts_mut() - .insert(DhcpOption::MessageType(MessageType::Ack)); - message.opts_mut().insert(DhcpOption::AddressLeaseTime(600)); - - let mut encoded = Vec::new(); - message.encode(&mut Encoder::new(&mut encoded)).unwrap(); - encoded - } } diff --git a/lib/proxy/mod.rs b/lib/proxy/mod.rs index c3ddd65..62c2d30 100644 --- a/lib/proxy/mod.rs +++ b/lib/proxy/mod.rs @@ -2,27 +2,24 @@ mod control; mod exposed_port; mod flows; mod host; -mod port_forwarder; mod rule; mod rules; -mod udp_packet_helper; mod vm; -use crate::dhcp_snooper::DhcpSnooper; +use crate::dhcp_server::{DHCP_SERVER_MAC, DhcpServer}; use crate::host::Host; use crate::host::NetType; use crate::poller::Poller; use crate::vm::VM; -use anyhow::Result; +use anyhow::{Context, Result, bail}; use control::{Control, normalize_rules}; pub use exposed_port::ExposedPort; use flows::{FlowTable, PendingFlow}; use ipnet::Ipv4Net; use mac_address::MacAddress; -use port_forwarder::PortForwarder; pub use rule::{Direction, Rule, Target}; pub(crate) use rules::{PolicyDecision, Rules}; -use smoltcp::wire::{EthernetFrame, Ipv4Address}; +use smoltcp::wire::{EthernetAddress, EthernetFrame, Ipv4Address}; use std::io::ErrorKind; use std::os::unix::io::{AsRawFd, RawFd}; use std::time::Duration; @@ -33,12 +30,11 @@ pub struct Proxy<'proxy> { host: Host, poller: Poller<'proxy>, vm_mac_address: smoltcp::wire::EthernetAddress, - dhcp_snooper: DhcpSnooper, + dhcp_server: DhcpServer, rules: Rules, control: Option, flows: Option, enobufs_encountered: bool, - port_forwarder: PortForwarder, } impl Proxy<'_> { @@ -51,12 +47,19 @@ impl Proxy<'_> { exposed_ports: Vec, control_fd: Option, ) -> Result> { + // Ensure that VM's MAC address won't conflict with our DHCP server's MAC address + let vm_mac_address = EthernetAddress(vm_mac_address.bytes()); + if vm_mac_address == DHCP_SERVER_MAC { + bail!("VM MAC address {vm_mac_address} is reserved for the DHCP server"); + } + let allow = normalize_rules(allow); let block = normalize_rules(block); let vm = VM::new(vm_fd)?; - let host = Host::new( + let mut host = Host::new( vm_net_type, + vm_mac_address, !allow.contains(&Rule::Stateless(Target::Prefix(Ipv4Net::default()))), )?; let poller_timeout = Duration::from_millis(100); @@ -75,20 +78,34 @@ impl Proxy<'_> { let rules = Rules::new(host.gateway_ip, &allow, &block); // Any stateful rule enables flow inspection for the whole VM, including - // traffic admitted through implicit global, gateway, and DNS fallbacks + // traffic admitted through implicit global and gateway fallbacks let flows = rules.has_stateful().then(FlowTable::new); + // vmnet owns the gateway (including ARP and DNS); the VM address is + // reserved for this MAC for the lifetime of the attached interface. + let dhcp_server = DhcpServer::new( + vm_mac_address, + host.vm_ip, + host.subnet_mask, + host.gateway_ip, + ); + + // Install port forwardings for the reserved VM's IP address + for port in exposed_ports { + host.port_forwarding_add_rule(port.external_port, host.vm_ip, port.internal_port) + .context("failed to configure port forwarding")?; + } + Ok(Proxy { vm, host, poller, - vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()), - dhcp_snooper: DhcpSnooper::new(poller_timeout, vm_mac_address.bytes()), + vm_mac_address, + dhcp_server, rules, control, flows, enobufs_encountered: false, - port_forwarder: PortForwarder::new(exposed_ports), }) } @@ -108,7 +125,7 @@ impl Proxy<'_> { loop { let (vm_readable, host_readable, interrupt) = self.poller.wait()?; - // Update coarse time for DHCP snooping and flows + // Update coarse time for flows coarsetime::Instant::update(); // Service control on every wake (including timeouts) so a bounded read or a pending @@ -134,9 +151,6 @@ impl Proxy<'_> { if !self.vm.is_connected()? { return Ok(()); } - - self.port_forwarder - .tick(&mut self.host, self.dhcp_snooper.lease()); } self.poller.rearm(); @@ -149,7 +163,7 @@ impl Proxy<'_> { loop { match self.vm.read(buf) { Ok(n) => { - // Update coarse time for DHCP snooping and flows + // Update coarse time for flows coarsetime::Instant::update(); if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) { @@ -177,7 +191,7 @@ impl Proxy<'_> { loop { match self.host.read(batch, bufs) { Ok(pktcnt) => { - // Update coarse time for DHCP snooping and flows + // Update coarse time for flows coarsetime::Instant::update(); for buf in batch.packet_sized_bufs(bufs).take(pktcnt) { @@ -263,22 +277,19 @@ impl Proxy<'_> { #[cfg(test)] mod tests { use crate::NetType; - use crate::dhcp_snooper::Lease; use crate::proxy::Proxy; use mac_address::MacAddress; use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair}; use serial_test::serial; use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, UdpPacket}; - use std::collections::HashSet; use std::os::fd::AsRawFd; use std::str::FromStr; - use std::time::Duration; #[test] #[serial] fn test_blocking_takes_precedence() { - let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); - let mut proxy = create_proxy(vm_ip, vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]); + let mut proxy = create_proxy(vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]); + let vm_ip = proxy.host.vm_ip; assert_eq!(proxy.rules.len(), 1); @@ -288,8 +299,8 @@ mod tests { #[test] #[serial] fn test_longest_prefix_match_wins() { - let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); - let mut proxy = create_proxy(vm_ip, vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]); + let mut proxy = create_proxy(vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]); + let vm_ip = proxy.host.vm_ip; assert_eq!(proxy.rules.len(), 2); @@ -301,8 +312,8 @@ mod tests { #[test] #[serial] fn test_allow_host() { - let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); - let mut proxy = create_proxy(vm_ip, vec!["@host"], vec!["0.0.0.0/0"]); + let mut proxy = create_proxy(vec!["@host"], vec!["0.0.0.0/0"]); + let vm_ip = proxy.host.vm_ip; assert_eq!(proxy.rules.len(), 2); @@ -317,10 +328,10 @@ mod tests { #[test] #[serial] fn test_bare_default_block_applies_in_both_directions_in_stateful_mode() { - let vm_ip = Ipv4Address::new(192, 168, 0, 2); + let mut proxy = create_proxy(vec!["in 192.0.2.0/24"], vec!["0.0.0.0/0"]); + let vm_ip = proxy.host.vm_ip; let fallback_peer = Ipv4Address::new(203, 0, 113, 1); let explicitly_allowed_peer = Ipv4Address::new(192, 0, 2, 1); - let mut proxy = create_proxy(vm_ip, vec!["in 192.0.2.0/24"], vec!["0.0.0.0/0"]); let fallback_request = udp_packet(fallback_peer, 40_000, vm_ip, 1_234); let fallback_request = Ipv4Packet::new_checked(fallback_request.as_slice()).unwrap(); @@ -342,9 +353,9 @@ mod tests { #[test] #[serial] fn test_directional_egress_block_does_not_block_reply_to_unmatched_inbound_flow() { - let vm_ip = Ipv4Address::new(192, 168, 0, 2); + let mut proxy = create_proxy(vec![], vec!["out 203.0.113.0/24"]); + let vm_ip = proxy.host.vm_ip; let peer = Ipv4Address::new(203, 0, 113, 1); - let mut proxy = create_proxy(vm_ip, vec![], vec!["out 203.0.113.0/24"]); let request = udp_packet(peer, 40_000, vm_ip, 1_234); let request = Ipv4Packet::new_checked(request.as_slice()).unwrap(); @@ -358,9 +369,9 @@ mod tests { #[test] #[serial] fn test_directional_ingress_block_does_not_block_reply_to_bare_outbound_allow() { - let vm_ip = Ipv4Address::new(192, 168, 0, 2); + let mut proxy = create_proxy(vec!["203.0.113.0/24"], vec!["in 203.0.113.0/24"]); + let vm_ip = proxy.host.vm_ip; let peer = Ipv4Address::new(203, 0, 113, 1); - let mut proxy = create_proxy(vm_ip, vec!["203.0.113.0/24"], vec!["in 203.0.113.0/24"]); let request = udp_packet(vm_ip, 1_234, peer, 40_000); let request = Ipv4Packet::new_checked(request.as_slice()).unwrap(); @@ -371,7 +382,7 @@ mod tests { assert!(proxy.allowed_from_host_ipv4(&reply).is_some()); } - fn create_proxy<'test>(vm_ip: Ipv4Address, allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> { + fn create_proxy<'test>(allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> { let (vm_fd, _) = socketpair( AddressFamily::Unix, SockType::Datagram, @@ -381,9 +392,9 @@ mod tests { .unwrap(); let vm_fd = Box::leak(Box::new(vm_fd)); - let mut proxy = Proxy::new( + Proxy::new( vm_fd.as_raw_fd(), - MacAddress::from_str("02:00:00:00:00:01").unwrap(), + MacAddress::from_str("02:00:00:00:00:02").unwrap(), NetType::Nat, allow .into_iter() @@ -396,15 +407,7 @@ mod tests { Vec::default(), None, ) - .unwrap(); - - proxy.dhcp_snooper.set_lease(Some(Lease::new( - vm_ip, - Duration::from_secs(600), - HashSet::new(), - ))); - - proxy + .unwrap() } fn allowed_from_vm_ipv4(proxy: &mut Proxy, src: Ipv4Address, dst: &str) -> Option<()> { diff --git a/lib/proxy/port_forwarder.rs b/lib/proxy/port_forwarder.rs deleted file mode 100644 index 377d0ab..0000000 --- a/lib/proxy/port_forwarder.rs +++ /dev/null @@ -1,99 +0,0 @@ -use crate::dhcp_snooper::Lease; -use crate::host::Host; -use crate::proxy::exposed_port::ExposedPort; -use anyhow::Result; -use log::error; -use std::net::Ipv4Addr; - -#[derive(Default)] -pub struct PortForwarder { - port_forwardings: Vec, - failed: bool, -} - -#[derive(Debug, Clone, Copy, Default)] -struct PortForwarding { - exposed_port: ExposedPort, - forwarding_to_addr: Option, -} - -impl PortForwarder { - pub fn new(exposed_ports: Vec) -> PortForwarder { - let port_forwardings = exposed_ports - .into_iter() - .map(|exposed_port| PortForwarding { - exposed_port, - ..Default::default() - }) - .collect(); - - PortForwarder { - port_forwardings, - ..Default::default() - } - } - - pub fn tick(&mut self, host: &mut Host, lease: &Option) { - if self.failed { - return; - } - - if let Err(err) = self.tick_inner(host, lease) { - error!("port-forwarding failed: {}", err); - - self.failed = true; - } - } - - fn tick_inner(&mut self, host: &mut Host, lease: &Option) -> Result<()> { - if let Some(lease) = lease { - // Lease exists, but is not valid, remove all port forwardings - if !lease.valid() { - self.remove_all_port_forwardings(host)?; - - return Ok(()); - } - - // Lease exists and is valid, install/re-install port forwardings - for port_forwarding in &mut self.port_forwardings { - if let Some(installed_addr) = port_forwarding.forwarding_to_addr { - // Port forwarding already installed, perhaps it's outdated? - if installed_addr == lease.address() { - // Nope, the port forwarding is up to date - continue; - } - - // Remove port forwarding since the lease address had changed - host.port_forwarding_remove_rule(port_forwarding.exposed_port.external_port)?; - port_forwarding.forwarding_to_addr = None; - } - - // Install new port forwarding - host.port_forwarding_add_rule( - port_forwarding.exposed_port.external_port, - lease.address(), - port_forwarding.exposed_port.internal_port, - )?; - port_forwarding.forwarding_to_addr = Some(lease.address()); - } - } else { - // Lease does not exist, remove all port forwardings - self.remove_all_port_forwardings(host)?; - } - - Ok(()) - } - - fn remove_all_port_forwardings(&mut self, host: &mut Host) -> Result<()> { - for port_forwarding in &mut self.port_forwardings { - if port_forwarding.forwarding_to_addr.is_none() { - continue; - } - - host.port_forwarding_remove_rule(port_forwarding.exposed_port.external_port)?; - port_forwarding.forwarding_to_addr = None; - } - - Ok(()) - } -} diff --git a/lib/proxy/udp_packet_helper.rs b/lib/proxy/udp_packet_helper.rs deleted file mode 100644 index 1b77e1e..0000000 --- a/lib/proxy/udp_packet_helper.rs +++ /dev/null @@ -1,66 +0,0 @@ -use smoltcp::wire::UdpPacket; - -pub(crate) trait UdpPacketHelper { - const DNS_PORT: u16 = 53; - const BOOTPS_PORT: u16 = 67; - const BOOTPC_PORT: u16 = 68; - - fn is_dns_request(&self) -> bool; - - fn is_dhcp_request(&self) -> bool; - fn is_dhcp_response(&self) -> bool; -} - -impl UdpPacketHelper for UdpPacket<&[u8]> { - fn is_dns_request(&self) -> bool { - self.dst_port() == Self::DNS_PORT - } - - fn is_dhcp_request(&self) -> bool { - self.src_port() == Self::BOOTPC_PORT && self.dst_port() == Self::BOOTPS_PORT - } - - fn is_dhcp_response(&self) -> bool { - self.src_port() == Self::BOOTPS_PORT && self.dst_port() == Self::BOOTPC_PORT - } -} - -#[cfg(test)] -mod tests { - use super::UdpPacketHelper; - use smoltcp::wire::UdpPacket; - - #[test] - fn test_is_dhcp_request_requires_both_standard_ports() { - assert!(is_dhcp_request(68, 67)); - assert!(!is_dhcp_request(68, 9999)); - assert!(!is_dhcp_request(9999, 67)); - } - - #[test] - fn test_is_dhcp_response_requires_both_standard_ports() { - assert!(is_dhcp_response(67, 68)); - assert!(!is_dhcp_response(67, 9999)); - assert!(!is_dhcp_response(9999, 68)); - } - - fn is_dhcp_request(src_port: u16, dst_port: u16) -> bool { - let buffer = udp_packet_buffer(src_port, dst_port); - let udp_pkt = UdpPacket::new_unchecked(&buffer[..]); - udp_pkt.is_dhcp_request() - } - - fn is_dhcp_response(src_port: u16, dst_port: u16) -> bool { - let buffer = udp_packet_buffer(src_port, dst_port); - let udp_pkt = UdpPacket::new_unchecked(&buffer[..]); - udp_pkt.is_dhcp_response() - } - - fn udp_packet_buffer(src_port: u16, dst_port: u16) -> [u8; 8] { - let mut buffer = [0; 8]; - let mut udp_pkt = UdpPacket::new_unchecked(&mut buffer[..]); - udp_pkt.set_src_port(src_port); - udp_pkt.set_dst_port(dst_port); - buffer - } -} diff --git a/lib/proxy/vm.rs b/lib/proxy/vm.rs index bda7730..eb2b5c0 100644 --- a/lib/proxy/vm.rs +++ b/lib/proxy/vm.rs @@ -1,11 +1,8 @@ -use crate::dhcp_snooper::{Lease, message_matches_bootp_client}; use crate::proxy::flows::{FlowDirection, FlowMatch}; -use crate::proxy::udp_packet_helper::UdpPacketHelper; use crate::proxy::{Direction, PolicyDecision, Proxy}; use anyhow::Context; use anyhow::Result; -use dhcproto::Decodable; -use dhcproto::v4::Opcode; +use dhcproto::v4::SERVER_PORT; use smoltcp::phy::ChecksumCapabilities; use smoltcp::wire::{ ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Address, @@ -53,25 +50,17 @@ impl Proxy<'_> { } fn allowed_from_vm_arp(&self, arp_pkt: ArpPacket<&[u8]>) -> Option<()> { - vm_arp_allowed(arp_pkt, self.vm_mac_address, self.dhcp_snooper.lease()) + vm_arp_allowed(arp_pkt, self.vm_mac_address, self.host.vm_ip) } pub(crate) fn allowed_from_vm_ipv4(&mut self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> { - // Is this packet coming from VM's IP address that we've learned from DHCP snooping? - if let Some(lease) = &self.dhcp_snooper.lease() - && lease.is_valid_for(ipv4_pkt.src_addr()) - { - // Unicast DHCP renewal is required to maintain the VM's lease - // and must bypass user-specified rules - if is_allowed_dhcp_request( - &ipv4_pkt, - Some(self.host.gateway_ip), - self.vm_mac_address, - self.dhcp_snooper.lease(), - ) { - return Some(()); - } + // Consume DHCP before enforcing the reserved source address + if self.consume_dhcp(&ipv4_pkt) { + return None; + } + // Is this packet coming from the VM's reserved IP address? + if self.host.vm_ip == ipv4_pkt.src_addr() { // Consult the flow table before evaluating outbound policy // so established flows are not treated as new traffic let pending = match self @@ -116,83 +105,50 @@ impl Proxy<'_> { if dst_addr == self.host.gateway_ip { return self.admit_with_tracking_if_trackable(pending); } - - // Additionally, allow DNS requests to DNS-servers - // provided to a VM by the host's DHCP server - if ipv4_pkt.next_header() == IpProtocol::Udp { - let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?; - - if udp_pkt.is_dns_request() && self.dhcp_snooper.valid_dns_target(&dst_addr) { - return self.admit_with_tracking_if_trackable(pending); - } - } - } - - // Allow outgoing DHCP requests to the bootpd(8) broadcast address, - // otherwise DHCP snooper will never be populated - if is_allowed_dhcp_request( - &ipv4_pkt, - None, - self.vm_mac_address, - self.dhcp_snooper.lease(), - ) { - return Some(()); } None } -} -fn is_allowed_dhcp_request( - ipv4_pkt: &Ipv4Packet<&[u8]>, - unicast_target: Option, - vm_mac_address: smoltcp::wire::EthernetAddress, - lease: &Option, -) -> bool { - // Require the source address to be either: - // * covered by the VM's current lease - // * unspecified on the broadcast DHCP path - let src_addr = ipv4_pkt.src_addr(); - let src_has_valid_lease = lease - .as_ref() - .is_some_and(|lease| lease.is_valid_for(src_addr)); - if !src_has_valid_lease && !(unicast_target.is_none() && src_addr.is_unspecified()) { - return false; - } - - let dst_addr = ipv4_pkt.dst_addr(); + fn consume_dhcp(&mut self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> bool { + // Only inspect UDP packets that contain the source and destination ports + if ipv4_pkt.next_header() != IpProtocol::Udp + || ipv4_pkt.frag_offset() != 0 + || ipv4_pkt.payload().len() < 4 + { + return false; + } - // Keep the common path cheap and inspect UDP only for a permitted DHCP target - if !dst_addr.is_broadcast() && unicast_target != Some(dst_addr) { - return false; - } + // Only consume packets addressed to the DHCP server port + let udp = UdpPacket::new_unchecked(ipv4_pkt.payload()); + if udp.dst_port() != SERVER_PORT { + return false; + } - if ipv4_pkt.next_header() != IpProtocol::Udp { - return false; - } + // Pass the request to the DHCP server and send any reply + let result = match self.dhcp_server.receive_vm(ipv4_pkt, &udp) { + Ok(Some(reply)) => self.write_to_vm(&reply), + Ok(None) => Ok(()), + Err(err) => Err(err), + }; - let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else { - return false; - }; + // Report failures to generate or send the reply + if let Err(err) = result { + sentry::capture_message( + &format!("Failed to reply to DHCP request: {err:#}"), + sentry::Level::Warning, + ); + } - // Require the standard DHCP client and server ports - if !udp_pkt.is_dhcp_request() { - return false; + // Consume the packet even if the request was rejected or the reply failed + true } - - // Require the BOOTP client hardware address to match this VM - let mut decoder = dhcproto::v4::Decoder::new(udp_pkt.payload()); - let Ok(message) = dhcproto::v4::Message::decode(&mut decoder) else { - return false; - }; - - message_matches_bootp_client(&message, Opcode::BootRequest, vm_mac_address.0) } fn vm_arp_allowed( arp_pkt: ArpPacket<&[u8]>, vm_mac_address: smoltcp::wire::EthernetAddress, - lease: &Option, + address: Ipv4Address, ) -> Option<()> { let (operation, source_hardware_addr, source_protocol_addr) = match ArpRepr::parse(&arp_pkt).ok()? { @@ -213,84 +169,34 @@ fn vm_arp_allowed( return None; } - if let Some(lease) = lease { - if lease.is_valid_for(source_protocol_addr) { - return Some(()); - } - } else if source_protocol_addr.is_unspecified() { - return Some(()); - } - - None + (source_protocol_addr == address || source_protocol_addr.is_unspecified()).then_some(()) } #[cfg(test)] mod tests { - use crate::dhcp_snooper::Lease; - use dhcproto::v4::{DhcpOption, Message, MessageType}; - use dhcproto::{Encodable, Encoder}; use smoltcp::wire::{ - ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, IpProtocol, - Ipv4Address, Ipv4Packet, UdpPacket, + ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, Ipv4Address, }; - use std::collections::HashSet; - use std::time::Duration; - - const VM_MAC: EthernetAddress = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); - - #[test] - fn test_allowed_dhcp_request_policy() { - let gateway = Ipv4Address::new(192, 168, 64, 1); - let lease_ip = Ipv4Address::new(192, 168, 64, 2); - let other = Ipv4Address::new(192, 168, 64, 3); - let no_lease = None; - let lease = Some(Lease::new( - lease_ip, - Duration::from_secs(600), - HashSet::new(), - )); - let initial = |src, chaddr| { - allowed_dhcp_request(src, Ipv4Address::BROADCAST, None, chaddr, &no_lease) - }; - let renewal = |src, dst| allowed_dhcp_request(src, dst, Some(gateway), VM_MAC.0, &lease); - let other_mac = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02]; - - assert!(initial(Ipv4Address::UNSPECIFIED, VM_MAC.0)); - assert!(renewal(lease_ip, gateway)); - assert!(!renewal(other, gateway)); - assert!(!renewal(Ipv4Address::UNSPECIFIED, gateway)); - assert!(!renewal(lease_ip, other)); - assert!(!initial(Ipv4Address::UNSPECIFIED, other_mac)); - } #[test] - fn test_allowed_from_vm_arp_allows_unspecified_request_without_lease() { + fn test_allowed_from_vm_arp_allows_unspecified_request() { let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); let buf = arp_packet(vm_mac_address.0, [0, 0, 0, 0], ArpOperation::Request, 6, 4); let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap(); - assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_some()); + assert!( + super::vm_arp_allowed(arp_pkt, vm_mac_address, Ipv4Address::new(1, 2, 3, 4)).is_some() + ); } #[test] fn test_allowed_from_vm_arp_allows_reply_for_leased_ip() { let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); - let lease_ip = Ipv4Address::new(192, 168, 0, 2); - let lease = Some(Lease::new( - lease_ip, - Duration::from_secs(600), - HashSet::new(), - )); - let buf = arp_packet( - vm_mac_address.0, - lease_ip.octets(), - ArpOperation::Reply, - 6, - 4, - ); + let vm_ip = Ipv4Address::new(192, 168, 0, 2); + let buf = arp_packet(vm_mac_address.0, vm_ip.octets(), ArpOperation::Reply, 6, 4); let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap(); - assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &lease).is_some()); + assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, vm_ip).is_some()); } #[test] @@ -305,7 +211,9 @@ mod tests { ); let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap(); - assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none()); + assert!( + super::vm_arp_allowed(arp_pkt, vm_mac_address, Ipv4Address::new(1, 2, 3, 4)).is_none() + ); } #[test] @@ -316,7 +224,9 @@ mod tests { arp_pkt.set_hardware_type(ArpHardware::Unknown(2)); let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap(); - assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none()); + assert!( + super::vm_arp_allowed(arp_pkt, vm_mac_address, Ipv4Address::new(1, 2, 3, 4)).is_none() + ); } #[test] @@ -327,7 +237,9 @@ mod tests { arp_pkt.set_protocol_type(EthernetProtocol::Ipv6); let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap(); - assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none()); + assert!( + super::vm_arp_allowed(arp_pkt, vm_mac_address, Ipv4Address::new(1, 2, 3, 4)).is_none() + ); } #[test] @@ -336,7 +248,9 @@ mod tests { let buf = arp_packet(vm_mac_address.0, [0, 0, 0], ArpOperation::Request, 6, 3); let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap(); - assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none()); + assert!( + super::vm_arp_allowed(arp_pkt, vm_mac_address, Ipv4Address::new(1, 2, 3, 4)).is_none() + ); } fn arp_packet( @@ -361,55 +275,4 @@ mod tests { arp_pkt.set_target_protocol_addr(&vec![0; protocol_len as usize]); buf } - - fn allowed_dhcp_request( - src_addr: Ipv4Address, - dst_addr: Ipv4Address, - unicast_target: Option, - chaddr: [u8; 6], - lease: &Option, - ) -> bool { - let mut buf = dhcp_request(chaddr); - let mut ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_mut_slice()); - ipv4_pkt.set_src_addr(src_addr); - ipv4_pkt.set_dst_addr(dst_addr); - - let ipv4_pkt = Ipv4Packet::new_checked(buf.as_slice()).unwrap(); - super::is_allowed_dhcp_request(&ipv4_pkt, unicast_target, VM_MAC, lease) - } - - fn dhcp_request(chaddr: [u8; 6]) -> Vec { - let mut message = Message::new( - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - Ipv4Address::UNSPECIFIED, - &chaddr, - ); - message - .opts_mut() - .insert(DhcpOption::MessageType(MessageType::Discover)); - - let mut dhcp_payload = Vec::new(); - message - .encode(&mut Encoder::new(&mut dhcp_payload)) - .unwrap(); - - let total_len = 20 + 8 + dhcp_payload.len(); - let mut buf = vec![0; total_len]; - let mut ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_mut_slice()); - ipv4_pkt.set_version(4); - ipv4_pkt.set_header_len(20); - ipv4_pkt.set_total_len(total_len as u16); - ipv4_pkt.set_next_header(IpProtocol::Udp); - ipv4_pkt.set_src_addr(Ipv4Address::UNSPECIFIED); - ipv4_pkt.set_dst_addr(Ipv4Address::BROADCAST); - - let mut udp_pkt = UdpPacket::new_unchecked(ipv4_pkt.payload_mut()); - udp_pkt.set_src_port(68); - udp_pkt.set_dst_port(67); - udp_pkt.set_len((8 + dhcp_payload.len()) as u16); - udp_pkt.payload_mut().copy_from_slice(&dhcp_payload); - buf - } } diff --git a/lib/subnet_finder/mod.rs b/lib/subnet_finder/mod.rs new file mode 100644 index 0000000..b1e5ac2 --- /dev/null +++ b/lib/subnet_finder/mod.rs @@ -0,0 +1,84 @@ +use anyhow::{Context, Result}; +use ipnet::{Ipv4AddrRange, Ipv4Net}; +use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig}; +use rand::seq::{IndexedRandom, IteratorRandom}; +use std::net::Ipv4Addr; + +const MAX_ATTEMPTS: usize = 128; + +/// Find an available private subnet and its first two usable host addresses +pub(crate) fn find_available_subnet(prefix_len: u8) -> Result<(Ipv4Addr, Ipv4Addr, Ipv4Net)> { + // Add private address space (as defined in RFC 1918[1]) + // + // [1]: https://datatracker.ietf.org/doc/html/rfc1918#section-3 + let private_subnets: Vec = vec![ + "10.0.0.0/8".parse()?, + "172.16.0.0/12".parse()?, + "192.168.0.0/16".parse()?, + ]; + + // Figure out which address space is already utilized on the host + let mut used_subnets = Vec::new(); + + for interface in NetworkInterface::show()? { + for addr in interface.addr { + // We only support IPv4 for now + if let Addr::V4(addr) = addr { + let mask = addr.netmask.context("IPv4 interface has no netmask")?; + used_subnets.push(Ipv4Net::with_netmask(addr.ip, mask)?); + } + } + } + + // Try up to MAX_ATTEMPTS random subnets until we're able to + // get an available subnet of the desired length + for _ in 0..MAX_ATTEMPTS { + // Give each private range an equal chance, even if a larger one is occupied + let private_subnet = private_subnets.choose(&mut rand::rng()).unwrap(); + + // Skip private ranges too small to contain the requested subnet + if prefix_len < private_subnet.prefix_len() { + continue; + } + + // Pick a subnet of the desired length within this private range + let address = Ipv4AddrRange::new(private_subnet.network(), private_subnet.broadcast()) + .choose(&mut rand::rng()) + .unwrap(); + let candidate = Ipv4Net::new(address, prefix_len)?.trunc(); + + // Only use the subnet if it doesn't overlap address space + // that is already utilized on the host + let overlaps = used_subnets + .iter() + .any(|used_subnet| candidate.contains(used_subnet) || used_subnet.contains(&candidate)); + if overlaps { + continue; + } + + // Take the first two hosts from the subnet + let mut hosts = candidate.hosts(); + if let (Some(first_host), Some(second_host)) = (hosts.next(), hosts.next()) { + return Ok((first_host, second_host, candidate)); + } + } + + anyhow::bail!("failed to find an unused private IPv4 /{prefix_len} subnet after {MAX_ATTEMPTS} attempts") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[serial_test::serial] + fn finds_two_usable_addresses() { + // Find a subnet on the current host + let (gateway_ip, vm_ip, subnet) = find_available_subnet(30).unwrap(); + + // Check the subnet and its two usable addresses + assert_eq!(subnet.prefix_len(), 30); + assert!(subnet.network().is_private()); + assert_eq!(subnet.hosts().collect::>(), [gateway_ip, vm_ip]); + } +} From af8ee01167a4583c787c8c4208e5a7a5cd84fa35 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Mon, 28 Sep 2026 17:56:54 +0100 Subject: [PATCH 2/2] $ cargo fmt --- lib/subnet_finder/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/subnet_finder/mod.rs b/lib/subnet_finder/mod.rs index b1e5ac2..8aca8ef 100644 --- a/lib/subnet_finder/mod.rs +++ b/lib/subnet_finder/mod.rs @@ -63,7 +63,9 @@ pub(crate) fn find_available_subnet(prefix_len: u8) -> Result<(Ipv4Addr, Ipv4Add } } - anyhow::bail!("failed to find an unused private IPv4 /{prefix_len} subnet after {MAX_ATTEMPTS} attempts") + anyhow::bail!( + "failed to find an unused private IPv4 /{prefix_len} subnet after {MAX_ATTEMPTS} attempts" + ) } #[cfg(test)]