Compare commits

...

7 Commits
0.21.1 ... main

Author SHA1 Message Date
Darry 18f5a3338e
Require BOOTP chaddr match for host→VM DHCP responses (#192)
* Require BOOTP chaddr match for host→VM DHCP responses

Mirror #191 request-path identity checks: admit/forward DHCP
BootReplies only when chaddr matches the VM MAC, so foreign
client replies are not written into the guest fd.

Co-authored-by: Cursor <cursoragent@cursor.com>

* $ cargo fmt

---------

Co-authored-by: genforAI <genforAI@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Nikolay Edigaryev <edi@openai.com>
2026-08-14 13:41:41 +01:00
Yibo Zhuang 0c4327dd71
Exit Softnet when its VM socket disconnects (#193)
Check the VM socket on poll timeouts because macOS kqueue does not
report Unix datagram peer disconnects. This prevents orphaned
Softnet processes from blocking VM cleanup.
2026-08-14 13:36:32 +01:00
edi-oai 28bb29df4a
Validate DHCP client identity and source address (#191)
* Validate DHCP client identity and source address

* Use CFBoolean instead of CFNumber for dhcp_ignore_client_identifier

* Serialize bootpd preference updates
2026-08-10 22:10:20 -04:00
dependabot[bot] d079057ecf
Bump the all-updates group with 4 updates (#190)
Bumps the all-updates group with 4 updates: [clap](https://github.com/clap-rs/clap), [sentry](https://github.com/getsentry/sentry-rust), [sentry-anyhow](https://github.com/getsentry/sentry-rust) and [ipnet](https://github.com/krisprice/ipnet).


Updates `clap` from 4.6.4 to 4.6.5
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.4...clap_complete-v4.6.5)

Updates `sentry` from 0.49.0 to 0.49.1
- [Release notes](https://github.com/getsentry/sentry-rust/releases)
- [Changelog](https://github.com/getsentry/sentry-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-rust/compare/0.49.0...0.49.1)

Updates `sentry-anyhow` from 0.49.0 to 0.49.1
- [Release notes](https://github.com/getsentry/sentry-rust/releases)
- [Changelog](https://github.com/getsentry/sentry-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-rust/compare/0.49.0...0.49.1)

Updates `ipnet` from 2.12.0 to 2.12.1
- [Release notes](https://github.com/krisprice/ipnet/releases)
- [Changelog](https://github.com/krisprice/ipnet/blob/master/RELEASES.md)
- [Commits](https://github.com/krisprice/ipnet/commits)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
- dependency-name: sentry
  dependency-version: 0.49.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
- dependency-name: sentry-anyhow
  dependency-version: 0.49.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
- dependency-name: ipnet
  dependency-version: 2.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 00:47:15 +01:00
dependabot[bot] 237aec1df5
Bump the all-updates group across 1 directory with 5 updates (#189)
* Bump the all-updates group across 1 directory with 5 updates

Bumps the all-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [libc](https://github.com/rust-lang/libc) | `0.2.188` | `0.2.189` |
| [clap](https://github.com/clap-rs/clap) | `4.6.3` | `4.6.4` |
| [sentry](https://github.com/getsentry/sentry-rust) | `0.48.5` | `0.49.0` |
| [sentry-anyhow](https://github.com/getsentry/sentry-rust) | `0.48.5` | `0.49.0` |
| [serial_test](https://github.com/palfrey/serial_test) | `3.5.0` | `4.0.1` |



Updates `libc` from 0.2.188 to 0.2.189
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.188...0.2.189)

Updates `clap` from 4.6.3 to 4.6.4
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.3...clap_complete-v4.6.4)

Updates `sentry` from 0.48.5 to 0.49.0
- [Release notes](https://github.com/getsentry/sentry-rust/releases)
- [Changelog](https://github.com/getsentry/sentry-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-rust/compare/0.48.5...0.49.0)

Updates `sentry-anyhow` from 0.48.5 to 0.49.0
- [Release notes](https://github.com/getsentry/sentry-rust/releases)
- [Changelog](https://github.com/getsentry/sentry-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-rust/compare/0.48.5...0.49.0)

Updates `serial_test` from 3.5.0 to 4.0.1
- [Release notes](https://github.com/palfrey/serial_test/releases)
- [Commits](https://github.com/palfrey/serial_test/compare/v3.5.0...v4.0.1)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
- dependency-name: libc
  dependency-version: 0.2.189
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-updates
- dependency-name: sentry
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: sentry-anyhow
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-updates
- dependency-name: serial_test
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

* Work around Sentry 0.49 marking ClientOptions as #[non_exhaustive]

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikolay Edigaryev <edi@openai.com>
2026-08-04 14:50:20 +01:00
edi-oai cdc2a508aa
Introduce stateful "in TARGET" and "out TARGET" rules (#188) 2026-08-03 11:33:22 -04:00
edi-oai 54b419fd18
dhcp_snooper: accept DHCP replies destined to broadcast addresses (#186) 2026-07-30 18:27:12 +01:00
16 changed files with 2239 additions and 454 deletions

190
Cargo.lock generated
View File

@ -246,12 +246,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.4.0" version = "1.4.0"
@ -374,9 +368,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.3" version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -384,9 +378,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.2" version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -396,14 +390,14 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.3" version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 3.0.2",
] ]
[[package]] [[package]]
@ -895,25 +889,6 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64"
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.1.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hash32" name = "hash32"
version = "0.3.1" version = "0.3.1"
@ -1091,7 +1066,6 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"h2",
"http 1.1.0", "http 1.1.0",
"http-body", "http-body",
"httparse", "httparse",
@ -1102,22 +1076,6 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.1.0",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.6.0" version = "0.6.0"
@ -1335,9 +1293,9 @@ checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1"
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]] [[package]]
name = "iri-string" name = "iri-string"
@ -1452,9 +1410,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.188" version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
@ -2066,12 +2024,10 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http 1.1.0", "http 1.1.0",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
@ -2094,20 +2050,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.15",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -2149,19 +2091,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rustls-pemfile" name = "rustls-pemfile"
version = "2.2.0" version = "2.2.0"
@ -2180,17 +2109,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.21" version = "1.0.21"
@ -2258,9 +2176,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
[[package]] [[package]]
name = "sentry" name = "sentry"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d631477761f57c76148456e55e80e9a479ff3fa4c65b2b4a0c3acf1167fd4638" checksum = "63207365db50cb817402f0ec8097d6dad3d644ef3fffd6ba30c75b3077e514da"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases",
"httpdate", "httpdate",
@ -2271,6 +2189,7 @@ dependencies = [
"sentry-contexts", "sentry-contexts",
"sentry-core", "sentry-core",
"sentry-debug-images", "sentry-debug-images",
"sentry-log",
"sentry-panic", "sentry-panic",
"sentry-tracing", "sentry-tracing",
"tokio", "tokio",
@ -2279,9 +2198,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-actix" name = "sentry-actix"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "119ede4e37790ec04e8a14073c8414f0a4b2648402856c0563495a9a5cf54d57" checksum = "4a0eb1ed18478fb48db007aeaa672a3de176055357feb768eee0c3471802d0ce"
dependencies = [ dependencies = [
"actix-http", "actix-http",
"actix-web", "actix-web",
@ -2292,9 +2211,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-anyhow" name = "sentry-anyhow"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f73466a403f4da78c7e576049d6044b31d2b16dfcc26b51705f318ed74b804" checksum = "6265521f1b724f709bc07747ecb01c5289b974cb70b348026df01780280fc136"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"sentry-backtrace", "sentry-backtrace",
@ -2303,9 +2222,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-backtrace" name = "sentry-backtrace"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "448b0981fbde6cdc9eb087ba3dc01035a253fef9a0d9e79aaf198fc26acb2e64" checksum = "e3bcc2497c2327998146207b7600599ef7592233920f4d4e1d41ddeeba0e4210"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"regex", "regex",
@ -2314,9 +2233,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-contexts" name = "sentry-contexts"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e909d02170ba6d1dc5ebd05bef7999280f5d960e3eaee5d1a18d88d41b334" checksum = "1cb04cba225b38f59d08f9e3c29ab7259d9cc94fbb2c46d613a51cb0a4a7ee05"
dependencies = [ dependencies = [
"hostname", "hostname",
"libc", "libc",
@ -2328,9 +2247,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-core" name = "sentry-core"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecf0b1a4a4e9ec88395b52e6fa4868b95564c1fa96b26a0606f5f6288a0f7149" checksum = "48759bc392fb5e3b36b4efcb485f51074c0ba8479711cd5c8cf79e86f9f75d41"
dependencies = [ dependencies = [
"rand 0.9.4", "rand 0.9.4",
"sentry-types", "sentry-types",
@ -2341,19 +2260,30 @@ dependencies = [
[[package]] [[package]]
name = "sentry-debug-images" name = "sentry-debug-images"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81d7749c57fc78ed52134889e8121da874065bc40da788d5798cce0f8c19f15" checksum = "0a5bd325059b70b21ca6e42b0f2409821272dddc1cf37923de37269af55389b5"
dependencies = [ dependencies = [
"findshlibs", "findshlibs",
"sentry-core", "sentry-core",
] ]
[[package]] [[package]]
name = "sentry-panic" name = "sentry-log"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7d93d6ecb55d2251c5fc084c55c03a2bc68904918d76117e602c999e92f00f" checksum = "f9553a2f0f75bc8550137ebc753e8d2161af63ff780ca59983935f8df8f01655"
dependencies = [
"bitflags 2.9.4",
"log",
"sentry-core",
]
[[package]]
name = "sentry-panic"
version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a685d22f672b1562ebeff3f2affceb5960595648cc936dae73da3e1d6a55fbd1"
dependencies = [ dependencies = [
"sentry-backtrace", "sentry-backtrace",
"sentry-core", "sentry-core",
@ -2361,9 +2291,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-tracing" name = "sentry-tracing"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58d26379236c4ef97eaf081bca3c7bf0ef6f06b3aa881eca2ee8e8dbc923e5b8" checksum = "8aa7d8e0db4cccddbac01ddabd5344ad03b7c2f954b3ff850cecb1d9cf5d4758"
dependencies = [ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"sentry-backtrace", "sentry-backtrace",
@ -2374,9 +2304,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-types" name = "sentry-types"
version = "0.48.5" version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2239099d47e76857b0825a182ecdb90159add7aa1097b60247c6e7ca6bf49c6a" checksum = "fab146d15a30ab4897a95fd15d6a038b8d7fbf2661c5f8b13738ce8df7a095b7"
dependencies = [ dependencies = [
"debugid", "debugid",
"hex", "hex",
@ -2446,9 +2376,9 @@ dependencies = [
[[package]] [[package]]
name = "serial_test" name = "serial_test"
version = "3.5.0" version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11"
dependencies = [ dependencies = [
"futures-executor", "futures-executor",
"futures-util", "futures-util",
@ -2460,13 +2390,13 @@ dependencies = [
[[package]] [[package]]
name = "serial_test_derive" name = "serial_test_derive"
version = "3.5.0" version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 3.0.2",
] ]
[[package]] [[package]]
@ -2611,12 +2541,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -2827,16 +2751,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.15" version = "0.7.15"
@ -2998,12 +2912,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "ureq" name = "ureq"
version = "3.0.12" version = "3.0.12"

View File

@ -32,7 +32,7 @@ prefix-trie = "0"
ipnet = "2" ipnet = "2"
oslog = "0.2.0" oslog = "0.2.0"
log = "0.4.29" log = "0.4.29"
serial_test = "3" serial_test = "4"
coarsetime = "0.1.37" coarsetime = "0.1.37"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@ -30,6 +30,33 @@ And assumes that:
...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. ...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.
### Stateful flow authorization
Stateful `in`/`out` rules use a bounded authorization cache that records the
direction and exact transport tuple of policy-approved flows, allowing matching
return traffic without treating it as a new flow and thus requiring a separate
policy entry. It is not a complete TCP connection tracker: endpoint transport
stacks remain responsible for validating sequence numbers, receive windows,
resets, and application-level traffic.
This cache deliberately favors security, bounded resource use, and a simple
implementation over availability. Only policy-authorized initiator traffic
renews an entry; return traffic does not. Softnet does not maintain fairness
quotas, eviction heuristics, or complete TCP lifecycle state.
A packet admitted by a stateful rule is denied when Softnet cannot represent its
flow, including when the cache is full. High flow churn, long idle connections,
or ambiguous retransmissions may therefore interrupt networking and require the
affected VM to reconnect.
For TCP, a bare TCP SYN on an existing tuple is deliberately returned to policy
because Softnet cannot distinguish a retransmission from tuple reuse without
tracking TCP sequence state. If authorized, it may replace the tuple's previous
cache lifetime; this can reduce availability but cannot grant traffic that
policy did not permit.
For ICMP, stateful flow authorization supports only echo requests and replies.
## Installing ## Installing
For proper functioning, Softnet binary requires two things: For proper functioning, Softnet binary requires two things:
@ -46,7 +73,7 @@ Softnet is started and managed automatically by Tart if `--net-softnet` flag is
### Dynamic network policy ### Dynamic network policy
Softnet can update the running VM's IPv4 egress policy without restarting the VM. Pass a connected Unix stream socket as `--control-fd` to enable a newline-delimited [JSON-RPC 2.0](https://www.jsonrpc.org/specification) control channel. The socket is duplex and must be separate from `--vm-fd`, which carries VM packets. Softnet can update the running VM's IPv4 policy without restarting the VM. Pass a connected Unix stream socket as `--control-fd` to enable a newline-delimited [JSON-RPC 2.0](https://www.jsonrpc.org/specification) control channel. The socket is duplex and must be separate from `--vm-fd`, which carries VM packets.
The supported methods are `softnet.policy.get` and `softnet.policy.set`. A complete policy update looks like this (each request and response occupies one line): The supported methods are `softnet.policy.get` and `softnet.policy.set`. A complete policy update looks like this (each request and response occupies one line):
@ -55,6 +82,8 @@ The supported methods are `softnet.policy.get` and `softnet.policy.set`. A compl
{"jsonrpc":"2.0","id":"42","result":{"allow":["10.0.0.0/8","@host"],"block":["0.0.0.0/0"],"ruleCount":3}} {"jsonrpc":"2.0","id":"42","result":{"allow":["10.0.0.0/8","@host"],"block":["0.0.0.0/0"],"ruleCount":3}}
``` ```
Every request must include a non-null string (at most 256 bytes) or non-negative integer `id`; notifications are rejected so policy changes always have an acknowledgment. Policy updates are atomic: all targets are parsed and a new prefix map is built before the active policy changes. Longest-prefix matching and block precedence for identical prefixes are preserved. Targets are normalized and deduplicated. A policy update may contain at most 4096 combined allow/block targets, and a request frame may not exceed 1 MiB. Every request must include a non-null string (at most 256 bytes) or non-negative integer `id`; notifications are rejected so policy changes always have an acknowledgment. Policy updates are atomic: all rules are parsed and a new prefix map is built before the active policy changes. Longest-prefix matching and block precedence for identical rules are preserved. Rules are normalized and deduplicated. A policy update may contain at most 4096 combined allow/block rules, and a request frame may not exceed 1 MiB.
Use `block=["0.0.0.0/0"]` with specific allow targets for a default-deny policy. Closing the control socket leaves the last accepted policy active. When the normalized allow or block policy changes, Softnet clears the flow table so the new policy applies to established flows immediately. This may interrupt active connections. Repeating the same normalized policy is a no-op and preserves the flow table.
Use `block=["0.0.0.0/0"]` with specific allow rules for a default-deny egress policy. Closing the control socket leaves the last accepted policy active.

View File

@ -1,18 +1,20 @@
use dhcproto::Decodable; use dhcproto::Decodable;
use dhcproto::v4::{DhcpOption, MessageType, OptionCode}; use dhcproto::v4::{DhcpOption, HType, Message, MessageType, Opcode, OptionCode};
use smoltcp::wire::Ipv4Address; use smoltcp::wire::Ipv4Address;
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
#[derive(Default)] #[derive(Default)]
pub struct DhcpSnooper { pub struct DhcpSnooper {
vm_mac_address: [u8; 6],
vm_lease: Option<Lease>, vm_lease: Option<Lease>,
uncertainty_duration: Duration, uncertainty_duration: Duration,
} }
impl DhcpSnooper { impl DhcpSnooper {
pub fn new(uncertainty_duration: Duration) -> Self { pub fn new(uncertainty_duration: Duration, vm_mac_address: [u8; 6]) -> Self {
DhcpSnooper { DhcpSnooper {
vm_mac_address,
uncertainty_duration, uncertainty_duration,
..Default::default() ..Default::default()
} }
@ -26,6 +28,14 @@ impl DhcpSnooper {
Err(_) => return, 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() { match message.opts().msg_type() {
Some(MessageType::Ack) => { Some(MessageType::Ack) => {
let lease_time = match message.opts().get(OptionCode::AddressLeaseTime) { let lease_time = match message.opts().get(OptionCode::AddressLeaseTime) {
@ -63,6 +73,11 @@ impl DhcpSnooper {
&self.vm_lease &self.vm_lease
} }
pub(crate) fn address_and_dns_ips(&self) -> Option<(Ipv4Address, HashSet<Ipv4Address>)> {
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 { pub fn valid_dns_target(&self, addr: &Ipv4Address) -> bool {
if let Some(lease) = &self.vm_lease { if let Some(lease) = &self.vm_lease {
return lease.dns_ips.contains(addr); return lease.dns_ips.contains(addr);
@ -96,7 +111,72 @@ impl Lease {
coarsetime::Instant::recent() < self.valid_until coarsetime::Instant::recent() < self.valid_until
} }
pub fn valid_ip_source(&self, address: Ipv4Address) -> bool { pub fn is_valid_for(&self, address: Ipv4Address) -> bool {
self.address == address && self.valid() 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());
}
}

View File

@ -1,6 +1,5 @@
use super::{Action, Target}; use super::{Rule, Rules};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use ipnet::Ipv4Net;
use jsonrpsee_types::{ use jsonrpsee_types::{
ErrorObjectOwned, Id, Request, Response, ResponsePayload, ErrorObjectOwned, Id, Request, Response, ResponsePayload,
error::{ error::{
@ -8,7 +7,6 @@ use jsonrpsee_types::{
METHOD_NOT_FOUND_CODE as METHOD_NOT_FOUND, PARSE_ERROR_CODE as PARSE_ERROR, METHOD_NOT_FOUND_CODE as METHOD_NOT_FOUND, PARSE_ERROR_CODE as PARSE_ERROR,
}, },
}; };
use prefix_trie::PrefixMap;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use smoltcp::wire::Ipv4Address; use smoltcp::wire::Ipv4Address;
@ -20,26 +18,26 @@ use std::os::unix::net::UnixStream;
const MAX_REQUEST_BYTES: usize = 1024 * 1024; const MAX_REQUEST_BYTES: usize = 1024 * 1024;
const MAX_PENDING_RESPONSE_BYTES: usize = 4 * MAX_REQUEST_BYTES; const MAX_PENDING_RESPONSE_BYTES: usize = 4 * MAX_REQUEST_BYTES;
const MAX_TARGETS: usize = 4096; const MAX_RULES: usize = 4096;
const MAX_IDENTIFIER_BYTES: usize = 256; const MAX_IDENTIFIER_BYTES: usize = 256;
const MAX_SERVICE_BYTES: usize = MAX_REQUEST_BYTES; const MAX_SERVICE_BYTES: usize = MAX_REQUEST_BYTES;
pub(super) struct Policy { pub(super) struct Policy {
allow: Vec<Target>, allow: Vec<Rule>,
block: Vec<Target>, block: Vec<Rule>,
gateway_ip: Ipv4Address, gateway_ip: Ipv4Address,
} }
struct PolicyUpdate { struct PolicyUpdate {
rules: PrefixMap<Ipv4Net, Action>, rules: Rules,
allow: Vec<Target>, allow: Vec<Rule>,
block: Vec<Target>, block: Vec<Rule>,
} }
impl Policy { impl Policy {
pub(super) fn new(gateway_ip: Ipv4Address, allow: Vec<Target>, block: Vec<Target>) -> Self { pub(super) fn new(gateway_ip: Ipv4Address, allow: Vec<Rule>, block: Vec<Rule>) -> Self {
let allow = normalize_targets(allow); let allow = normalize_rules(allow);
let block = normalize_targets(block); let block = normalize_rules(block);
Policy { Policy {
allow, allow,
@ -53,16 +51,16 @@ impl Policy {
allow: Vec<String>, allow: Vec<String>,
block: Vec<String>, block: Vec<String>,
) -> std::result::Result<PolicyUpdate, ErrorObjectOwned> { ) -> std::result::Result<PolicyUpdate, ErrorObjectOwned> {
if allow.len() + block.len() > MAX_TARGETS { if allow.len() + block.len() > MAX_RULES {
return Err(rpc_error( return Err(rpc_error(
INVALID_PARAMS, INVALID_PARAMS,
format!("allow and block may contain at most {MAX_TARGETS} targets combined"), format!("allow and block may contain at most {MAX_RULES} rules combined"),
)); ));
} }
let allow = parse_targets(allow)?; let allow = parse_rules(allow)?;
let block = parse_targets(block)?; let block = parse_rules(block)?;
let rules = build_rules(self.gateway_ip, &allow, &block); let rules = Rules::new(self.gateway_ip, &allow, &block);
Ok(PolicyUpdate { Ok(PolicyUpdate {
rules, rules,
@ -71,12 +69,15 @@ impl Policy {
}) })
} }
fn apply(&mut self, update: PolicyUpdate) -> PrefixMap<Ipv4Net, Action> { fn apply(&mut self, update: PolicyUpdate) -> Option<Rules> {
// Build and validate everything before updating any active state. The packet filter // Build and validate everything before updating any active state. The packet filter
// observes either the old PrefixMap or the complete new one. // observes either the old rule set or the complete new one.
let changed = self.allow != update.allow || self.block != update.block;
self.allow = update.allow; self.allow = update.allow;
self.block = update.block; self.block = update.block;
update.rules
changed.then_some(update.rules)
} }
fn result(&self, rule_count: usize) -> Value { fn result(&self, rule_count: usize) -> Value {
@ -90,78 +91,34 @@ impl PolicyUpdate {
} }
} }
fn policy_result(allow: &[Target], block: &[Target], rule_count: usize) -> Value { fn policy_result(allow: &[Rule], block: &[Rule], rule_count: usize) -> Value {
json!({ json!({
"allow": allow.iter().map(target_string).collect::<Vec<_>>(), "allow": allow.iter().map(ToString::to_string).collect::<Vec<_>>(),
"block": block.iter().map(target_string).collect::<Vec<_>>(), "block": block.iter().map(ToString::to_string).collect::<Vec<_>>(),
"ruleCount": rule_count, "ruleCount": rule_count,
}) })
} }
fn parse_targets(targets: Vec<String>) -> std::result::Result<Vec<Target>, ErrorObjectOwned> { fn parse_rules(rules: Vec<String>) -> std::result::Result<Vec<Rule>, ErrorObjectOwned> {
let mut parsed = Vec::with_capacity(targets.len()); let mut parsed = Vec::with_capacity(rules.len());
for target in targets { for rule in rules {
let parsed_target = target.parse().map_err(|_| { let parsed_rule = rule.parse().map_err(|_| {
rpc_error( rpc_error(
INVALID_PARAMS, INVALID_PARAMS,
format!("invalid target {target:?}: expected an IPv4 CIDR or @host"), format!("invalid rule {rule:?}: expected TARGET, \"in TARGET\", or \"out TARGET\""),
) )
})?; })?;
parsed.push(parsed_target); parsed.push(parsed_rule);
} }
Ok(normalize_targets(parsed)) Ok(normalize_rules(parsed))
} }
fn normalize_targets(targets: Vec<Target>) -> Vec<Target> { pub(super) fn normalize_rules(mut rules: Vec<Rule>) -> Vec<Rule> {
let mut targets = targets rules.iter_mut().for_each(|rule| *rule = rule.normalized());
.into_iter() rules.sort_by_key(ToString::to_string);
.map(|target| match target { rules.dedup();
Target::Prefix(prefix) => Target::Prefix(prefix.trunc()),
Target::Host => Target::Host,
})
.collect::<Vec<_>>();
targets.sort_by_key(target_string);
targets.dedup();
targets
}
fn target_string(target: &Target) -> String {
match target {
Target::Prefix(prefix) => prefix.to_string(),
Target::Host => "@host".to_string(),
}
}
fn build_rules(
gateway_ip: Ipv4Address,
allow: &[Target],
block: &[Target],
) -> PrefixMap<Ipv4Net, Action> {
let mut rules = PrefixMap::new();
for target in allow {
let prefix = match target {
Target::Prefix(prefix) => *prefix,
Target::Host => gateway_ip.into(),
};
rules.insert(prefix, Action::Allow);
}
// SECURITY: blocking rules must always take precedence over allowing rules when prefixes
// are identical, including @host and an explicit prefix for the gateway address.
for target in block {
let prefix = match target {
Target::Prefix(prefix) => *prefix,
Target::Host => gateway_ip.into(),
};
rules.insert(prefix, Action::Block);
}
rules rules
} }
@ -173,14 +130,15 @@ pub(super) struct Control {
output_offset: usize, output_offset: usize,
discarding_input: bool, discarding_input: bool,
input_closed: bool, input_closed: bool,
policy_changed: bool,
} }
impl Control { impl Control {
pub(super) fn new( pub(super) fn new(
control_fd: RawFd, control_fd: RawFd,
gateway_ip: Ipv4Address, gateway_ip: Ipv4Address,
allow: Vec<Target>, allow: Vec<Rule>,
block: Vec<Target>, block: Vec<Rule>,
) -> Result<Self> { ) -> Result<Self> {
let control_fd = duplicate_control_fd(control_fd)?; let control_fd = duplicate_control_fd(control_fd)?;
@ -196,10 +154,11 @@ impl Control {
output_offset: 0, output_offset: 0,
discarding_input: false, discarding_input: false,
input_closed: false, input_closed: false,
policy_changed: false,
}) })
} }
pub(super) fn service(&mut self, rules: &mut PrefixMap<Ipv4Net, Action>) -> Result<bool> { pub(super) fn service(&mut self, rules: &mut Rules) -> Result<bool> {
if !self.flush()? { if !self.flush()? {
return Ok(false); return Ok(false);
} }
@ -262,7 +221,12 @@ impl Control {
.context("failed to shut down the control socket") .context("failed to shut down the control socket")
} }
fn process_input(&mut self, rules: &mut PrefixMap<Ipv4Net, Action>) -> Result<bool> { /// Returns whether the policy changed and clears the change flag.
pub(super) fn policy_changed(&mut self) -> bool {
std::mem::take(&mut self.policy_changed)
}
fn process_input(&mut self, rules: &mut Rules) -> Result<bool> {
loop { loop {
if self.discarding_input { if self.discarding_input {
if let Some(newline) = self.input.iter().position(|byte| *byte == b'\n') { if let Some(newline) = self.input.iter().position(|byte| *byte == b'\n') {
@ -311,11 +275,14 @@ impl Control {
continue; continue;
} }
let (response, update) = handle_request(&self.policy, rules.len(), &line[..newline]); let (response, update) = handle_request(&self.policy, rules, &line[..newline]);
self.enqueue(response)?; self.enqueue(response)?;
if let Some(update) = update { if let Some(update) = update
*rules = self.policy.apply(update); && let Some(updated_rules) = self.policy.apply(update)
{
*rules = updated_rules;
self.policy_changed = true;
} }
if !self.flush()? { if !self.flush()? {
@ -384,11 +351,7 @@ struct SetParams {
block: Vec<String>, block: Vec<String>,
} }
fn handle_request( fn handle_request(policy: &Policy, rules: &Rules, line: &[u8]) -> (Value, Option<PolicyUpdate>) {
policy: &Policy,
rule_count: usize,
line: &[u8],
) -> (Value, Option<PolicyUpdate>) {
let value = match serde_json::from_slice::<Value>(line) { let value = match serde_json::from_slice::<Value>(line) {
Ok(value) => value, Ok(value) => value,
Err(_) => { Err(_) => {
@ -425,7 +388,7 @@ fn handle_request(
"softnet.policy.get does not accept parameters", "softnet.policy.get does not accept parameters",
)) ))
} else { } else {
Ok(policy.result(rule_count)) Ok(policy.result(rules.len()))
} }
} }
"softnet.policy.set" => { "softnet.policy.set" => {
@ -571,11 +534,9 @@ fn validate_control_fd(control_fd: RawFd) -> Result<()> {
mod tests { mod tests {
use super::{ use super::{
Control, INVALID_PARAMS, INVALID_REQUEST, MAX_PENDING_RESPONSE_BYTES, MAX_REQUEST_BYTES, Control, INVALID_PARAMS, INVALID_REQUEST, MAX_PENDING_RESPONSE_BYTES, MAX_REQUEST_BYTES,
MAX_TARGETS, METHOD_NOT_FOUND, PARSE_ERROR, Policy, build_rules, handle_request, MAX_RULES, METHOD_NOT_FOUND, PARSE_ERROR, Policy, handle_request,
}; };
use crate::proxy::{Action, Target}; use crate::proxy::{Direction, PolicyDecision, Rule, Rules};
use ipnet::Ipv4Net;
use prefix_trie::PrefixMap;
use serde_json::{Value, json}; use serde_json::{Value, json};
use smoltcp::wire::Ipv4Address; use smoltcp::wire::Ipv4Address;
use std::fs::File; use std::fs::File;
@ -583,12 +544,11 @@ mod tests {
use std::net::{Shutdown, TcpListener}; use std::net::{Shutdown, TcpListener};
use std::os::fd::{AsRawFd, RawFd}; use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::net::{UnixDatagram, UnixStream}; use std::os::unix::net::{UnixDatagram, UnixStream};
use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
struct TestPolicy { struct TestPolicy {
state: Policy, state: Policy,
rules: PrefixMap<Ipv4Net, Action>, rules: Rules,
} }
impl TestPolicy { impl TestPolicy {
@ -605,20 +565,17 @@ mod tests {
} }
} }
fn targets(targets: &[&str]) -> Vec<Target> { fn rules(rules: &[&str]) -> Vec<Rule> {
targets rules.iter().map(|rule| rule.parse().unwrap()).collect()
.iter()
.map(|target| target.parse().unwrap())
.collect()
} }
fn policy(allow: &[&str], block: &[&str]) -> TestPolicy { fn policy(allow: &[&str], block: &[&str]) -> TestPolicy {
let gateway_ip = Ipv4Address::new(192, 168, 64, 1); let gateway_ip = Ipv4Address::new(192, 168, 64, 1);
let allow = targets(allow); let allow = rules(allow);
let block = targets(block); let block = rules(block);
TestPolicy { TestPolicy {
rules: build_rules(gateway_ip, &allow, &block), rules: Rules::new(gateway_ip, &allow, &block),
state: Policy::new(gateway_ip, allow, block), state: Policy::new(gateway_ip, allow, block),
} }
} }
@ -637,9 +594,11 @@ mod tests {
} }
fn raw_request(policy: &mut TestPolicy, line: &[u8]) -> Value { fn raw_request(policy: &mut TestPolicy, line: &[u8]) -> Value {
let (response, update) = handle_request(&policy.state, policy.rules.len(), line); let (response, update) = handle_request(&policy.state, &policy.rules, line);
if let Some(update) = update { if let Some(update) = update
policy.rules = policy.state.apply(update); && let Some(rules) = policy.state.apply(update)
{
policy.rules = rules;
} }
response response
@ -684,19 +643,21 @@ mod tests {
assert_eq!(response["result"]["ruleCount"], 2); assert_eq!(response["result"]["ruleCount"], 2);
assert_eq!( assert_eq!(
policy.rules.get(&Ipv4Net::from_str("10.0.0.0/8").unwrap()), policy
Some(&Action::Block) .rules
.policy_decision(Ipv4Address::new(10, 0, 0, 1), Direction::Out),
Some(PolicyDecision::Block)
); );
assert_eq!( assert_eq!(
policy policy
.rules .rules
.get(&Ipv4Net::from_str("192.168.64.1/32").unwrap()), .policy_decision(Ipv4Address::new(192, 168, 64, 1), Direction::Out),
Some(&Action::Block) Some(PolicyDecision::Block)
); );
} }
#[test] #[test]
fn set_normalizes_targets() { fn set_normalizes_rules() {
let mut policy = policy(&[], &[]); let mut policy = policy(&[], &[]);
let first = request( let first = request(
@ -722,7 +683,41 @@ mod tests {
} }
#[test] #[test]
fn invalid_targets_and_limits_leave_policy_unchanged() { fn set_supports_directional_rules_and_counts_logical_rules() {
let mut policy = policy(&[], &[]);
let response = request(
&mut policy,
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "softnet.policy.set",
"params": {
"allow": ["in 10.1.2.3/8", "out 10.0.0.0/8"],
"block": ["in 10.0.0.0/8"]
}
}),
);
assert_eq!(
response["result"]["allow"],
json!(["in 10.0.0.0/8", "out 10.0.0.0/8"])
);
assert_eq!(response["result"]["block"], json!(["in 10.0.0.0/8"]));
assert_eq!(response["result"]["ruleCount"], 2);
let target = Ipv4Address::new(10, 1, 2, 3);
assert_eq!(
policy.rules.policy_decision(target, Direction::In),
Some(PolicyDecision::Block)
);
assert_eq!(
policy.rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn invalid_rules_and_limits_leave_policy_unchanged() {
let mut policy = policy(&["@host"], &["0.0.0.0/0"]); let mut policy = policy(&["@host"], &["0.0.0.0/0"]);
let before = policy.result(); let before = policy.result();
@ -738,14 +733,14 @@ mod tests {
assert_eq!(invalid["error"]["code"], INVALID_PARAMS); assert_eq!(invalid["error"]["code"], INVALID_PARAMS);
assert_eq!(policy.result(), before); assert_eq!(policy.result(), before);
let targets = vec!["10.0.0.0/8"; MAX_TARGETS + 1]; let rules = vec!["10.0.0.0/8"; MAX_RULES + 1];
let too_many = request( let too_many = request(
&mut policy, &mut policy,
json!({ json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 2, "id": 2,
"method": "softnet.policy.set", "method": "softnet.policy.set",
"params": {"allow": targets, "block": []} "params": {"allow": rules, "block": []}
}), }),
); );
assert_eq!(too_many["error"]["code"], INVALID_PARAMS); assert_eq!(too_many["error"]["code"], INVALID_PARAMS);
@ -879,7 +874,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1))) .set_read_timeout(Some(Duration::from_secs(1)))
.unwrap(); .unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
client client
.write_all( .write_all(
@ -903,7 +898,7 @@ mod tests {
assert_eq!(lines[0]["id"], 1); assert_eq!(lines[0]["id"], 1);
assert_eq!(lines[1]["id"], 2); assert_eq!(lines[1]["id"], 2);
assert_eq!(lines[1]["result"]["allow"], json!(["@host"])); assert_eq!(lines[1]["result"]["allow"], json!(["@host"]));
assert_eq!(control.policy.allow, vec![Target::Host]); assert_eq!(control.policy.allow, vec!["@host".parse().unwrap()]);
let before = control.policy.result(rules.len()); let before = control.policy.result(rules.len());
drop(client); drop(client);
@ -911,6 +906,32 @@ mod tests {
assert_eq!(control.policy.result(rules.len()), before); assert_eq!(control.policy.result(rules.len()), before);
} }
#[test]
fn policy_change_detection_uses_normalized_policy() {
let (mut client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = Rules::default();
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"out 10.1.2.3/8\"],\"block\":[\"in 10.0.0.0/8\",\"out 10.0.0.0/8\"]}}\n")
.unwrap();
assert!(control.service(&mut rules).unwrap());
assert!(control.policy_changed());
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"out 10.0.0.0/8\"],\"block\":[\"in 10.0.0.0/8\",\"out 10.0.0.0/8\"]}}\n")
.unwrap();
assert!(control.service(&mut rules).unwrap());
assert!(!control.policy_changed());
client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[],\"block\":[\"in 10.0.0.0/8\",\"out 10.0.0.0/8\"]}}\n")
.unwrap();
assert!(control.service(&mut rules).unwrap());
assert!(control.policy_changed());
assert!(control.policy.allow.is_empty());
}
#[test] #[test]
fn write_side_eof_flushes_the_final_policy_response() { fn write_side_eof_flushes_the_final_policy_response() {
let (mut client, server) = UnixStream::pair().unwrap(); let (mut client, server) = UnixStream::pair().unwrap();
@ -918,7 +939,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1))) .set_read_timeout(Some(Duration::from_secs(1)))
.unwrap(); .unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
client client
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"10.0.0.0/8\"],\"block\":[]}}\n") .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"softnet.policy.set\",\"params\":{\"allow\":[\"10.0.0.0/8\"],\"block\":[]}}\n")
@ -942,7 +963,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1))) .set_read_timeout(Some(Duration::from_secs(1)))
.unwrap(); .unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
control.input = vec![b'x'; MAX_REQUEST_BYTES + 1]; control.input = vec![b'x'; MAX_REQUEST_BYTES + 1];
control.process_input(&mut rules).unwrap(); control.process_input(&mut rules).unwrap();
@ -973,7 +994,7 @@ mod tests {
.set_read_timeout(Some(Duration::from_secs(1))) .set_read_timeout(Some(Duration::from_secs(1)))
.unwrap(); .unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
let before = control.policy.result(rules.len()); let before = control.policy.result(rules.len());
client client
@ -993,7 +1014,7 @@ mod tests {
let response = serde_json::from_slice::<Value>(&response[..n - 1]).unwrap(); let response = serde_json::from_slice::<Value>(&response[..n - 1]).unwrap();
assert_eq!(response["id"], 1); assert_eq!(response["id"], 1);
assert_eq!(response["result"]["allow"], json!(["@host"])); assert_eq!(response["result"]["allow"], json!(["@host"]));
assert_eq!(control.policy.allow, vec![Target::Host]); assert_eq!(control.policy.allow, vec!["@host".parse().unwrap()]);
} }
#[test] #[test]
@ -1026,8 +1047,8 @@ mod tests {
fn pipelined_policy_responses_stop_before_queue_overflow() { fn pipelined_policy_responses_stop_before_queue_overflow() {
let (_client, server) = UnixStream::pair().unwrap(); let (_client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
let allow = (0..MAX_TARGETS) let allow = (0..MAX_RULES)
.map(|index| format!("10.{}.{}.0/24", index / 256, index % 256)) .map(|index| format!("10.{}.{}.0/24", index / 256, index % 256))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut input = serde_json::to_vec(&json!({ let mut input = serde_json::to_vec(&json!({
@ -1048,7 +1069,7 @@ mod tests {
control.input = input; control.input = input;
assert!(control.process_input(&mut rules).unwrap()); assert!(control.process_input(&mut rules).unwrap());
assert_eq!(rules.len(), MAX_TARGETS); assert_eq!(rules.len(), MAX_RULES);
assert!(!control.input.is_empty()); assert!(!control.input.is_empty());
assert!(!control.output.is_empty()); assert!(!control.output.is_empty());
assert!(control.output.len() - control.output_offset <= MAX_PENDING_RESPONSE_BYTES); assert!(control.output.len() - control.output_offset <= MAX_PENDING_RESPONSE_BYTES);
@ -1058,7 +1079,7 @@ mod tests {
fn response_queue_overflow_does_not_apply_a_policy_update() { fn response_queue_overflow_does_not_apply_a_policy_update() {
let (_client, server) = UnixStream::pair().unwrap(); let (_client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
let before = control.policy.result(rules.len()); let before = control.policy.result(rules.len());
control.output = vec![b'x'; MAX_PENDING_RESPONSE_BYTES]; control.output = vec![b'x'; MAX_PENDING_RESPONSE_BYTES];
@ -1077,7 +1098,7 @@ mod tests {
fn response_backpressure_stops_consuming_policy_updates() { fn response_backpressure_stops_consuming_policy_updates() {
let (mut client, server) = UnixStream::pair().unwrap(); let (mut client, server) = UnixStream::pair().unwrap();
let mut control = control(server.as_raw_fd()).unwrap(); let mut control = control(server.as_raw_fd()).unwrap();
let mut rules = PrefixMap::new(); let mut rules = Rules::default();
let before = control.policy.result(rules.len()); let before = control.policy.result(rules.len());
control.output = vec![b'x'; MAX_REQUEST_BYTES]; control.output = vec![b'x'; MAX_REQUEST_BYTES];

173
lib/proxy/flows/icmp.rs Normal file
View File

@ -0,0 +1,173 @@
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
use coarsetime::{Duration, Instant};
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, Ipv4Address, Ipv4Packet};
const ECHO_TIMEOUT: Duration = Duration::from_secs(30);
impl FlowTable {
pub(super) fn inspect_icmp(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
let Ok(icmp) = Icmpv4Packet::new_checked(ipv4_pkt.payload()) else {
return FlowMatch::Denied;
};
if !icmp.verify_checksum() {
return FlowMatch::Denied;
}
match (icmp.msg_type(), icmp.msg_code()) {
(Icmpv4Message::EchoRequest, 0) => {
self.inspect_echo(ipv4_pkt, direction, icmp.echo_ident(), true, now)
}
(Icmpv4Message::EchoReply, 0) => {
self.inspect_echo(ipv4_pkt, direction, icmp.echo_ident(), false, now)
}
// Other ICMP messages follow normal source policy: treating errors that quote
// tracked tuples as RELATED requires validation beyond this exact-tuple table
//
// Potential degradation of PMTU discovery and traceroute is an accepted tradeoff.
_ => FlowMatch::Untracked,
}
}
fn inspect_echo(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
ident: u16,
is_request: bool,
now: Instant,
) -> FlowMatch {
// We need to preserve the request direction so opposite-direction echo flows remain distinct
let initiating_direction = if is_request {
direction
} else {
direction.opposite()
};
let key = FlowKey::icmp_echo(
ipv4_pkt.src_addr(),
ipv4_pkt.dst_addr(),
direction,
initiating_direction,
ident,
);
if let Some(matched) = self.match_existing_flow(key, direction, now, ECHO_TIMEOUT) {
return matched;
}
if is_request {
FlowMatch::candidate(key, initiating_direction, now, ECHO_TIMEOUT)
} else {
// Only replies matching an admitted request are tracked
FlowMatch::Untracked
}
}
}
impl FlowKey {
fn icmp_echo(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
direction: FlowDirection,
initiating_direction: FlowDirection,
ident: u16,
) -> Self {
let (host_addr, vm_addr) = direction.host_vm_pair(src_addr, dst_addr);
Self::IcmpEcho {
host_addr,
vm_addr,
ident,
initiating_direction,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{HOST, VM, inspect_from_host, inspect_from_vm, ipv4_packet};
use super::super::{FlowMatch, FlowTable};
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Address};
#[test]
fn echo_request_and_reply_are_tracked_in_both_directions() {
let mut tracker = FlowTable::new();
let vm_request = echo_packet(VM, HOST, Icmpv4Message::EchoRequest, 7, 1);
let FlowMatch::Candidate(pending) = inspect_from_vm(&mut tracker, &vm_request) else {
panic!("expected a new flow");
};
assert!(tracker.commit(pending));
let host_reply = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 7, 1);
assert!(matches!(
inspect_from_host(&mut tracker, &host_reply),
FlowMatch::Allowed
));
let wrong_ident = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 8, 1);
assert!(matches!(
inspect_from_host(&mut tracker, &wrong_ident),
FlowMatch::Untracked
));
// The opposite direction is a distinct flow even with the same identifier.
let host_request = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &host_request) else {
panic!("expected a new flow");
};
assert!(tracker.commit(pending));
let vm_reply = echo_packet(VM, HOST, Icmpv4Message::EchoReply, 7, 1);
assert!(matches!(
inspect_from_vm(&mut tracker, &vm_reply),
FlowMatch::Allowed
));
}
#[test]
fn invalid_echo_is_denied_and_unsolicited_echo_is_untracked() {
let mut tracker = FlowTable::new();
let unsolicited = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 7, 1);
assert!(matches!(
inspect_from_host(&mut tracker, &unsolicited),
FlowMatch::Untracked
));
let mut wrong_code = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
wrong_code[21] = 1;
Icmpv4Packet::new_unchecked(&mut wrong_code[20..]).fill_checksum();
assert!(matches!(
inspect_from_host(&mut tracker, &wrong_code),
FlowMatch::Untracked
));
let mut bad_checksum = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
bad_checksum[27] ^= 1;
assert!(matches!(
inspect_from_host(&mut tracker, &bad_checksum),
FlowMatch::Denied
));
}
fn echo_packet(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
message: Icmpv4Message,
ident: u16,
sequence: u16,
) -> Vec<u8> {
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Icmp, 8);
let mut icmp = Icmpv4Packet::new_unchecked(&mut bytes[20..]);
icmp.set_msg_type(message);
icmp.set_msg_code(0);
icmp.set_echo_ident(ident);
icmp.set_echo_seq_no(sequence);
icmp.fill_checksum();
bytes
}
}

394
lib/proxy/flows/mod.rs Normal file
View File

@ -0,0 +1,394 @@
mod icmp;
mod tcp;
mod udp;
use coarsetime::{Duration, Instant};
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet};
use std::collections::HashMap;
const MAX_FLOWS: usize = 32_768;
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
/// A bounded cache of exact-tuple permissions for return traffic.
///
/// The table establishes only the direction in which a flow was authorized.
/// This is deliberately not a TCP state machine. Endpoint transport stacks remain
/// responsible for TCP handshakes, teardown, sequence numbers, and receive windows,
/// as well as ICMP echo sequences.
///
/// Capacity is intentionally enforced with one fail-closed limit per VM.
/// Exhaustion may deny further networking for that VM; this availability
/// tradeoff is accepted to avoid fairness quotas, admission scans, and eviction.
#[derive(Debug)]
pub(crate) struct FlowTable {
flows: HashMap<FlowKey, Flow>,
next_sweep: Instant,
}
/// A proposed flow-table entry awaiting policy authorization.
pub(crate) struct PendingFlow {
key: FlowKey,
flow: Flow,
}
/// Metadata stored for an admitted flow.
#[derive(Clone, Copy, Debug)]
struct Flow {
initiating_direction: FlowDirection,
expires_at: Instant,
}
/// The result of classifying a packet against the flow table.
pub(crate) enum FlowMatch {
/// The packet is exact-tuple return traffic for an admitted flow.
Allowed,
/// Policy must authorize this initiator-side packet before committing it.
Candidate(PendingFlow),
/// The packet is malformed or violates a transport invariant that we deliberately enforce.
Denied,
/// The flow table has no stateful interpretation for this packet.
Untracked,
}
impl FlowMatch {
fn candidate(
key: FlowKey,
initiating_direction: FlowDirection,
now: Instant,
timeout: Duration,
) -> Self {
Self::Candidate(PendingFlow {
key,
flow: Flow {
initiating_direction,
expires_at: now + timeout,
},
})
}
}
/// The canonical identity used to index a flow-table entry.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum FlowKey {
IcmpEcho {
host_addr: Ipv4Address,
vm_addr: Ipv4Address,
ident: u16,
initiating_direction: FlowDirection,
},
Tcp {
host_addr: Ipv4Address,
host_port: u16,
vm_addr: Ipv4Address,
vm_port: u16,
},
Udp {
host_addr: Ipv4Address,
host_port: u16,
vm_addr: Ipv4Address,
vm_port: u16,
},
}
/// A packet direction across the hostVM boundary.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(super) enum FlowDirection {
FromHost,
FromVm,
}
impl FlowTable {
pub(crate) fn new() -> Self {
Self {
flows: HashMap::new(),
next_sweep: Instant::recent() + SWEEP_INTERVAL,
}
}
pub(super) fn inspect(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
) -> FlowMatch {
self.inspect_at(ipv4_pkt, direction, Instant::recent())
}
fn inspect_at(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
// Perform lazy garbage collection of expired flow entries
self.sweep_if_due(now);
// Later fragments do not contain enough transport information to bind
// them to an exact flow, so packet policy must decide
if ipv4_pkt.more_frags() || ipv4_pkt.frag_offset() != 0 {
return FlowMatch::Untracked;
}
match ipv4_pkt.next_header() {
IpProtocol::Icmp => self.inspect_icmp(ipv4_pkt, direction, now),
IpProtocol::Tcp => self.inspect_tcp(ipv4_pkt, direction, now),
IpProtocol::Udp => self.inspect_udp(ipv4_pkt, direction, now),
_ => FlowMatch::Untracked,
}
}
pub(crate) fn commit(&mut self, pending: PendingFlow) -> bool {
let PendingFlow { key, flow } = pending;
// Reject new entries when full while allowing existing entries to be updated
if !self.flows.contains_key(&key) && self.flows.len() >= MAX_FLOWS {
return false;
}
self.flows.insert(key, flow);
true
}
pub(crate) fn clear(&mut self) {
self.flows.clear();
}
fn sweep_if_due(&mut self, now: Instant) {
if now < self.next_sweep {
return;
}
self.flows.retain(|_, flow| now < flow.expires_at);
self.next_sweep = now + SWEEP_INTERVAL;
}
fn match_existing_flow(
&self,
key: FlowKey,
direction: FlowDirection,
now: Instant,
timeout: Duration,
) -> Option<FlowMatch> {
let existing = self.flows.get(&key).copied()?;
// Treat expired entries as missing even before the next sweep
if now >= existing.expires_at {
return None;
}
if direction == existing.initiating_direction {
Some(FlowMatch::candidate(
key,
existing.initiating_direction,
now,
timeout,
))
} else {
Some(FlowMatch::Allowed)
}
}
}
impl FlowDirection {
fn host_vm_pair<T>(self, src: T, dst: T) -> (T, T) {
match self {
Self::FromHost => (src, dst),
Self::FromVm => (dst, src),
}
}
fn opposite(self) -> Self {
match self {
Self::FromHost => Self::FromVm,
Self::FromVm => Self::FromHost,
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::{HOST, VM, inspect_from_host, ipv4_packet, udp_packet};
use super::{
Duration, FlowDirection, FlowMatch, FlowTable, Instant, MAX_FLOWS, SWEEP_INTERVAL,
};
use smoltcp::wire::{IpProtocol, Ipv4Packet};
#[test]
fn all_ipv4_fragments_are_untracked() {
for (more_fragments, offset) in [(true, 0), (false, 8)] {
let mut bytes = udp_packet(HOST, 50_000, VM, 53);
let mut packet = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
packet.set_frag_offset(offset);
packet.set_more_frags(more_fragments);
let mut tracker = FlowTable::new();
assert!(matches!(
inspect_from_host(&mut tracker, &bytes),
FlowMatch::Untracked
));
}
}
#[test]
fn expired_tuple_is_not_matched_before_the_next_sweep() {
let mut tracker = FlowTable::new();
let datagram = udp_packet(HOST, 50_000, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &datagram) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
let now = Instant::recent();
let flow = tracker.flows.values_mut().next().unwrap();
flow.expires_at = now - Duration::from_secs(1);
tracker.next_sweep = now + SWEEP_INTERVAL;
let reply = udp_packet(VM, 53, HOST, 50_000);
assert!(matches!(
tracker.inspect_at(
&Ipv4Packet::new_checked(reply.as_slice()).unwrap(),
FlowDirection::FromVm,
now,
),
FlowMatch::Candidate(_)
));
}
#[test]
fn global_limit_rejects_new_tuple_but_allows_replacement() {
let mut tracker = FlowTable::new();
for port in 10_000..10_000 + MAX_FLOWS as u16 {
let datagram = udp_packet(HOST, port, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &datagram) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
}
let over_limit = udp_packet(HOST, 50_000, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &over_limit) else {
panic!("expected a candidate");
};
assert!(!tracker.commit(pending));
let replacement = udp_packet(HOST, 10_000, VM, 53);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &replacement) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
}
#[test]
fn unsupported_protocol_is_untracked() {
let bytes = ipv4_packet(HOST, VM, IpProtocol::Unknown(253), 0);
let mut tracker = FlowTable::new();
assert!(matches!(
inspect_from_host(&mut tracker, &bytes),
FlowMatch::Untracked
));
}
}
#[cfg(test)]
mod test_support {
use super::{FlowDirection, FlowMatch, FlowTable};
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, TcpPacket, UdpPacket};
pub(super) const HOST: Ipv4Address = Ipv4Address::new(192, 168, 64, 1);
pub(super) const VM: Ipv4Address = Ipv4Address::new(192, 168, 64, 2);
pub(super) fn inspect_from_host(tracker: &mut FlowTable, bytes: &[u8]) -> FlowMatch {
let packet = Ipv4Packet::new_checked(bytes).unwrap();
tracker.inspect(&packet, FlowDirection::FromHost)
}
pub(super) fn inspect_from_vm(tracker: &mut FlowTable, bytes: &[u8]) -> FlowMatch {
let packet = Ipv4Packet::new_checked(bytes).unwrap();
tracker.inspect(&packet, FlowDirection::FromVm)
}
#[derive(Clone, Copy)]
pub(super) struct TcpFlags {
syn: bool,
ack: bool,
rst: bool,
fin: bool,
}
impl TcpFlags {
pub(super) const SYN: Self = Self {
syn: true,
ack: false,
rst: false,
fin: false,
};
pub(super) const SYN_ACK: Self = Self {
syn: true,
ack: true,
rst: false,
fin: false,
};
pub(super) const ACK: Self = Self {
syn: false,
ack: true,
rst: false,
fin: false,
};
}
pub(super) fn tcp_packet(
src_addr: Ipv4Address,
src_port: u16,
dst_addr: Ipv4Address,
dst_port: u16,
flags: TcpFlags,
) -> Vec<u8> {
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Tcp, 20);
let mut tcp = TcpPacket::new_unchecked(&mut bytes[20..]);
tcp.set_src_port(src_port);
tcp.set_dst_port(dst_port);
tcp.set_header_len(20);
tcp.set_syn(flags.syn);
tcp.set_ack(flags.ack);
tcp.set_rst(flags.rst);
tcp.set_fin(flags.fin);
tcp.set_window_len(u16::MAX);
bytes
}
pub(super) fn udp_packet(
src_addr: Ipv4Address,
src_port: u16,
dst_addr: Ipv4Address,
dst_port: u16,
) -> Vec<u8> {
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Udp, 8);
let mut udp = UdpPacket::new_unchecked(&mut bytes[20..]);
udp.set_src_port(src_port);
udp.set_dst_port(dst_port);
udp.set_len(8);
bytes
}
pub(super) fn ipv4_packet(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
protocol: IpProtocol,
payload_len: usize,
) -> Vec<u8> {
let mut bytes = vec![0; 20 + payload_len];
let total_len = bytes.len() as u16;
let mut ipv4 = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
ipv4.set_version(4);
ipv4.set_header_len(20);
ipv4.set_total_len(total_len);
ipv4.set_next_header(protocol);
ipv4.set_src_addr(src_addr);
ipv4.set_dst_addr(dst_addr);
bytes
}
}

156
lib/proxy/flows/tcp.rs Normal file
View File

@ -0,0 +1,156 @@
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
use coarsetime::{Duration, Instant};
use smoltcp::wire::{Ipv4Address, Ipv4Packet, TcpPacket};
const SYN_TIMEOUT: Duration = Duration::from_secs(60);
const TCP_TIMEOUT: Duration = Duration::from_secs(5 * 60);
impl FlowTable {
pub(super) fn inspect_tcp(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
let Ok(tcp) = TcpPacket::new_checked(ipv4_pkt.payload()) else {
return FlowMatch::Denied;
};
if tcp.src_port() == 0 || tcp.dst_port() == 0 {
return FlowMatch::Denied;
}
let key = FlowKey::tcp(
direction,
(ipv4_pkt.src_addr(), tcp.src_port()),
(ipv4_pkt.dst_addr(), tcp.dst_port()),
);
// A bare SYN may be either a retransmission or a new connection reusing
// the tuple in either direction. Always return it to policy, and do not
// replace an existing permission until the candidate is committed.
let is_initial_syn = tcp.syn() && !tcp.ack() && !tcp.fin() && !tcp.rst();
if is_initial_syn {
return FlowMatch::candidate(key, direction, now, SYN_TIMEOUT);
}
self.match_existing_flow(key, direction, now, TCP_TIMEOUT)
.unwrap_or(FlowMatch::Untracked)
}
}
impl FlowKey {
fn tcp(direction: FlowDirection, src: (Ipv4Address, u16), dst: (Ipv4Address, u16)) -> Self {
let ((host_addr, host_port), (vm_addr, vm_port)) = direction.host_vm_pair(src, dst);
Self::Tcp {
host_addr,
host_port,
vm_addr,
vm_port,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{
HOST, TcpFlags, VM, inspect_from_host, inspect_from_vm, tcp_packet,
};
use super::super::{FlowMatch, FlowTable};
fn admit_host_syn(tracker: &mut FlowTable) {
let syn = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN);
let FlowMatch::Candidate(pending) = inspect_from_host(tracker, &syn) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
}
#[test]
fn admitted_syn_creates_an_exact_reverse_permission() {
let mut tracker = FlowTable::new();
admit_host_syn(&mut tracker);
let original_expiry = tracker.flows.values().next().unwrap().expires_at;
let reply = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::ACK);
assert!(matches!(
inspect_from_vm(&mut tracker, &reply),
FlowMatch::Allowed
));
assert_eq!(
tracker.flows.values().next().unwrap().expires_at,
original_expiry,
"return traffic must not refresh a tuple"
);
let initiator_data = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::ACK);
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &initiator_data) else {
panic!("expected an initiator candidate");
};
assert_eq!(
tracker.flows.values().next().unwrap().expires_at,
original_expiry,
"inspection must not refresh a tuple before policy accepts it"
);
assert!(tracker.commit(pending));
assert!(tracker.flows.values().next().unwrap().expires_at > original_expiry);
}
#[test]
fn new_tuple_requires_a_clean_syn() {
let mut tracker = FlowTable::new();
for flags in [TcpFlags::ACK, TcpFlags::SYN_ACK] {
let packet = tcp_packet(HOST, 49_152, VM, 22, flags);
assert!(matches!(
inspect_from_host(&mut tracker, &packet),
FlowMatch::Untracked
));
}
let syn = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN);
assert!(matches!(
inspect_from_host(&mut tracker, &syn),
FlowMatch::Candidate(_)
));
}
#[test]
fn reply_requires_the_exact_tuple() {
let mut tracker = FlowTable::new();
admit_host_syn(&mut tracker);
let wrong_port = tcp_packet(VM, 22, HOST, 49_153, TcpFlags::ACK);
assert!(matches!(
inspect_from_vm(&mut tracker, &wrong_port),
FlowMatch::Untracked
));
}
#[test]
fn reverse_bare_syn_is_a_new_policy_candidate() {
let mut tracker = FlowTable::new();
admit_host_syn(&mut tracker);
let reverse_syn = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::SYN);
let FlowMatch::Candidate(pending) = inspect_from_vm(&mut tracker, &reverse_syn) else {
panic!("reverse SYN must return to policy");
};
// Inspection is provisional: a policy rejection leaves the admitted
// flow untouched.
let old_flow_reply = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::ACK);
assert!(matches!(
inspect_from_vm(&mut tracker, &old_flow_reply),
FlowMatch::Allowed
));
assert!(tracker.commit(pending));
let reverse_reply = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN_ACK);
assert!(matches!(
inspect_from_host(&mut tracker, &reverse_reply),
FlowMatch::Allowed
));
}
}

92
lib/proxy/flows/udp.rs Normal file
View File

@ -0,0 +1,92 @@
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
use coarsetime::{Duration, Instant};
use smoltcp::wire::{Ipv4Address, Ipv4Packet, UdpPacket};
const UDP_TIMEOUT: Duration = Duration::from_secs(30);
impl FlowTable {
pub(super) fn inspect_udp(
&mut self,
ipv4_pkt: &Ipv4Packet<&[u8]>,
direction: FlowDirection,
now: Instant,
) -> FlowMatch {
let Ok(udp) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
return FlowMatch::Denied;
};
if udp.dst_port() == 0 {
return FlowMatch::Denied;
}
// Deliberate policy, not merely a packet-format validation:
// RFC 8085, §5.1 says UDP senders SHOULD NOT use source port zero.
//
// We enforce this recommendation to retain source-port entropy
// and protection against off-path packet injection.
if udp.src_port() == 0 {
return FlowMatch::Denied;
}
let key = FlowKey::udp(
direction,
(ipv4_pkt.src_addr(), udp.src_port()),
(ipv4_pkt.dst_addr(), udp.dst_port()),
);
if let Some(matched) = self.match_existing_flow(key, direction, now, UDP_TIMEOUT) {
return matched;
}
FlowMatch::candidate(key, direction, now, UDP_TIMEOUT)
}
}
impl FlowKey {
fn udp(direction: FlowDirection, src: (Ipv4Address, u16), dst: (Ipv4Address, u16)) -> Self {
let ((host_addr, host_port), (vm_addr, vm_port)) = direction.host_vm_pair(src, dst);
Self::Udp {
host_addr,
host_port,
vm_addr,
vm_port,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::{HOST, VM, inspect_from_host, inspect_from_vm, udp_packet};
use super::super::{FlowMatch, FlowTable};
#[test]
fn rejects_zero_source_port_per_rfc_8085() {
let mut tracker = FlowTable::new();
let datagram = udp_packet(HOST, 0, VM, 5353);
assert!(matches!(
inspect_from_host(&mut tracker, &datagram),
FlowMatch::Denied
));
}
#[test]
fn udp_reply_requires_an_exact_admitted_request() {
let mut tracker = FlowTable::new();
let request = udp_packet(HOST, 50_000, VM, 5353);
let reply = udp_packet(VM, 5353, HOST, 50_000);
assert!(matches!(
inspect_from_vm(&mut tracker, &reply),
FlowMatch::Candidate(_)
));
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &request) else {
panic!("expected a candidate");
};
assert!(tracker.commit(pending));
assert!(matches!(
inspect_from_vm(&mut tracker, &reply),
FlowMatch::Allowed
));
}
}

View File

@ -1,7 +1,12 @@
use crate::proxy::Proxy; use crate::dhcp_snooper::message_matches_bootp_client;
use crate::proxy::flows::{FlowDirection, FlowMatch};
use crate::proxy::udp_packet_helper::UdpPacketHelper; use crate::proxy::udp_packet_helper::UdpPacketHelper;
use crate::proxy::{Direction, PolicyDecision, Proxy};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, UdpPacket}; use dhcproto::Decodable;
use dhcproto::v4::Opcode;
use smoltcp::phy::ChecksumCapabilities;
use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, Ipv4Repr, UdpPacket};
impl Proxy<'_> { impl Proxy<'_> {
pub(crate) fn process_frame_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Result<()> { pub(crate) fn process_frame_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Result<()> {
@ -12,7 +17,7 @@ impl Proxy<'_> {
// Snoop bootpd(8) replies from the host to // Snoop bootpd(8) replies from the host to
// figure out the IP assigned to the VM // figure out the IP assigned to the VM
if frame.dst_addr() == self.vm_mac_address { if frame.dst_addr() == self.vm_mac_address || frame.dst_addr().is_broadcast() {
self.snoop(frame); self.snoop(frame);
} }
@ -39,11 +44,71 @@ impl Proxy<'_> {
fn allowed_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { fn allowed_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
match frame.ethertype() { match frame.ethertype() {
EthernetProtocol::Arp => Some(()), EthernetProtocol::Arp => Some(()),
EthernetProtocol::Ipv4 => Some(()), EthernetProtocol::Ipv4 => {
let ipv4_pkt = Ipv4Packet::new_unchecked(frame.payload());
Ipv4Repr::parse(&ipv4_pkt, &ChecksumCapabilities::ignored()).ok()?;
self.allowed_from_host_ipv4(&ipv4_pkt)
}
_ => None, _ => None,
} }
} }
pub(super) fn allowed_from_host_ipv4(&mut self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> Option<()> {
// Backwards compatibility with Softnet consumers that only use stateless rules
if self.flows.is_none() {
return Some(());
}
// DHCP is required to maintain the VM's lease and must bypass user-specified rules
if self.is_allowed_dhcp_response(ipv4_pkt) {
return Some(());
}
// 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()))
{
match self
.flows
.as_mut()?
.inspect(ipv4_pkt, FlowDirection::FromHost)
{
FlowMatch::Allowed => return Some(()),
FlowMatch::Denied => return None,
FlowMatch::Candidate(pending) => Some(pending),
FlowMatch::Untracked => None,
}
} else {
None
};
// The flow is either pending or untracked, evaluate it against inbound policy
match self
.rules
.policy_decision(ipv4_pkt.src_addr(), Direction::In)
{
// Return traffic was handled above; enforce explicit inbound blocks here
Some(PolicyDecision::Block) => None,
// Stateless policy is outbound-only; fail closed if this invariant is violated
Some(PolicyDecision::AllowStateless) => None,
// Untracked packets cannot satisfy stateful policy
Some(PolicyDecision::AllowStateful) => self.admit_with_tracking(pending?),
// No inbound rule matched, so allow by default. Track the flow when needed
// so its reply is not treated as a new outbound flow
None => {
self.admit_with_tracking_if_stateful(pending, ipv4_pkt.src_addr(), Direction::Out)
}
}
}
fn snoop(&mut self, frame: &EthernetFrame<&[u8]>) { fn snoop(&mut self, frame: &EthernetFrame<&[u8]>) {
if frame.ethertype() != EthernetProtocol::Ipv4 { if frame.ethertype() != EthernetProtocol::Ipv4 {
return; return;
@ -54,11 +119,7 @@ impl Proxy<'_> {
_ => return, _ => return,
}; };
if ipv4_pkt.src_addr() != self.host.gateway_ip { if !self.is_allowed_dhcp_response(&ipv4_pkt) {
return;
}
if ipv4_pkt.next_header() != smoltcp::wire::IpProtocol::Udp {
return; return;
} }
@ -67,10 +128,91 @@ impl Proxy<'_> {
Err(_) => return, Err(_) => return,
}; };
if !udp_pkt.is_dhcp_response() { let address_and_dns_ips_saved = self.dhcp_snooper.address_and_dns_ips();
return; 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 is_allowed_dhcp_response(&self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> bool {
if ipv4_pkt.src_addr() != self.host.gateway_ip
|| ipv4_pkt.next_header() != smoltcp::wire::IpProtocol::Udp
{
return false;
} }
self.dhcp_snooper.register_dhcp_reply(udp_pkt.payload()); let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
return false;
};
// Require the standard DHCP server and client ports
if !udp_pkt.is_dhcp_response() {
return false;
}
// 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 false;
};
message_matches_bootp_client(&message, Opcode::BootReply, self.vm_mac_address.0)
}
}
#[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<u8> {
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
} }
} }

View File

@ -1,7 +1,10 @@
mod control; mod control;
mod exposed_port; mod exposed_port;
mod flows;
mod host; mod host;
mod port_forwarder; mod port_forwarder;
mod rule;
mod rules;
mod udp_packet_helper; mod udp_packet_helper;
mod vm; mod vm;
@ -11,16 +14,17 @@ use crate::host::NetType;
use crate::poller::Poller; use crate::poller::Poller;
use crate::vm::VM; use crate::vm::VM;
use anyhow::Result; use anyhow::Result;
use control::Control; use control::{Control, normalize_rules};
pub use exposed_port::ExposedPort; pub use exposed_port::ExposedPort;
use flows::{FlowTable, PendingFlow};
use ipnet::Ipv4Net; use ipnet::Ipv4Net;
use mac_address::MacAddress; use mac_address::MacAddress;
use port_forwarder::PortForwarder; use port_forwarder::PortForwarder;
use prefix_trie::PrefixMap; pub use rule::{Direction, Rule, Target};
use smoltcp::wire::EthernetFrame; pub(crate) use rules::{PolicyDecision, Rules};
use smoltcp::wire::{EthernetFrame, Ipv4Address};
use std::io::ErrorKind; use std::io::ErrorKind;
use std::os::unix::io::{AsRawFd, RawFd}; use std::os::unix::io::{AsRawFd, RawFd};
use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use vmnet::Batch; use vmnet::Batch;
@ -30,50 +34,30 @@ pub struct Proxy<'proxy> {
poller: Poller<'proxy>, poller: Poller<'proxy>,
vm_mac_address: smoltcp::wire::EthernetAddress, vm_mac_address: smoltcp::wire::EthernetAddress,
dhcp_snooper: DhcpSnooper, dhcp_snooper: DhcpSnooper,
rules: PrefixMap<Ipv4Net, Action>, rules: Rules,
control: Option<Control>, control: Option<Control>,
flows: Option<FlowTable>,
enobufs_encountered: bool, enobufs_encountered: bool,
port_forwarder: PortForwarder, port_forwarder: PortForwarder,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Prefix(Ipv4Net),
Host,
}
impl FromStr for Target {
type Err = ipnet::AddrParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s == "@host" {
return Ok(Target::Host);
}
Ipv4Net::from_str(s).map(Target::Prefix)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Action {
Block,
Allow,
}
impl Proxy<'_> { impl Proxy<'_> {
pub fn new<'proxy>( pub fn new<'proxy>(
vm_fd: RawFd, vm_fd: RawFd,
vm_mac_address: MacAddress, vm_mac_address: MacAddress,
vm_net_type: NetType, vm_net_type: NetType,
allow: Vec<Target>, allow: Vec<Rule>,
block: Vec<Target>, block: Vec<Rule>,
exposed_ports: Vec<ExposedPort>, exposed_ports: Vec<ExposedPort>,
control_fd: Option<RawFd>, control_fd: Option<RawFd>,
) -> Result<Proxy<'proxy>> { ) -> Result<Proxy<'proxy>> {
let allow = normalize_rules(allow);
let block = normalize_rules(block);
let vm = VM::new(vm_fd)?; let vm = VM::new(vm_fd)?;
let host = Host::new( let host = Host::new(
vm_net_type, vm_net_type,
!allow.contains(&Target::Prefix(Ipv4Net::default())), !allow.contains(&Rule::Stateless(Target::Prefix(Ipv4Net::default()))),
)?; )?;
let poller_timeout = Duration::from_millis(100); let poller_timeout = Duration::from_millis(100);
let control = control_fd let control = control_fd
@ -88,38 +72,21 @@ impl Proxy<'_> {
poller_timeout, poller_timeout,
)?; )?;
// Craft packet filter rules let rules = Rules::new(host.gateway_ip, &allow, &block);
//
// SECURITY: blocking rules must always take precedence
// over allowing rules when prefixes are identical.
let mut rules = PrefixMap::new();
for allow_target in allow { // Any stateful rule enables flow inspection for the whole VM, including
let allow_prefix = match allow_target { // traffic admitted through implicit global, gateway, and DNS fallbacks
Target::Prefix(prefix) => prefix, let flows = rules.has_stateful().then(FlowTable::new);
Target::Host => host.gateway_ip.into(),
};
rules.insert(allow_prefix, Action::Allow);
}
for block_target in block {
let block_prefix = match block_target {
Target::Prefix(prefix) => prefix,
Target::Host => host.gateway_ip.into(),
};
rules.insert(block_prefix, Action::Block);
}
Ok(Proxy { Ok(Proxy {
vm, vm,
host, host,
poller, poller,
vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()), vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()),
dhcp_snooper: DhcpSnooper::new(poller_timeout), dhcp_snooper: DhcpSnooper::new(poller_timeout, vm_mac_address.bytes()),
rules, rules,
control, control,
flows,
enobufs_encountered: false, enobufs_encountered: false,
port_forwarder: PortForwarder::new(exposed_ports), port_forwarder: PortForwarder::new(exposed_ports),
}) })
@ -141,7 +108,12 @@ impl Proxy<'_> {
loop { loop {
let (vm_readable, host_readable, interrupt) = self.poller.wait()?; let (vm_readable, host_readable, interrupt) = self.poller.wait()?;
// Update coarse time for the DHCP snooper // kqueue does not report peer disconnects for Unix datagram sockets.
if !self.vm.is_connected()? {
return Ok(());
}
// Update coarse time for DHCP snooping and flows
coarsetime::Instant::update(); coarsetime::Instant::update();
// Service control on every wake (including timeouts) so a bounded read or a pending // Service control on every wake (including timeouts) so a bounded read or a pending
@ -177,7 +149,7 @@ impl Proxy<'_> {
loop { loop {
match self.vm.read(buf) { match self.vm.read(buf) {
Ok(n) => { Ok(n) => {
// Update coarse time for the DHCP snooper // Update coarse time for DHCP snooping and flows
coarsetime::Instant::update(); coarsetime::Instant::update();
if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) { if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) {
@ -205,7 +177,7 @@ impl Proxy<'_> {
loop { loop {
match self.host.read(batch, bufs) { match self.host.read(batch, bufs) {
Ok(pktcnt) => { Ok(pktcnt) => {
// Update coarse time for the DHCP snooper // Update coarse time for DHCP snooping and flows
coarsetime::Instant::update(); coarsetime::Instant::update();
for buf in batch.packet_sized_bufs(bufs).take(pktcnt) { for buf in batch.packet_sized_bufs(bufs).take(pktcnt) {
@ -240,6 +212,11 @@ impl Proxy<'_> {
} }
}; };
// Invalidate tracked flows whenever the policy changes
if control.policy_changed() {
self.flows = self.rules.has_stateful().then(FlowTable::new);
}
if keep_open { if keep_open {
return; return;
} }
@ -254,19 +231,44 @@ impl Proxy<'_> {
log::warn!("failed to shut down Softnet control socket: {err:#}"); log::warn!("failed to shut down Softnet control socket: {err:#}");
} }
} }
/// Commits the pending flow, rejecting the packet if the table cannot store it.
fn admit_with_tracking(&mut self, pending: PendingFlow) -> Option<()> {
self.flows.as_mut()?.commit(pending).then_some(())
}
/// Commits a pending flow for trackable packets; untracked packets proceed without one.
fn admit_with_tracking_if_trackable(&mut self, pending: Option<PendingFlow>) -> Option<()> {
match pending {
Some(pending) => self.admit_with_tracking(pending),
None => Some(()),
}
}
/// Commits a pending flow when the return-direction rule is stateful.
fn admit_with_tracking_if_stateful(
&mut self,
pending: Option<PendingFlow>,
peer_addr: Ipv4Address,
return_direction: Direction,
) -> Option<()> {
if self.rules.is_stateful(peer_addr, return_direction) {
self.admit_with_tracking_if_trackable(pending)
} else {
Some(())
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::NetType; use crate::NetType;
use crate::dhcp_snooper::Lease; use crate::dhcp_snooper::Lease;
use crate::proxy::{Action, Proxy}; use crate::proxy::Proxy;
use ipnet::Ipv4Net;
use mac_address::MacAddress; use mac_address::MacAddress;
use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair}; use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair};
use prefix_trie::PrefixMap;
use serial_test::serial; use serial_test::serial;
use smoltcp::wire::{Ipv4Address, Ipv4Packet}; use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, UdpPacket};
use std::collections::HashSet; use std::collections::HashSet;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::str::FromStr; use std::str::FromStr;
@ -276,57 +278,97 @@ mod tests {
#[serial] #[serial]
fn test_blocking_takes_precedence() { fn test_blocking_takes_precedence() {
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
let proxy = create_proxy(vm_ip, vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]); let mut proxy = create_proxy(vm_ip, vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]);
assert_eq!( assert_eq!(proxy.rules.len(), 1);
proxy.rules,
PrefixMap::<Ipv4Net, Action>::from_iter(vec![(
Ipv4Net::from_str("66.66.0.0/16").unwrap(),
Action::Block
),])
);
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "66.66.66.66").is_none()); assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "66.66.66.66").is_none());
} }
#[test] #[test]
#[serial] #[serial]
fn test_longest_prefix_match_wins() { fn test_longest_prefix_match_wins() {
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
let proxy = create_proxy(vm_ip, vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]); let mut proxy = create_proxy(vm_ip, vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]);
assert_eq!( assert_eq!(proxy.rules.len(), 2);
proxy.rules,
PrefixMap::<Ipv4Net, Action>::from_iter(vec![
(Ipv4Net::from_str("33.33.33.33/32").unwrap(), Action::Allow),
(Ipv4Net::from_str("33.33.33.0/24").unwrap(), Action::Block),
])
);
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "33.33.33.32").is_none()); assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.32").is_none());
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "33.33.33.33").is_some()); assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.33").is_some());
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "33.33.33.34").is_none()); assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.34").is_none());
} }
#[test] #[test]
#[serial] #[serial]
fn test_allow_host() { fn test_allow_host() {
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
let proxy = create_proxy(vm_ip, vec!["@host"], vec!["0.0.0.0/0"]); let mut proxy = create_proxy(vm_ip, vec!["@host"], vec!["0.0.0.0/0"]);
assert_eq!( assert_eq!(proxy.rules.len(), 2);
proxy.rules,
PrefixMap::from_iter(vec![
(proxy.host.gateway_ip.into(), Action::Allow),
(Ipv4Net::from_str("0.0.0.0/0").unwrap(), Action::Block),
])
);
// Access to global IPs should be disallowed because of --block=0.0.0.0/0 // Access to global IPs should be disallowed because of --block=0.0.0.0/0
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, "8.8.8.8").is_none()); assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "8.8.8.8").is_none());
// Despite the above, access to host IP address should be possible because of --allow=@host // Despite the above, access to host IP address should be possible because of --allow=@host
assert!(allowed_from_vm_ipv4(&proxy, vm_ip, &proxy.host.gateway_ip.to_string()).is_some()); let gateway_ip = proxy.host.gateway_ip.to_string();
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, &gateway_ip).is_some());
}
#[test]
#[serial]
fn test_bare_default_block_applies_in_both_directions_in_stateful_mode() {
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
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();
assert!(proxy.allowed_from_host_ipv4(&fallback_request).is_none());
let fallback_reply = udp_packet(vm_ip, 1_234, fallback_peer, 40_000);
let fallback_reply = Ipv4Packet::new_checked(fallback_reply.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(fallback_reply).is_none());
let allowed_request = udp_packet(explicitly_allowed_peer, 40_000, vm_ip, 1_234);
let allowed_request = Ipv4Packet::new_checked(allowed_request.as_slice()).unwrap();
assert!(proxy.allowed_from_host_ipv4(&allowed_request).is_some());
let allowed_reply = udp_packet(vm_ip, 1_234, explicitly_allowed_peer, 40_000);
let allowed_reply = Ipv4Packet::new_checked(allowed_reply.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(allowed_reply).is_some());
}
#[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 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();
assert!(proxy.allowed_from_host_ipv4(&request).is_some());
let reply = udp_packet(vm_ip, 1_234, peer, 40_000);
let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap();
assert!(proxy.allowed_from_vm_ipv4(reply).is_some());
}
#[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 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();
assert!(proxy.allowed_from_vm_ipv4(request).is_some());
let reply = udp_packet(peer, 40_000, vm_ip, 1_234);
let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap();
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>(vm_ip: Ipv4Address, allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> {
@ -345,11 +387,11 @@ mod tests {
NetType::Nat, NetType::Nat,
allow allow
.into_iter() .into_iter()
.map(|cidr| cidr.parse().unwrap()) .map(|value| value.parse().unwrap())
.collect(), .collect(),
block block
.into_iter() .into_iter()
.map(|cidr| cidr.parse().unwrap()) .map(|value| value.parse().unwrap())
.collect(), .collect(),
Vec::default(), Vec::default(),
None, None,
@ -365,7 +407,7 @@ mod tests {
proxy proxy
} }
fn allowed_from_vm_ipv4(proxy: &Proxy, src: Ipv4Address, dst: &str) -> Option<()> { fn allowed_from_vm_ipv4(proxy: &mut Proxy, src: Ipv4Address, dst: &str) -> Option<()> {
let mut buf = vec![0; 1500]; let mut buf = vec![0; 1500];
let mut ipv4_pkt_mut = Ipv4Packet::new_unchecked(&mut buf[..]); let mut ipv4_pkt_mut = Ipv4Packet::new_unchecked(&mut buf[..]);
@ -376,4 +418,26 @@ mod tests {
proxy.allowed_from_vm_ipv4(ipv4_pkt) proxy.allowed_from_vm_ipv4(ipv4_pkt)
} }
fn udp_packet(
src_addr: Ipv4Address,
src_port: u16,
dst_addr: Ipv4Address,
dst_port: u16,
) -> Vec<u8> {
let mut bytes = vec![0; 28];
let mut ipv4 = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
ipv4.set_version(4);
ipv4.set_header_len(20);
ipv4.set_total_len(28);
ipv4.set_next_header(IpProtocol::Udp);
ipv4.set_src_addr(src_addr);
ipv4.set_dst_addr(dst_addr);
let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut());
udp.set_src_port(src_port);
udp.set_dst_port(dst_port);
udp.set_len(8);
bytes
}
} }

158
lib/proxy/rule.rs Normal file
View File

@ -0,0 +1,158 @@
use ipnet::Ipv4Net;
use std::fmt::{self, Display, Formatter};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rule {
Stateless(Target),
Stateful {
direction: Direction,
target: Target,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Prefix(Ipv4Net),
Host,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
In,
Out,
}
impl Rule {
pub(super) fn normalized(self) -> Self {
match self {
Rule::Stateless(target) => Rule::Stateless(target.normalized()),
Rule::Stateful { direction, target } => Rule::Stateful {
direction,
target: target.normalized(),
},
}
}
}
impl FromStr for Rule {
type Err = ipnet::AddrParseError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let (direction, target) = if let Some(target) = input.strip_prefix("in ") {
(Direction::In, target)
} else if let Some(target) = input.strip_prefix("out ") {
(Direction::Out, target)
} else {
return input.parse().map(Rule::Stateless);
};
let target = target.trim_start_matches(' ').parse()?;
Ok(Rule::Stateful { direction, target })
}
}
impl Display for Rule {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Rule::Stateless(target) => Display::fmt(target, formatter),
Rule::Stateful { direction, target } => match direction {
Direction::In => write!(formatter, "in {target}"),
Direction::Out => write!(formatter, "out {target}"),
},
}
}
}
impl Target {
fn normalized(self) -> Self {
match self {
Target::Prefix(prefix) => Target::Prefix(prefix.trunc()),
Target::Host => Target::Host,
}
}
}
impl FromStr for Target {
type Err = ipnet::AddrParseError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input == "@host" {
Ok(Target::Host)
} else {
input.parse().map(Target::Prefix)
}
}
}
impl Display for Target {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Target::Prefix(prefix) => Display::fmt(prefix, formatter),
Target::Host => formatter.write_str("@host"),
}
}
}
#[cfg(test)]
mod tests {
use super::{Direction, Rule, Target};
use ipnet::Ipv4Net;
use std::str::FromStr;
#[test]
fn parses_stateless_target() {
assert_eq!(
"@host".parse::<Rule>().unwrap(),
Rule::Stateless(Target::Host)
);
}
#[test]
fn parses_stateful_directions() {
let private_network = Target::Prefix(Ipv4Net::from_str("10.0.0.0/8").unwrap());
assert_eq!(
"in @host".parse::<Rule>().unwrap(),
Rule::Stateful {
direction: Direction::In,
target: Target::Host,
}
);
assert_eq!(
"out 10.0.0.0/8".parse::<Rule>().unwrap(),
Rule::Stateful {
direction: Direction::Out,
target: private_network,
}
);
}
#[test]
fn displays_normalized_rules() {
for (input, expected) in [("in 10.1.2.3/8", "in 10.0.0.0/8"), ("@host", "@host")] {
assert_eq!(
input.parse::<Rule>().unwrap().normalized().to_string(),
expected
);
}
}
#[test]
fn rejects_invalid_rules() {
for input in [
"",
"from @host",
"in",
"out",
"in from @host",
"out to @host",
"infrom @host",
" in @host",
"in @host ",
"in\t@host",
] {
assert!(input.parse::<Rule>().is_err(), "{input:?} should fail");
}
}
}

279
lib/proxy/rules.rs Normal file
View File

@ -0,0 +1,279 @@
use super::{Direction, Rule, Target};
use ipnet::Ipv4Net;
use prefix_trie::PrefixMap;
use smoltcp::wire::Ipv4Address;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Action {
Block,
Allow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PolicyDecision {
Block,
AllowStateless,
AllowStateful,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Mode {
#[default]
Legacy,
Stateful,
}
#[derive(Default)]
pub(crate) struct Rules {
mode: Mode,
inbound: PrefixMap<Ipv4Net, Action>,
outbound: PrefixMap<Ipv4Net, Action>,
}
impl Rules {
pub(crate) fn new(host_address: Ipv4Address, allow: &[Rule], block: &[Rule]) -> Self {
// Preserve legacy behavior for bare-only policies. Once a directional rule
// is present, compile the whole policy using directional semantics.
let mode = if allow
.iter()
.chain(block)
.any(|rule| matches!(rule, Rule::Stateful { .. }))
{
Mode::Stateful
} else {
Mode::Legacy
};
let mut rules = Self {
mode,
..Self::default()
};
for &rule in allow {
rules.insert(rule, Action::Allow, host_address);
}
// SECURITY: blocking rules must always take precedence
// over allowing rules when the rules are identical.
for &rule in block {
rules.insert(rule, Action::Block, host_address);
}
rules
}
pub(crate) fn policy_decision(
&self,
address: Ipv4Address,
direction: Direction,
) -> Option<PolicyDecision> {
match (self.select(address, direction)?, self.mode) {
(Action::Block, _) => Some(PolicyDecision::Block),
(Action::Allow, Mode::Legacy) => Some(PolicyDecision::AllowStateless),
(Action::Allow, Mode::Stateful) => Some(PolicyDecision::AllowStateful),
}
}
pub(crate) fn is_stateful(&self, address: Ipv4Address, direction: Direction) -> bool {
self.mode == Mode::Stateful && self.select(address, direction).is_some()
}
pub(crate) fn len(&self) -> usize {
self.inbound.len() + self.outbound.len()
}
pub(crate) fn has_stateful(&self) -> bool {
self.mode == Mode::Stateful
}
fn select(&self, address: Ipv4Address, direction: Direction) -> Option<Action> {
let entries = match direction {
Direction::In => &self.inbound,
Direction::Out => &self.outbound,
};
entries
.get_lpm(&Ipv4Net::from(address))
.map(|(_, action)| *action)
}
fn insert(&mut self, rule: Rule, action: Action, host_address: Ipv4Address) {
match rule {
Rule::Stateless(target) => {
// Bare rules apply in both directions in stateful mode
if self.mode == Mode::Stateful {
self.insert_direction(Direction::In, target, action, host_address);
}
self.insert_direction(Direction::Out, target, action, host_address);
}
Rule::Stateful { direction, target } => {
self.insert_direction(direction, target, action, host_address);
}
}
}
fn insert_direction(
&mut self,
direction: Direction,
target: Target,
action: Action,
host_address: Ipv4Address,
) {
let prefix = match target {
Target::Prefix(prefix) => prefix,
Target::Host => host_address.into(),
};
let entries = match direction {
Direction::In => &mut self.inbound,
Direction::Out => &mut self.outbound,
};
entries.insert(prefix, action);
}
}
#[cfg(test)]
mod tests {
use super::{Action, Mode, PolicyDecision, Rules};
use crate::proxy::Direction;
use smoltcp::wire::Ipv4Address;
const HOST: Ipv4Address = Ipv4Address::new(192, 168, 64, 1);
fn stateful_rules() -> Rules {
Rules {
mode: Mode::Stateful,
..Rules::default()
}
}
#[test]
fn test_policy_precedence() {
let target = Ipv4Address::new(10, 0, 0, 1);
let mut rules = stateful_rules();
rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST);
rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Allow, HOST);
assert_eq!(
rules.policy_decision(target, Direction::In),
Some(PolicyDecision::AllowStateful)
);
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::Block)
);
rules.insert("10.0.0.1/32".parse().unwrap(), Action::Allow, HOST);
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn test_directional_rules_at_same_prefix_are_independent() {
let mut rules = stateful_rules();
for (target, action) in [
("in @host", Action::Allow),
("out @host", Action::Allow),
("in @host", Action::Block),
] {
rules.insert(target.parse().unwrap(), action, HOST);
}
assert_eq!(
rules.policy_decision(HOST, Direction::In),
Some(PolicyDecision::Block)
);
assert_eq!(
rules.policy_decision(HOST, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
assert_eq!(rules.len(), 2);
}
#[test]
fn test_stateless_rules_are_outbound_only() {
let target = Ipv4Address::new(10, 1, 2, 3);
let mut rules = Rules::default();
rules.insert("10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
assert!(rules.policy_decision(target, Direction::In).is_none());
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::Block)
);
}
#[test]
fn test_inbound_selection_uses_more_specific_bare_rule() {
let target = Ipv4Address::new(10, 1, 2, 3);
let mut rules = stateful_rules();
rules.insert("10.1.0.0/16".parse().unwrap(), Action::Allow, HOST);
rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
assert_eq!(
rules.policy_decision(target, Direction::In),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn test_block_wins_over_allow_at_same_outbound_prefix() {
let target = Ipv4Address::new(10, 1, 2, 3);
for (allow, block) in [
("10.0.0.0/8", "out 10.0.0.0/8"),
("out 10.0.0.0/8", "10.0.0.0/8"),
] {
let mut rules = stateful_rules();
rules.insert(allow.parse().unwrap(), Action::Allow, HOST);
rules.insert(block.parse().unwrap(), Action::Block, HOST);
assert_eq!(
rules.policy_decision(target, Direction::Out),
Some(PolicyDecision::Block)
);
}
}
#[test]
fn test_directional_rule_makes_bare_rules_stateful() {
let allow = "out @host".parse().unwrap();
let block = "0.0.0.0/0".parse().unwrap();
let rules = Rules::new(HOST, &[allow], &[block]);
assert_eq!(rules.len(), 3);
assert!(rules.has_stateful());
assert_eq!(
rules.policy_decision(HOST, Direction::In),
Some(PolicyDecision::Block)
);
assert_eq!(
rules.policy_decision(HOST, Direction::Out),
Some(PolicyDecision::AllowStateful)
);
}
#[test]
fn test_return_tracking_applies_to_all_rules_in_stateful_mode() {
let stateful_target = Ipv4Address::new(10, 1, 2, 3);
let stateless_target = Ipv4Address::new(192, 0, 2, 1);
let mut rules = stateful_rules();
rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST);
rules.insert("out 10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
assert!(rules.is_stateful(stateful_target, Direction::Out));
assert!(rules.is_stateful(stateless_target, Direction::Out));
assert!(rules.is_stateful(stateful_target, Direction::In));
rules.insert("10.0.0.0/8".parse().unwrap(), Action::Allow, HOST);
assert!(rules.is_stateful(stateful_target, Direction::Out));
}
}

View File

@ -1,14 +1,19 @@
use crate::dhcp_snooper::Lease; 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::udp_packet_helper::UdpPacketHelper;
use crate::proxy::{Action, Proxy}; use crate::proxy::{Direction, PolicyDecision, Proxy};
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use ipnet::Ipv4Net; use dhcproto::Decodable;
use dhcproto::v4::Opcode;
use smoltcp::phy::ChecksumCapabilities;
use smoltcp::wire::{ use smoltcp::wire::{
ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Packet, ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Address,
UdpPacket, Ipv4Packet, Ipv4Repr, UdpPacket,
}; };
const IPV4_HEADER_LEN_WITHOUT_OPTIONS: u8 = 20;
impl Proxy<'_> { impl Proxy<'_> {
pub(crate) fn process_frame_from_vm(&mut self, frame: EthernetFrame<&[u8]>) -> Result<()> { pub(crate) fn process_frame_from_vm(&mut self, frame: EthernetFrame<&[u8]>) -> Result<()> {
if self.allowed_from_vm(&frame).is_none() { if self.allowed_from_vm(&frame).is_none() {
@ -22,7 +27,7 @@ impl Proxy<'_> {
.context("failed to write to the host") .context("failed to write to the host")
} }
fn allowed_from_vm(&self, frame: &EthernetFrame<&[u8]>) -> Option<()> { fn allowed_from_vm(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
if frame.src_addr() != self.vm_mac_address { if frame.src_addr() != self.vm_mac_address {
return None; return None;
} }
@ -33,7 +38,14 @@ impl Proxy<'_> {
self.allowed_from_vm_arp(arp_pkt) self.allowed_from_vm_arp(arp_pkt)
} }
EthernetProtocol::Ipv4 => { EthernetProtocol::Ipv4 => {
let ipv4_pkt = Ipv4Packet::new_checked(frame.payload()).ok()?; let ipv4_pkt = Ipv4Packet::new_unchecked(frame.payload());
Ipv4Repr::parse(&ipv4_pkt, &ChecksumCapabilities::ignored()).ok()?;
// Reject IPv4 options because source routing could bypass destination-based policy
if ipv4_pkt.header_len() != IPV4_HEADER_LEN_WITHOUT_OPTIONS {
return None;
}
self.allowed_from_vm_ipv4(ipv4_pkt) self.allowed_from_vm_ipv4(ipv4_pkt)
} }
_ => None, _ => None,
@ -44,34 +56,65 @@ impl Proxy<'_> {
vm_arp_allowed(arp_pkt, self.vm_mac_address, self.dhcp_snooper.lease()) vm_arp_allowed(arp_pkt, self.vm_mac_address, self.dhcp_snooper.lease())
} }
pub(crate) fn allowed_from_vm_ipv4(&self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> { 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? // Is this packet coming from VM's IP address that we've learned from DHCP snooping?
if let Some(lease) = &self.dhcp_snooper.lease() if let Some(lease) = &self.dhcp_snooper.lease()
&& lease.valid_ip_source(ipv4_pkt.src_addr()) && 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(());
}
// Consult the flow table before evaluating outbound policy
// so established flows are not treated as new traffic
let pending = match self
.flows
.as_mut()
.map(|flows| flows.inspect(&ipv4_pkt, FlowDirection::FromVm))
.unwrap_or(FlowMatch::Untracked)
{
FlowMatch::Allowed => return Some(()),
FlowMatch::Denied => return None,
FlowMatch::Candidate(pending) => Some(pending),
FlowMatch::Untracked => None,
};
// The flow is either pending or untracked, evaluate it against outbound policy
let dst_addr = ipv4_pkt.dst_addr(); let dst_addr = ipv4_pkt.dst_addr();
// Filter traffic based on user-specified rules first match self.rules.policy_decision(dst_addr, Direction::Out) {
if !self.rules.is_empty() { // Return traffic was handled above; enforce explicit outbound blocks here
let dst_net = Ipv4Net::from(dst_addr); Some(PolicyDecision::Block) => return None,
if let Some((_, action)) = self.rules.get_lpm(&dst_net) { // Track statelessly allowed traffic only when needed so its reply is not
return match action { // treated as a new inbound flow
Action::Allow => Some(()), Some(PolicyDecision::AllowStateless) => {
Action::Block => None, return self.admit_with_tracking_if_stateful(pending, dst_addr, Direction::In);
};
} }
// Untracked packets cannot satisfy stateful policy
Some(PolicyDecision::AllowStateful) => return self.admit_with_tracking(pending?),
// No outbound rule matched; apply the built-in fallbacks below
None => {}
} }
// When no user-specified rules matched, simply allow all global traffic // When no user-specified rules matched, simply allow all global traffic
if ip_network::IpNetwork::from(dst_addr).is_global() { if ip_network::IpNetwork::from(dst_addr).is_global() {
return Some(()); return self.admit_with_tracking_if_trackable(pending);
} }
// Additionally, allow communication with the host, // Additionally, allow communication with the host,
// otherwise things like SSH to a VM won't work // otherwise things like SSH to a VM won't work
if ipv4_pkt.dst_addr() == self.host.gateway_ip { if dst_addr == self.host.gateway_ip {
return Some(()); return self.admit_with_tracking_if_trackable(pending);
} }
// Additionally, allow DNS requests to DNS-servers // Additionally, allow DNS requests to DNS-servers
@ -79,29 +122,73 @@ impl Proxy<'_> {
if ipv4_pkt.next_header() == IpProtocol::Udp { if ipv4_pkt.next_header() == IpProtocol::Udp {
let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?; let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?;
if udp_pkt.is_dns_request() if udp_pkt.is_dns_request() && self.dhcp_snooper.valid_dns_target(&dst_addr) {
&& self.dhcp_snooper.valid_dns_target(&ipv4_pkt.dst_addr()) return self.admit_with_tracking_if_trackable(pending);
{
return Some(());
} }
} }
} }
// Allow outgoing DHCP requests to broadcast addresses, // Allow outgoing DHCP requests to the bootpd(8) broadcast address,
// otherwise DHCP snooper will never be populated // otherwise DHCP snooper will never be populated
if ipv4_pkt.next_header() == IpProtocol::Udp { if is_allowed_dhcp_request(
let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?; &ipv4_pkt,
None,
// Allow DHCP communication with the bootpd(8) on host via broadcast address self.vm_mac_address,
if udp_pkt.is_dhcp_request() && ipv4_pkt.dst_addr().is_broadcast() { self.dhcp_snooper.lease(),
return Some(()); ) {
} return Some(());
} }
None None
} }
} }
fn is_allowed_dhcp_request(
ipv4_pkt: &Ipv4Packet<&[u8]>,
unicast_target: Option<Ipv4Address>,
vm_mac_address: smoltcp::wire::EthernetAddress,
lease: &Option<Lease>,
) -> 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();
// 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;
}
if ipv4_pkt.next_header() != IpProtocol::Udp {
return false;
}
let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
return false;
};
// Require the standard DHCP client and server ports
if !udp_pkt.is_dhcp_request() {
return false;
}
// 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( fn vm_arp_allowed(
arp_pkt: ArpPacket<&[u8]>, arp_pkt: ArpPacket<&[u8]>,
vm_mac_address: smoltcp::wire::EthernetAddress, vm_mac_address: smoltcp::wire::EthernetAddress,
@ -127,7 +214,7 @@ fn vm_arp_allowed(
} }
if let Some(lease) = lease { if let Some(lease) = lease {
if lease.valid_ip_source(source_protocol_addr) { if lease.is_valid_for(source_protocol_addr) {
return Some(()); return Some(());
} }
} else if source_protocol_addr.is_unspecified() { } else if source_protocol_addr.is_unspecified() {
@ -140,12 +227,42 @@ fn vm_arp_allowed(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::dhcp_snooper::Lease; use crate::dhcp_snooper::Lease;
use dhcproto::v4::{DhcpOption, Message, MessageType};
use dhcproto::{Encodable, Encoder};
use smoltcp::wire::{ use smoltcp::wire::{
ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, Ipv4Address, ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, IpProtocol,
Ipv4Address, Ipv4Packet, UdpPacket,
}; };
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; 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] #[test]
fn test_allowed_from_vm_arp_allows_unspecified_request_without_lease() { fn test_allowed_from_vm_arp_allows_unspecified_request_without_lease() {
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
@ -244,4 +361,55 @@ mod tests {
arp_pkt.set_target_protocol_addr(&vec![0; protocol_len as usize]); arp_pkt.set_target_protocol_addr(&vec![0; protocol_len as usize]);
buf buf
} }
fn allowed_dhcp_request(
src_addr: Ipv4Address,
dst_addr: Ipv4Address,
unicast_target: Option<Ipv4Address>,
chaddr: [u8; 6],
lease: &Option<Lease>,
) -> 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<u8> {
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
}
} }

View File

@ -26,6 +26,14 @@ impl VM {
pub fn read(&self, buf: &mut [u8]) -> std::io::Result<usize> { pub fn read(&self, buf: &mut [u8]) -> std::io::Result<usize> {
self.sock.recv(buf) self.sock.recv(buf)
} }
pub fn is_connected(&self) -> io::Result<bool> {
match self.sock.peer_addr() {
Ok(_) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotConnected => Ok(false),
Err(error) => Err(error),
}
}
} }
fn duplicate_vm_fd(vm_fd: RawFd) -> Result<RawFd> { fn duplicate_vm_fd(vm_fd: RawFd) -> Result<RawFd> {
@ -115,10 +123,12 @@ impl AsRawFd for VM {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::VM; use super::VM;
use polling::{Event, Events, PollMode, Poller};
use std::fs::File; use std::fs::File;
use std::net::UdpSocket; use std::net::UdpSocket;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::os::unix::net::{UnixDatagram, UnixStream}; use std::os::unix::net::{UnixDatagram, UnixStream};
use std::time::Duration;
#[test] #[test]
fn test_new_rejects_negative_fd() { fn test_new_rejects_negative_fd() {
@ -187,4 +197,62 @@ mod tests {
assert!(socket_fd_is_open); assert!(socket_fd_is_open);
} }
#[test]
fn test_connected_socket_has_peer() {
let (socket, _peer) = UnixDatagram::pair().unwrap();
let vm = VM::new(socket.as_raw_fd()).unwrap();
assert!(vm.is_connected().unwrap());
}
#[test]
fn test_disconnected_peer_is_detected_without_kqueue_event() {
let (socket, peer) = UnixDatagram::pair().unwrap();
let vm = VM::new(socket.as_raw_fd()).unwrap();
let poller = Poller::new().unwrap();
let mut events = Events::new();
unsafe {
poller
.add_with_mode(vm.as_raw_fd(), Event::readable(0), PollMode::Edge)
.unwrap();
}
drop(peer);
poller
.wait(&mut events, Some(Duration::from_millis(20)))
.unwrap();
assert!(events.is_empty());
assert!(!vm.is_connected().unwrap());
}
#[test]
fn test_disconnected_peer_is_detected_when_another_socket_wakes_kqueue() {
let (socket, peer) = UnixDatagram::pair().unwrap();
let (host, host_peer) = UnixDatagram::pair().unwrap();
let vm = VM::new(socket.as_raw_fd()).unwrap();
let poller = Poller::new().unwrap();
let mut events = Events::new();
unsafe {
poller
.add_with_mode(vm.as_raw_fd(), Event::readable(0), PollMode::Edge)
.unwrap();
poller
.add_with_mode(host.as_raw_fd(), Event::readable(1), PollMode::Edge)
.unwrap();
}
drop(peer);
host_peer.send(&[1]).unwrap();
poller
.wait(&mut events, Some(Duration::from_millis(20)))
.unwrap();
assert!(events.iter().any(|event| event.key == 1));
assert!(!events.iter().any(|event| event.key == 0));
assert!(!vm.is_connected().unwrap());
}
} }

View File

@ -7,19 +7,22 @@ use privdrop::PrivDrop;
use softnet::NetType; use softnet::NetType;
use softnet::proxy::ExposedPort; use softnet::proxy::ExposedPort;
use softnet::proxy::Proxy; use softnet::proxy::Proxy;
use softnet::proxy::Target; use softnet::proxy::Rule;
use std::borrow::Cow;
use std::env; use std::env;
use std::os::raw::c_int; use std::os::raw::c_int;
use std::os::unix::io::RawFd; use std::os::unix::io::RawFd;
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use std::process::{Command, ExitCode}; use std::process::{Command, ExitCode};
use system_configuration::core_foundation::base::TCFType; use system_configuration::core_foundation::base::TCFType;
use system_configuration::core_foundation::boolean::CFBoolean;
use system_configuration::core_foundation::dictionary::CFDictionary; use system_configuration::core_foundation::dictionary::CFDictionary;
use system_configuration::core_foundation::number::CFNumber; use system_configuration::core_foundation::number::CFNumber;
use system_configuration::core_foundation::string::CFString; use system_configuration::core_foundation::string::CFString;
use system_configuration::preferences::SCPreferences; use system_configuration::preferences::SCPreferences;
use system_configuration::sys::preferences::{SCPreferencesCommitChanges, SCPreferencesSetValue}; use system_configuration::sys::preferences::{
SCPreferencesApplyChanges, SCPreferencesCommitChanges, SCPreferencesLock,
SCPreferencesSetValue, SCPreferencesUnlock,
};
use uzers::{get_current_groupname, get_current_username, get_effective_uid}; use uzers::{get_current_groupname, get_current_username, get_effective_uid};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -59,34 +62,51 @@ struct Args {
#[clap( #[clap(
long, long,
help = "Comma-separated list of CIDRs to allow the traffic to \ help = "Comma-separated rules for allowing traffic, in the following forms:\n\n\
(e.g. --allow=192.168.0.0/24 may be used to allow a LAN access for a VM), \ * TARGET: traffic sent from the VM to TARGET; reverse traffic is not filtered by this rule\n\
plus supported @-aliases. Currently the only supported @-alias is @host, \ * in TARGET: flows initiated from TARGET to the VM\n\
which matches the vmnet bridge gateway IP. \ * out TARGET: flows initiated from the VM to TARGET\n\n\
When used with --block, the longest prefix match always wins. \ Targets are:\n\n\
In case an identical prefix is both --allow'ed and --block'ed, \ * IPv4 CIDRs\n\
blocking will take precedence. --allow=0.0.0.0/0 is a special case, \ * @host, which matches the vmnet bridge gateway IP\n\n\
it additionally disables bridge isolation (even when --block=0.0.0.0/0 is specified).", Directional rules make bare TARGET rules stateful in both directions.\n\n\
value_name = "comma-separated CIDRs or @-alias", When used with --block, the longest prefix match wins. If an identical rule is both \
allowed and blocked, blocking takes precedence.\n\n\
--allow=0.0.0.0/0 additionally disables bridge isolation, even when \
--block=0.0.0.0/0 is specified.\n\n\
Examples:\n\n\
* --allow=192.168.0.0/24 allow stateless traffic with this LAN\n\
* --allow=\"in @host\" — allow stateful flows initiated from @host\n\
* --allow=\"out 192.168.0.0/24\" — allow stateful flows initiated toward this LAN\n\
* --allow=\"in @host,out 192.168.0.0/24\" — multiple rules may be comma-separated",
value_name = "comma-separated rules",
use_value_delimiter = true, use_value_delimiter = true,
action = clap::ArgAction::Set action = clap::ArgAction::Set
)] )]
allow: Vec<Target>, allow: Vec<Rule>,
#[clap( #[clap(
long, long,
help = "Comma-separated list of CIDRs to block the traffic to \ help = "Comma-separated rules for blocking traffic, in the following forms:\n\n\
(e.g. --block=0.0.0.0/0 may be used to establish a default deny policy \ * TARGET: traffic sent from the VM to TARGET; reverse traffic is not filtered by this rule\n\
that is further relaxed with --allow), plus supported @-aliases. \ * in TARGET: flows initiated from TARGET to the VM\n\
Currently the only supported @-alias is @host, which matches the vmnet bridge gateway IP. \ * out TARGET: flows initiated from the VM to TARGET\n\n\
When used with --allow, the longest prefix match always wins. \ Targets are:\n\n\
In case an identical prefix is both --allow'ed and --block'ed, \ * IPv4 CIDRs\n\
blocking will take precedence.", * @host, which matches the vmnet bridge gateway IP\n\n\
value_name = "comma-separated CIDRs or @-alias", Directional rules make bare TARGET rules stateful in both directions.\n\n\
When used with --allow, the longest prefix match wins. If an identical rule is both \
allowed and blocked, blocking takes precedence.\n\n\
Examples:\n\n\
* --block=0.0.0.0/0 establish a stateless default-deny egress policy\n\
* --block=\"out @host\" — block stateful flows initiated toward @host\n\
* --block=\"out 66.66.66.0/24\" — block stateful flows initiated toward this CIDR\n\
* --block=\"out @host,out 66.66.66.0/24\" — multiple rules may be comma-separated",
value_name = "comma-separated rules",
use_value_delimiter = true, use_value_delimiter = true,
action = clap::ArgAction::Set action = clap::ArgAction::Set
)] )]
block: Vec<Target>, block: Vec<Rule>,
#[clap( #[clap(
long, long,
@ -113,10 +133,10 @@ fn main() -> ExitCode {
} }
// Initialize Sentry // Initialize Sentry
let _sentry = sentry::init(sentry::ClientOptions { let _sentry = sentry::init(
release: option_env!("CIRRUS_TAG").map(|tag| Cow::from(format!("softnet@{tag}"))), sentry::ClientOptions::default()
..Default::default() .maybe_release(option_env!("CIRRUS_TAG").map(|tag| format!("softnet@{tag}"))),
}); );
// Enrich future events with Cirrus CI-specific tags // Enrich future events with Cirrus CI-specific tags
if let Ok(tags) = env::var("CIRRUS_SENTRY_TAGS") { if let Ok(tags) = env::var("CIRRUS_SENTRY_TAGS") {
@ -199,8 +219,8 @@ fn try_main() -> anyhow::Result<()> {
)); ));
} }
// Set bootpd(8) min/max lease time while still having the root privileges // Configure bootpd(8) while still having the root privileges
set_bootpd_lease_time(args.bootpd_lease_time); configure_bootpd(args.bootpd_lease_time)?;
// Initialize the proxy while still having the root privileges // Initialize the proxy while still having the root privileges
let mut proxy = Proxy::new( let mut proxy = Proxy::new(
@ -252,26 +272,59 @@ fn sudo_escalation_works() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn set_bootpd_lease_time(lease_time: u32) { fn configure_bootpd(lease_time: u32) -> anyhow::Result<()> {
let prefs = SCPreferences::group( let prefs = SCPreferences::group(
&CFString::new("softnet"), &CFString::new("softnet"),
&CFString::new("com.apple.InternetSharing.default.plist"), &CFString::new("com.apple.InternetSharing.default.plist"),
); );
let bootpd_dict = CFDictionary::from_CFType_pairs(&[( let bootpd_dict = CFDictionary::from_CFType_pairs(&[
CFString::new("DHCPLeaseTimeSecs"), (
CFNumber::from(lease_time as i32), CFString::new("DHCPLeaseTimeSecs"),
)]); CFNumber::from(lease_time as i32).as_CFType(),
),
(
CFString::new("dhcp_ignore_client_identifier"),
CFBoolean::true_value().as_CFType(),
),
]);
unsafe { unsafe {
SCPreferencesSetValue( let prefs = prefs.as_concrete_TypeRef();
prefs.as_concrete_TypeRef(), anyhow::ensure!(
CFString::new("bootpd").as_concrete_TypeRef(), SCPreferencesLock(prefs, 1) != 0,
bootpd_dict.as_concrete_TypeRef().cast(), "failed to lock bootpd preferences"
); );
SCPreferencesCommitChanges(prefs.as_concrete_TypeRef()); let result = (|| -> anyhow::Result<()> {
anyhow::ensure!(
SCPreferencesSetValue(
prefs,
CFString::new("bootpd").as_concrete_TypeRef(),
bootpd_dict.as_concrete_TypeRef().cast(),
) != 0,
"failed to set bootpd preferences"
);
anyhow::ensure!(
SCPreferencesCommitChanges(prefs) != 0,
"failed to commit bootpd preferences"
);
anyhow::ensure!(
SCPreferencesApplyChanges(prefs) != 0,
"failed to apply bootpd preferences"
);
Ok(())
})();
let unlocked = SCPreferencesUnlock(prefs) != 0;
result?;
anyhow::ensure!(unlocked, "failed to unlock bootpd preferences");
} }
Ok(())
} }
#[cfg(test)] #[cfg(test)]