From 99dfcaabf1c3ea8e4d48d2aca9ec1e55eec81095 Mon Sep 17 00:00:00 2001 From: reoring Date: Wed, 11 Feb 2026 15:29:04 +0900 Subject: [PATCH 1/5] feat: add MITM support with kind acceptance automation --- .github/workflows/ci.yml | 12 + .gitignore | 1 + Cargo.lock | 268 +++++++- Cargo.toml | 6 + README.md | 16 +- scripts/iptables-init.sh | 19 + src/config.rs | 265 ++++++++ src/lib.rs | 1 + src/main.rs | 71 +- src/metrics.rs | 53 +- src/mitm.rs | 514 ++++++++++++++ tests/at/README.md | 15 + tests/e2e/manifests/mitm-test.yaml | 212 ++++++ tests/e2e/run-egress-test.sh | 33 + tests/e2e/run-kind-acceptance.sh | 124 ++++ tests/e2e/run-mitm-test.sh | 59 ++ tests/integration_test.rs | 56 ++ tests/mitm_unit_test.rs | 1002 ++++++++++++++++++++++++++++ 18 files changed, 2714 insertions(+), 13 deletions(-) create mode 100644 src/mitm.rs create mode 100644 tests/e2e/manifests/mitm-test.yaml create mode 100755 tests/e2e/run-egress-test.sh create mode 100755 tests/e2e/run-kind-acceptance.sh create mode 100755 tests/e2e/run-mitm-test.sh create mode 100644 tests/mitm_unit_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eead76a..a07d633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,15 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test + + kind-acceptance: + name: Kind Acceptance + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: helm/kind-action@v1 + with: + cluster_name: kind + - name: Run acceptance tests on kind + run: tests/e2e/run-kind-acceptance.sh diff --git a/.gitignore b/.gitignore index ea8c4bf..2c14fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +docs/wip diff --git a/Cargo.lock b/Cargo.lock index cd39209..4d1ff35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.21" @@ -76,6 +82,45 @@ dependencies = [ "rustversion", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -92,6 +137,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "aws-lc-rs" version = "1.15.4" @@ -144,15 +195,19 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "lru", "notify", "prometheus", + "rcgen", "reqwest", "rustls", "serde", "serde_yaml", "tempfile", "thiserror 2.0.18", + "time", "tokio", + "tokio-rustls", "tracing", "tracing-subscriber", "uuid", @@ -272,6 +327,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deadpool" version = "0.12.3" @@ -290,6 +351,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -350,6 +434,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -520,6 +610,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -781,7 +882,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -917,6 +1018,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -938,6 +1048,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -967,6 +1083,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.2.0" @@ -1003,6 +1129,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1013,6 +1173,15 @@ dependencies = [ "libc", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1098,6 +1267,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1131,6 +1310,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1214,6 +1399,20 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1306,6 +1505,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1700,6 +1908,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2326,6 +2565,33 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 9df0a9c..28ed459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,14 @@ http = "1" # TLS (outbound) hyper-rustls = { version = "0.27", features = ["http1", "ring", "webpki-tokio"] } rustls = "0.23" +tokio-rustls = "0.26" webpki-roots = "0.26" +# MITM certificate generation +rcgen = { version = "0.14", features = ["pem", "x509-parser"] } +lru = "0.12" +time = "0.3" + # Config serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" diff --git a/README.md b/README.md index 4972a89..800541d 100644 --- a/README.md +++ b/README.md @@ -138,16 +138,20 @@ containers: runAsUser: 1000 # must NOT be 1337 (BotBox UID) or iptables owner-match can be bypassed ``` -### 4. Run the E2E test +### 4. Run acceptance tests on kind (automated) ```bash -kubectl apply -f tests/e2e/manifests/egress-test.yaml -kubectl -n egress-test wait --for=jsonpath='{.status.phase}'=Succeeded pod/egress-test --timeout=120s -kubectl -n egress-test logs egress-test -c curl-client -kubectl delete namespace egress-test +tests/e2e/run-kind-acceptance.sh ``` -### 5. Run unit tests +### 5. Run individual E2E tests (optional) + +```bash +tests/e2e/run-egress-test.sh +tests/e2e/run-mitm-test.sh +``` + +### 6. Run unit tests ```bash cargo test diff --git a/scripts/iptables-init.sh b/scripts/iptables-init.sh index e832b85..908f489 100644 --- a/scripts/iptables-init.sh +++ b/scripts/iptables-init.sh @@ -12,6 +12,8 @@ set -eu PROXY_UID="${BOTBOX_UID:-1337}" PROXY_PORT="${BOTBOX_PROXY_PORT:-8080}" REDIRECT_FROM_PORT="${BOTBOX_REDIRECT_FROM_PORT:-80}" +ENABLE_MITM="${BOTBOX_ENABLE_MITM:-0}" +MITM_PORT="${BOTBOX_MITM_PORT:-8443}" NAT_CHAIN="${BOTBOX_NAT_CHAIN:-EGRESS_REDIRECT}" FILTER_CHAIN="${BOTBOX_FILTER_CHAIN:-EGRESS_FILTER}" @@ -26,6 +28,17 @@ ipt_nat() { iptables -w "${WAIT_SECONDS}" -t nat "$@" } +# Guard: MITM + REDIRECT_FROM_PORT=443 conflict. +# When both are set, the HTTP redirect rule matches port 443 first and sends +# traffic to the plain-HTTP proxy, making the MITM REDIRECT rule unreachable. +if [ "${ENABLE_MITM}" = "1" ] && [ "${REDIRECT_FROM_PORT}" = "443" ]; then + echo "ERROR: BOTBOX_ENABLE_MITM=1 and BOTBOX_REDIRECT_FROM_PORT=443 conflict." >&2 + echo " Port 443 traffic would be redirected to the HTTP proxy (port ${PROXY_PORT})" >&2 + echo " instead of the MITM listener (port ${MITM_PORT})." >&2 + echo " Use BOTBOX_REDIRECT_FROM_PORT=80 (default) with BOTBOX_ENABLE_MITM=1." >&2 + exit 1 +fi + echo "Installing BotBox iptables rules..." echo " proxy_uid=${PROXY_UID} proxy_port=${PROXY_PORT} redirect_from_port=${REDIRECT_FROM_PORT}" @@ -42,6 +55,12 @@ ipt_nat -A "${NAT_CHAIN}" -o lo -j RETURN ipt_nat -A "${NAT_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN ipt_nat -A "${NAT_CHAIN}" -p tcp --dport "${REDIRECT_FROM_PORT}" -j REDIRECT --to-port "${PROXY_PORT}" +# MITM: redirect outbound HTTPS (port 443) to MITM listener +if [ "${ENABLE_MITM}" = "1" ]; then + echo " mitm_port=${MITM_PORT} (MITM enabled)" + ipt_nat -A "${NAT_CHAIN}" -p tcp --dport 443 -j REDIRECT --to-port "${MITM_PORT}" +fi + ipt_nat -I OUTPUT 1 -p tcp -j "${NAT_CHAIN}" # --- Filter rules: block direct outbound from non-BotBox processes --- diff --git a/src/config.rs b/src/config.rs index f148c37..a25a432 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub struct Config { pub max_connections: Option, pub allow_non_loopback: Option, pub egress_policy: EgressPolicy, + pub mitm: Option, } #[derive(Debug, Deserialize, Clone)] @@ -45,6 +46,55 @@ pub struct HeaderRewrite { pub secret_ref: Option, } +#[derive(Debug, Deserialize, Clone)] +pub struct MitmConfig { + pub enabled: bool, + pub listen_addr: Option, + pub listen_port: Option, + pub ca_cert_path: String, + pub ca_key_path: String, + pub enforce_sni_host_match: Option, + pub deny_handshake_on_disallowed_sni: Option, + pub cert_ttl_seconds: Option, + pub cert_cache_size: Option, + pub cert_cache_ttl_seconds: Option, + pub handshake_timeout_ms: Option, +} + +impl MitmConfig { + pub fn listen_addr(&self) -> &str { + self.listen_addr.as_deref().unwrap_or("127.0.0.1") + } + + pub fn listen_port(&self) -> u16 { + self.listen_port.unwrap_or(8443) + } + + pub fn enforce_sni_host_match(&self) -> bool { + self.enforce_sni_host_match.unwrap_or(true) + } + + pub fn deny_handshake_on_disallowed_sni(&self) -> bool { + self.deny_handshake_on_disallowed_sni.unwrap_or(false) + } + + pub fn cert_ttl_seconds(&self) -> u64 { + self.cert_ttl_seconds.unwrap_or(86400) + } + + pub fn cert_cache_size(&self) -> u64 { + self.cert_cache_size.unwrap_or(1024) + } + + pub fn cert_cache_ttl_seconds(&self) -> u64 { + self.cert_cache_ttl_seconds.unwrap_or(3600) + } + + pub fn handshake_timeout_ms(&self) -> u64 { + self.handshake_timeout_ms.unwrap_or(5000) + } +} + pub fn strip_port(host: &str) -> &str { if host.starts_with('[') { // IPv6 with brackets: [::1]:port -> ::1 @@ -227,6 +277,88 @@ impl Config { } } + // MITM validation + if let Some(mitm) = &self.mitm { + if mitm.enabled { + // MITM listen_addr must be loopback (hard requirement, even with allow_non_loopback) + let mitm_addr = mitm.listen_addr(); + let mitm_ip: IpAddr = mitm_addr.parse().with_context(|| { + format!( + "mitm.listen_addr must be an IP literal, got '{}'", + mitm_addr + ) + })?; + if !mitm_ip.is_loopback() { + bail!( + "mitm.listen_addr '{}' must be loopback; MITM listener must bind to loopback only", + mitm_addr + ); + } + + // Port collision checks + let mitm_port = mitm.listen_port(); + if mitm_port == self.listen_port() { + bail!( + "mitm.listen_port {} collides with listen_port {}", + mitm_port, + self.listen_port() + ); + } + if mitm_port == self.metrics_port() { + bail!( + "mitm.listen_port {} collides with metrics_port {}", + mitm_port, + self.metrics_port() + ); + } + + // CA path validation + let cert_empty = mitm.ca_cert_path.trim().is_empty(); + let key_empty = mitm.ca_key_path.trim().is_empty(); + if cert_empty && key_empty { + bail!("mitm.ca_cert_path and mitm.ca_key_path must not be empty"); + } else if cert_empty { + bail!("mitm.ca_cert_path must not be empty"); + } else if key_empty { + bail!("mitm.ca_key_path must not be empty"); + } + + // cert_cache_size > 0 + if mitm.cert_cache_size() == 0 { + bail!("mitm.cert_cache_size must be greater than 0"); + } + + // cert_ttl_seconds in 60..604800 + let ttl = mitm.cert_ttl_seconds(); + if !(60..=604800).contains(&ttl) { + bail!( + "mitm.cert_ttl_seconds {} must be between 60 and 604800", + ttl + ); + } + + // handshake_timeout_ms in 100..60000 + let hs_timeout = mitm.handshake_timeout_ms(); + if !(100..=60000).contains(&hs_timeout) { + bail!( + "mitm.handshake_timeout_ms {} must be between 100 and 60000", + hs_timeout + ); + } + + // cert_cache_ttl_seconds must not exceed cert_ttl_seconds + // (otherwise expired certificates could be served from cache) + let cache_ttl = mitm.cert_cache_ttl_seconds(); + if cache_ttl > ttl { + bail!( + "mitm.cert_cache_ttl_seconds ({}) must not exceed mitm.cert_ttl_seconds ({})", + cache_ttl, + ttl + ); + } + } + } + if self.allow_non_loopback() { warn!( listen_addr = %self.listen_addr(), @@ -487,4 +619,137 @@ egress_policy: let err = config.validate().unwrap_err(); assert!(err.to_string().contains("cannot be used in rewrites")); } + + // --- MITM design contract tests (WIP docs/wip/mitm/*) --- + + #[test] + fn test_mitm_enabled_rejects_non_loopback_listener_even_with_global_override() { + let yaml = r#" +allow_non_loopback: true +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "0.0.0.0" + listen_port: 8443 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("mitm.listen_addr")); + assert!(err.to_string().contains("loopback")); + } + + #[test] + fn test_mitm_enabled_rejects_port_collision_with_http_listener() { + let yaml = r#" +listen_port: 8080 +metrics_port: 9090 +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8080 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("mitm.listen_port")); + assert!(err.to_string().contains("listen_port")); + } + + #[test] + fn test_mitm_enabled_rejects_port_collision_with_metrics_listener() { + let yaml = r#" +listen_port: 8080 +metrics_port: 9090 +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 9090 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("mitm.listen_port")); + assert!(err.to_string().contains("metrics_port")); + } + + #[test] + fn test_mitm_enabled_requires_non_empty_ca_paths() { + let yaml = r#" +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8443 + ca_cert_path: "" + ca_key_path: "" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("mitm.ca_cert_path")); + assert!(err.to_string().contains("mitm.ca_key_path")); + } + + #[test] + fn test_mitm_enabled_rejects_zero_cert_cache_size() { + let yaml = r#" +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8443 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" + cert_cache_size: 0 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("cert_cache_size")); + } + + #[test] + fn test_mitm_enabled_rejects_out_of_range_cert_ttl_seconds() { + let yaml = r#" +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8443 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" + cert_ttl_seconds: 30 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("cert_ttl_seconds")); + } + + #[test] + fn test_mitm_enabled_rejects_out_of_range_handshake_timeout_ms() { + let yaml = r#" +egress_policy: + rules: [] +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8443 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" + handshake_timeout_ms: 5 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("handshake_timeout_ms")); + } } diff --git a/src/lib.rs b/src/lib.rs index d59f7cb..3129de7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod header_rewrite; pub mod logging; pub mod metrics; +pub mod mitm; pub mod proxy; pub mod secrets; pub mod tls; diff --git a/src/main.rs b/src/main.rs index b3bd7b8..ca64fec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use botbox::allowlist::Allowlist; use botbox::config::Config; use botbox::metrics::{handle_metrics_request, Metrics}; +use botbox::mitm; use botbox::proxy::{ProxyBody, ProxyHandler}; use botbox::{logging, secrets, tls}; use clap::Parser; @@ -110,19 +111,22 @@ async fn main() -> Result<()> { // Create proxy handler let handler = Arc::new(ProxyHandler::new( connector, - allowlist, + allowlist.clone(), secret_store, metrics.clone(), std::time::Duration::from_secs(30), )); + // Shared shutdown channel for metrics + MITM listeners + let (shutdown_tx, _) = tokio::sync::watch::channel(()); + // Start metrics server let metrics_addr: SocketAddr = format!("127.0.0.1:{}", config.metrics_port()) .parse() .unwrap(); let metrics_clone = metrics.clone(); let ready_clone = ready.clone(); - let (metrics_shutdown_tx, metrics_shutdown_rx) = tokio::sync::watch::channel(()); + let metrics_shutdown_rx = shutdown_tx.subscribe(); let metrics_handle = tokio::spawn(async move { run_metrics_server( metrics_addr, @@ -133,6 +137,59 @@ async fn main() -> Result<()> { .await; }); + // Shared connection semaphore (HTTP proxy + MITM share the same pool) + let semaphore = Arc::new(Semaphore::new(config.max_connections() as usize)); + + // MITM TLS listener (if enabled) + let mitm_handle = if let Some(ref mitm_config) = config.mitm { + if mitm_config.enabled { + info!("MITM mode enabled, loading CA material"); + let ca = mitm::MitmCa::load(&mitm_config.ca_cert_path, &mitm_config.ca_key_path)?; + let ca = Arc::new(ca); + + let resolver = Arc::new(mitm::MitmCertResolver::new( + ca, + mitm_config, + allowlist, + metrics.clone(), + )); + let tls_config = mitm::build_mitm_server_config(resolver); + + let mitm_addr: SocketAddr = format!( + "{}:{}", + mitm_config.listen_addr(), + mitm_config.listen_port() + ) + .parse() + .unwrap(); + info!(addr = %mitm_addr, "starting MITM TLS listener"); + let mitm_listener = TcpListener::bind(mitm_addr).await?; + + let mitm_handler = handler.clone(); + let mitm_metrics = metrics.clone(); + let mitm_semaphore = semaphore.clone(); + let mitm_shutdown_rx = shutdown_tx.subscribe(); + let mitm_cfg = mitm_config.clone(); + + Some(tokio::spawn(async move { + mitm::run_mitm_listener( + mitm_listener, + tls_config, + mitm_handler, + mitm_cfg, + mitm_metrics, + mitm_semaphore, + mitm_shutdown_rx, + ) + .await; + })) + } else { + None + } + } else { + None + }; + // Start proxy server let proxy_addr: SocketAddr = format!("{}:{}", config.listen_addr(), config.listen_port()) .parse() @@ -158,7 +215,6 @@ async fn main() -> Result<()> { tokio::pin!(shutdown); let mut connections = tokio::task::JoinSet::new(); - let semaphore = Arc::new(Semaphore::new(config.max_connections() as usize)); loop { tokio::select! { @@ -250,11 +306,16 @@ async fn main() -> Result<()> { } } - // Signal metrics server to shut down - drop(metrics_shutdown_tx); + // Signal metrics + MITM servers to shut down + drop(shutdown_tx); info!("waiting for metrics server to stop"); let _ = metrics_handle.await; + if let Some(handle) = mitm_handle { + info!("waiting for MITM listener to stop"); + let _ = handle.await; + } + info!("proxy server stopped"); Ok(()) } diff --git a/src/metrics.rs b/src/metrics.rs index 20da049..a8c71f9 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -2,7 +2,7 @@ use http_body_util::Full; use hyper::body::Bytes; use hyper::{Request, Response}; use prometheus::{ - Encoder, HistogramOpts, HistogramVec, IntCounterVec, Opts, Registry, TextEncoder, + Encoder, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, Opts, Registry, TextEncoder, }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -14,6 +14,11 @@ pub struct Metrics { pub header_rewrites_total: IntCounterVec, pub upstream_errors_total: IntCounterVec, pub request_duration_seconds: HistogramVec, + // MITM metrics + pub tls_handshakes_total: IntCounterVec, + pub mitm_cert_issued_total: IntCounterVec, + pub mitm_cert_cache_total: IntCounterVec, + pub mitm_host_mismatch_total: IntCounter, } impl Metrics { @@ -53,6 +58,36 @@ impl Metrics { ) .unwrap(); + let tls_handshakes_total = IntCounterVec::new( + Opts::new("botbox_tls_handshakes_total", "Total MITM TLS handshakes"), + &["result"], + ) + .unwrap(); + + let mitm_cert_issued_total = IntCounterVec::new( + Opts::new( + "botbox_mitm_cert_issued_total", + "Total MITM certificates issued", + ), + &["decision"], + ) + .unwrap(); + + let mitm_cert_cache_total = IntCounterVec::new( + Opts::new( + "botbox_mitm_cert_cache_total", + "Total MITM cert cache operations", + ), + &["result"], + ) + .unwrap(); + + let mitm_host_mismatch_total = IntCounter::new( + "botbox_mitm_host_mismatch_total", + "Total MITM SNI/Host mismatches", + ) + .unwrap(); + registry.register(Box::new(requests_total.clone())).unwrap(); registry .register(Box::new(header_rewrites_total.clone())) @@ -63,6 +98,18 @@ impl Metrics { registry .register(Box::new(request_duration_seconds.clone())) .unwrap(); + registry + .register(Box::new(tls_handshakes_total.clone())) + .unwrap(); + registry + .register(Box::new(mitm_cert_issued_total.clone())) + .unwrap(); + registry + .register(Box::new(mitm_cert_cache_total.clone())) + .unwrap(); + registry + .register(Box::new(mitm_host_mismatch_total.clone())) + .unwrap(); Metrics { registry: Arc::new(registry), @@ -70,6 +117,10 @@ impl Metrics { header_rewrites_total, upstream_errors_total, request_duration_seconds, + tls_handshakes_total, + mitm_cert_issued_total, + mitm_cert_cache_total, + mitm_host_mismatch_total, } } } diff --git a/src/mitm.rs b/src/mitm.rs new file mode 100644 index 0000000..546d867 --- /dev/null +++ b/src/mitm.rs @@ -0,0 +1,514 @@ +use crate::allowlist::{Allowlist, Decision}; +use crate::config::{extract_port, normalize_policy_host, MitmConfig}; +use crate::metrics::Metrics; +use crate::proxy::{ProxyBody, ProxyHandler}; +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use lru::LruCache; +use rcgen::{ + CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, + SanType, SerialNumber, +}; +use rustls::server::ResolvesServerCert; +use rustls::sign::CertifiedKey; +use rustls::ServerConfig; +use std::fmt; +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use time::OffsetDateTime; +use tokio::net::TcpListener; +use tracing::{error, info, warn}; + +/// CA material loaded from PEM files. +pub struct MitmCa { + pub ca_cert_pem: String, + pub ca_cert_der: Vec, + pub ca_key_pair: KeyPair, +} + +impl MitmCa { + pub fn load(cert_path: &str, key_path: &str) -> anyhow::Result { + let cert_pem = std::fs::read_to_string(cert_path) + .map_err(|e| anyhow::anyhow!("failed to read CA cert from '{}': {}", cert_path, e))?; + let key_pem = std::fs::read_to_string(key_path) + .map_err(|e| anyhow::anyhow!("failed to read CA key from '{}': {}", key_path, e))?; + + let ca_key_pair = KeyPair::from_pem(&key_pem) + .map_err(|e| anyhow::anyhow!("failed to parse CA key PEM: {}", e))?; + + let ca_cert_der = pem_to_der(&cert_pem)?; + + Ok(MitmCa { + ca_cert_pem: cert_pem, + ca_cert_der, + ca_key_pair, + }) + } +} + +fn pem_to_der(pem: &str) -> anyhow::Result> { + use rustls::pki_types::pem::PemObject; + use rustls::pki_types::CertificateDer; + let cert = CertificateDer::from_pem_slice(pem.as_bytes()) + .map_err(|e| anyhow::anyhow!("failed to decode CA cert PEM: {}", e))?; + Ok(cert.to_vec()) +} + +struct CachedCert { + certified_key: Arc, + expires_at: Instant, +} + +pub struct CertCache { + inner: Mutex>, + ttl: Duration, +} + +impl CertCache { + pub fn new(capacity: usize, ttl: Duration) -> Self { + CertCache { + inner: Mutex::new(LruCache::new( + NonZeroUsize::new(capacity).expect("cache capacity must be > 0"), + )), + ttl, + } + } + + fn get(&self, hostname: &str) -> Option> { + let mut cache = self.inner.lock().unwrap(); + if let Some(entry) = cache.get(hostname) { + if Instant::now() < entry.expires_at { + return Some(entry.certified_key.clone()); + } + // Expired - remove it + cache.pop(hostname); + } + None + } + + fn insert(&self, hostname: String, key: Arc) { + let mut cache = self.inner.lock().unwrap(); + cache.put( + hostname, + CachedCert { + certified_key: key, + expires_at: Instant::now() + self.ttl, + }, + ); + } +} + +/// Validate SNI hostname: trim, lowercase, reject empty/oversized/wildcard/invalid chars. +pub fn validate_sni(sni: &str) -> Result { + let host = sni.trim().to_lowercase(); + if host.is_empty() { + return Err("empty SNI hostname"); + } + if host.len() > 253 { + return Err("SNI hostname exceeds 253 characters"); + } + if host.contains('*') { + return Err("wildcard SNI not allowed"); + } + for ch in host.chars() { + if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '.' { + return Err("invalid character in SNI hostname"); + } + } + Ok(host) +} + +/// MITM certificate resolver implementing rustls `ResolvesServerCert`. +/// +/// SNI is always required — without it, no hostname is available and no +/// certificate can be issued. Invalid SNI always causes handshake failure +/// for the same reason. +pub struct MitmCertResolver { + ca: Arc, + cache: CertCache, + cert_ttl: Duration, + deny_handshake_on_disallowed_sni: bool, + allowlist: Arc, + metrics: Metrics, +} + +impl fmt::Debug for MitmCertResolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MitmCertResolver") + .field( + "deny_handshake_on_disallowed_sni", + &self.deny_handshake_on_disallowed_sni, + ) + .finish() + } +} + +impl MitmCertResolver { + pub fn new( + ca: Arc, + config: &MitmConfig, + allowlist: Arc, + metrics: Metrics, + ) -> Self { + MitmCertResolver { + ca, + cache: CertCache::new( + config.cert_cache_size() as usize, + Duration::from_secs(config.cert_cache_ttl_seconds()), + ), + cert_ttl: Duration::from_secs(config.cert_ttl_seconds()), + deny_handshake_on_disallowed_sni: config.deny_handshake_on_disallowed_sni(), + allowlist, + metrics, + } + } + + fn issue_leaf(&self, hostname: &str) -> anyhow::Result> { + // Generate ECDSA P-256 leaf key + let leaf_key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| anyhow::anyhow!("failed to generate leaf key: {}", e))?; + + let mut params = CertificateParams::default(); + params.distinguished_name.push(DnType::CommonName, hostname); + params.subject_alt_names = + vec![SanType::DnsName(hostname.try_into().map_err(|e| { + anyhow::anyhow!("invalid DNS name '{}': {}", hostname, e) + })?)]; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.is_ca = IsCa::NoCa; + params.serial_number = Some(SerialNumber::from_slice( + &uuid::Uuid::new_v4().as_bytes()[..], + )); + + // Set validity: not_before = now - 5 minutes, not_after = now + cert_ttl + let now = OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::minutes(5); + params.not_after = now + time::Duration::seconds(self.cert_ttl.as_secs() as i64); + + // Create issuer from CA cert PEM + key pair + let issuer = Issuer::from_ca_cert_pem(&self.ca.ca_cert_pem, &self.ca.ca_key_pair) + .map_err(|e| anyhow::anyhow!("failed to create CA issuer: {}", e))?; + + // Sign leaf cert + let leaf_cert = params + .signed_by(&leaf_key_pair, &issuer) + .map_err(|e| anyhow::anyhow!("failed to sign leaf certificate: {}", e))?; + + let leaf_der = rustls::pki_types::CertificateDer::from(leaf_cert.der().to_vec()); + let ca_der = rustls::pki_types::CertificateDer::from(self.ca.ca_cert_der.clone()); + + // Convert leaf key pair to rustls signing key + let leaf_key_der = + rustls::pki_types::PrivateKeyDer::try_from(leaf_key_pair.serialize_der()) + .map_err(|e| anyhow::anyhow!("failed to convert leaf key to DER: {}", e))?; + let signing_key = rustls::crypto::ring::sign::any_ecdsa_type(&leaf_key_der) + .map_err(|e| anyhow::anyhow!("failed to create signing key: {}", e))?; + + let certified_key = CertifiedKey::new(vec![leaf_der, ca_der], signing_key); + Ok(Arc::new(certified_key)) + } +} + +impl ResolvesServerCert for MitmCertResolver { + /// Resolve a certificate for the given ClientHello. + /// + /// Metrics policy: this function tracks **cert issuance** and **cache** counters + /// only. The **handshake result** counter (`tls_handshakes_total`) is owned + /// exclusively by the accept loop in `run_mitm_listener()` so that each TCP + /// connection is counted exactly once. + fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { + // SNI is always required — without a hostname we cannot issue a cert. + let sni = client_hello.server_name()?; + + // Validate SNI + let hostname = match validate_sni(sni) { + Ok(h) => h, + Err(_reason) => { + self.metrics + .mitm_cert_issued_total + .with_label_values(&["skipped_invalid"]) + .inc(); + return None; + } + }; + + // Check allowlist if deny_handshake_on_disallowed_sni is true + if self.deny_handshake_on_disallowed_sni { + let decision = self.allowlist.check(&hostname); + if matches!(decision, Decision::Deny) { + self.metrics + .mitm_cert_issued_total + .with_label_values(&["skipped_disallowed"]) + .inc(); + return None; + } + } + + // Check cache + if let Some(key) = self.cache.get(&hostname) { + self.metrics + .mitm_cert_cache_total + .with_label_values(&["hit"]) + .inc(); + return Some(key); + } + self.metrics + .mitm_cert_cache_total + .with_label_values(&["miss"]) + .inc(); + + // Issue leaf cert (outside any lock) + match self.issue_leaf(&hostname) { + Ok(key) => { + self.cache.insert(hostname, key.clone()); + self.metrics + .mitm_cert_issued_total + .with_label_values(&["issued_allow"]) + .inc(); + Some(key) + } + Err(e) => { + error!(error = %e, "failed to issue MITM leaf certificate"); + None + } + } + } +} + +/// Build a rustls ServerConfig for the MITM TLS listener. +pub fn build_mitm_server_config(resolver: Arc) -> Arc { + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(resolver); + + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Arc::new(config) +} + +/// Validate an HTTP request arriving over the MITM TLS listener. +/// +/// MITM connections are transparently redirected from port 443. The app container +/// thinks it is talking directly to the upstream server, so it MUST send +/// origin-form requests (`GET /path HTTP/1.1`). An absolute-form request +/// (`GET http://host:PORT/path HTTP/1.1`) would carry its own authority/scheme +/// that `ProxyHandler::handle()` would trust over the Host header, allowing an +/// attacker to bypass the port-443 restriction and the SNI/Host match check. +/// We therefore reject any request that contains a URI authority or scheme. +#[allow(clippy::result_large_err)] +pub fn validate_mitm_request( + req: &Request, + sni_host: &str, + enforce_sni_host_match: bool, + metrics: &Metrics, +) -> Result<(), Response> { + // Reject absolute-form requests (scheme/authority in URI). + // MITM traffic must use origin-form only; absolute-form could bypass + // the Host/SNI checks because ProxyHandler prioritizes URI authority. + if req.uri().scheme().is_some() || req.uri().authority().is_some() { + return Err(error_response( + 400, + "absolute-form request not allowed on MITM listener", + )); + } + + // Extract host from Host header + let host_header = req + .headers() + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if host_header.is_empty() { + return Err(error_response(400, "missing Host header")); + } + + // Check port: must be 443 or absent + let port = extract_port(host_header); + if let Some(p) = port { + if p != 443 { + return Err(error_response( + 403, + "MITM listener only accepts port 443 traffic", + )); + } + } + + // SNI/Host match check + if enforce_sni_host_match { + let host_only = normalize_policy_host(host_header); + let sni_normalized = sni_host.trim().to_lowercase(); + if host_only != sni_normalized { + metrics.mitm_host_mismatch_total.inc(); + return Err(error_response(400, "SNI/Host header mismatch")); + } + } + + Ok(()) +} + +fn error_response(status: u16, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(http_body_util::Either::Left(Full::new(Bytes::from( + body.to_string(), + )))) + .unwrap() +} + +/// Run the MITM TLS listener. +pub async fn run_mitm_listener( + listener: TcpListener, + tls_config: Arc, + handler: Arc, + mitm_config: MitmConfig, + metrics: Metrics, + semaphore: Arc, + mut shutdown_rx: tokio::sync::watch::Receiver<()>, +) { + let acceptor = tokio_rustls::TlsAcceptor::from(tls_config); + let handshake_timeout = Duration::from_millis(mitm_config.handshake_timeout_ms()); + let enforce_sni_host_match = mitm_config.enforce_sni_host_match(); + + let mut connections = tokio::task::JoinSet::new(); + + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, addr)) => { + let permit = match Arc::clone(&semaphore).try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + warn!(peer = %addr, "MITM connection limit reached, dropping connection"); + drop(stream); + continue; + } + }; + let acceptor = acceptor.clone(); + let handler = handler.clone(); + let metrics = metrics.clone(); + + connections.spawn(async move { + let _permit = permit; + // TLS handshake with timeout + let tls_stream = match tokio::time::timeout( + handshake_timeout, + acceptor.accept(stream), + ).await { + Ok(Ok(tls)) => tls, + Ok(Err(e)) => { + if !e.to_string().contains("connection closed") { + warn!(peer = %addr, error = %e, "MITM TLS handshake failed"); + } + metrics + .tls_handshakes_total + .with_label_values(&["io_error"]) + .inc(); + return; + } + Err(_) => { + warn!(peer = %addr, "MITM TLS handshake timed out"); + metrics + .tls_handshakes_total + .with_label_values(&["timeout"]) + .inc(); + return; + } + }; + + metrics + .tls_handshakes_total + .with_label_values(&["ok"]) + .inc(); + + // Extract SNI from the TLS connection + let sni_host = tls_stream + .get_ref() + .1 + .server_name() + .unwrap_or("") + .to_string(); + + let io = TokioIo::new(tls_stream); + let service = service_fn(move |req: Request| { + let handler = handler.clone(); + let sni = sni_host.clone(); + let metrics = metrics.clone(); + async move { + // Validate MITM-specific constraints + if let Err(resp) = validate_mitm_request( + &req, + &sni, + enforce_sni_host_match, + &metrics, + ) { + return Ok::<_, hyper::Error>(resp); + } + + // Delegate to the existing proxy handler + handler.handle(req).await.or_else(|e| { + error!(error = %e, "MITM request handling error"); + Ok::<_, hyper::Error>( + Response::builder() + .status(500) + .body(ProxyBody::Left(Full::new(Bytes::from( + "internal server error", + )))) + .unwrap(), + ) + }) + } + }); + + if let Err(e) = http1::Builder::new() + .preserve_header_case(true) + .max_buf_size(64 * 1024) + .serve_connection(io, service) + .await + { + if !e.to_string().contains("connection closed") { + error!( + peer = %addr, + error = %e, + "MITM connection error" + ); + } + } + }); + } + Err(e) => { + error!(error = %e, "MITM accept error"); + } + } + } + _ = shutdown_rx.changed() => { + break; + } + } + } + + // Drain in-flight MITM connections + info!( + "MITM listener shutting down, draining {} connections", + connections.len() + ); + let drain_result = tokio::time::timeout(Duration::from_secs(30), async { + while connections.join_next().await.is_some() {} + }) + .await; + if drain_result.is_err() { + warn!( + "MITM drain timeout, aborting {} remaining connections", + connections.len() + ); + connections.abort_all(); + } + info!("MITM listener stopped"); +} diff --git a/tests/at/README.md b/tests/at/README.md index 2d131e0..4322e22 100644 --- a/tests/at/README.md +++ b/tests/at/README.md @@ -21,6 +21,21 @@ Notes: - These tests require Pod-level iptables (`CAP_NET_ADMIN`) to be permitted. - The test pod reaches `httpbin.org` over the public internet. +## Automated Execution (kind E2E) + +For an automated acceptance run (image build + kind image load + non-MITM E2E + MITM E2E), use: + +```bash +tests/e2e/run-kind-acceptance.sh +``` + +Useful environment variables: + +- `KIND_CLUSTER_NAME` (default: `kind`) +- `KUBECTL_CONTEXT` (default: `kind-${KIND_CLUSTER_NAME}`) +- `CREATE_KIND_CLUSTER=1` to auto-create the cluster when missing +- `SKIP_IMAGE_BUILD=1` to reuse already-built `botbox:test` images + ## Environment Setup (kind) 1. Create a cluster: diff --git a/tests/e2e/manifests/mitm-test.yaml b/tests/e2e/manifests/mitm-test.yaml new file mode 100644 index 0000000..116ca66 --- /dev/null +++ b/tests/e2e/manifests/mitm-test.yaml @@ -0,0 +1,212 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mitm-test +--- +apiVersion: v1 +kind: Secret +metadata: + name: botbox-secrets + namespace: mitm-test +type: Opaque +stringData: + openai-api-key: "REPLACE-ME-NOT-A-REAL-KEY" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: botbox-config + namespace: mitm-test +data: + config.yaml: | + listen_addr: "127.0.0.1" + listen_port: 8080 + metrics_port: 9090 + secrets_dir: "/var/run/secrets/botbox" + max_connections: 1024 + + egress_policy: + default_action: deny + rules: + - host: api.openai.com + action: allow + header_rewrites: + - name: Authorization + value: "Bearer {value}" + secret_ref: openai-api-key + + mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8443 + ca_cert_path: "/etc/botbox/mitm/ca.crt" + ca_key_path: "/etc/botbox/mitm/ca.key" + enforce_sni_host_match: true + deny_handshake_on_disallowed_sni: false + cert_ttl_seconds: 86400 + cert_cache_size: 1024 + cert_cache_ttl_seconds: 3600 + handshake_timeout_ms: 5000 +--- +apiVersion: v1 +kind: Pod +metadata: + name: mitm-test + namespace: mitm-test +spec: + restartPolicy: Never + initContainers: + - name: ca-init + image: alpine:3.20 + command: + - /bin/sh + - -c + - | + set -eu + apk add --no-cache openssl >/dev/null + mkdir -p /ca + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out /ca/ca.key + openssl req -x509 -new -key /ca/ca.key -sha256 -days 7 \ + -subj "/CN=botbox-pod-ca" -out /ca/ca.crt + chown 1337:1337 /ca/ca.key /ca/ca.crt + chmod 0400 /ca/ca.key + chmod 0444 /ca/ca.crt + volumeMounts: + - name: botbox-ca + mountPath: /ca + + - name: iptables-init + image: botbox-iptables-init:test + imagePullPolicy: Never + env: + - name: BOTBOX_REDIRECT_FROM_PORT + value: "443" + - name: BOTBOX_PROXY_PORT + value: "8443" + securityContext: + capabilities: + add: [NET_ADMIN] + runAsUser: 0 + runAsNonRoot: false + + - name: botbox + image: botbox:test + imagePullPolicy: Never + restartPolicy: Always + args: + - "--config" + - "/etc/botbox/config.yaml" + securityContext: + runAsUser: 1337 + runAsNonRoot: true + ports: + - containerPort: 8080 + name: proxy + - containerPort: 8443 + name: mitm + - containerPort: 9090 + name: metrics + readinessProbe: + httpGet: + path: /healthz + port: 9090 + initialDelaySeconds: 1 + periodSeconds: 2 + volumeMounts: + - name: secrets + mountPath: /var/run/secrets/botbox + readOnly: true + - name: config + mountPath: /etc/botbox + readOnly: true + - name: botbox-ca + mountPath: /etc/botbox/mitm + readOnly: true + + containers: + - name: curl-client + image: curlimages/curl:8.11.1 + env: + - name: CURL_CA_BUNDLE + value: /etc/botbox-ca/ca.crt + securityContext: + runAsUser: 1000 + runAsNonRoot: true + allowPrivilegeEscalation: false + command: + - /bin/sh + - -c + - | + set -eu + echo "Waiting for botbox readiness..." + ready=0 + for i in $(seq 1 40); do + if curl -sf http://127.0.0.1:9090/healthz >/dev/null 2>&1; then + ready=1 + break + fi + sleep 2 + done + if [ "$ready" -ne 1 ]; then + echo "readiness check failed" + exit 1 + fi + + echo "--- MITM positive path (expect upstream reachability, often 401 with dummy key) ---" + code="$(curl -sS -o /tmp/openai.out -w '%{http_code}' https://api.openai.com/v1/models || true)" + echo "MITM_OPENAI_CODE=${code}" + cat /tmp/openai.out || true + if [ "${code}" = "000" ]; then + echo "openai request timed out or could not connect" + exit 1 + fi + + echo "--- MITM negative allowlist path (expect deny) ---" + deny_code="$(curl -sS -o /tmp/deny.out -w '%{http_code}' https://example.com/ -m 8 || true)" + echo "MITM_DENY_CODE=${deny_code}" + if [ "${deny_code}" != "403" ]; then + echo "expected deny code 403 for non-allowlisted host" + cat /tmp/deny.out || true + exit 1 + fi + + echo "--- Direct bypass checks ---" + echo "TCP/443 direct probe" + tcp_code="$(curl -sS -o /tmp/tcp.out -w '%{http_code}' --connect-timeout 2 -m 6 https://cloudflare.com/ || true)" + echo "MITM_TCP443_CODE=${tcp_code}" + if [ "${tcp_code}" != "403" ] && [ "${tcp_code}" != "000" ]; then + echo "unexpected TCP/443 probe result" + cat /tmp/tcp.out || true + exit 1 + fi + + echo "UDP/443 direct probe (HTTP/3 if available)" + if curl --help all 2>/dev/null | grep -q -- '--http3'; then + set +e + curl -sS --http3 --connect-timeout 2 -m 6 https://cloudflare-quic.com/ >/tmp/udp.out 2>&1 + udp_rc=$? + set -e + echo "MITM_UDP443_RC=${udp_rc}" + if [ "${udp_rc}" -eq 0 ]; then + echo "expected UDP/443 probe to fail" + cat /tmp/udp.out || true + exit 1 + fi + else + echo "MITM_HTTP3_UNSUPPORTED=1" + fi + volumeMounts: + - name: botbox-ca + mountPath: /etc/botbox-ca + readOnly: true + + volumes: + - name: secrets + secret: + secretName: botbox-secrets + - name: config + configMap: + name: botbox-config + - name: botbox-ca + emptyDir: {} diff --git a/tests/e2e/run-egress-test.sh b/tests/e2e/run-egress-test.sh new file mode 100755 index 0000000..3b7c358 --- /dev/null +++ b/tests/e2e/run-egress-test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NAMESPACE="egress-test" +MANIFEST="tests/e2e/manifests/egress-test.yaml" +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}" + +k() { + if [[ -n "${KUBECTL_CONTEXT}" ]]; then + kubectl --context "${KUBECTL_CONTEXT}" "$@" + else + kubectl "$@" + fi +} + +cleanup() { + k delete namespace "${NAMESPACE}" --wait=false >/dev/null 2>&1 || true +} +trap cleanup EXIT + +k apply -f "${MANIFEST}" +k -n "${NAMESPACE}" wait --for=jsonpath='{.status.phase}'=Succeeded pod/egress-test --timeout=180s + +logs="$(k -n "${NAMESPACE}" logs egress-test -c curl-client)" +echo "${logs}" + +if ! printf '%s\n' "${logs}" | grep -q -- "--- curl exit code: 0 ---"; then + echo "E2E failed: non-MITM egress smoke test did not complete successfully" + exit 1 +fi + +echo "Non-MITM E2E checks passed." diff --git a/tests/e2e/run-kind-acceptance.sh b/tests/e2e/run-kind-acceptance.sh new file mode 100755 index 0000000..1692e5c --- /dev/null +++ b/tests/e2e/run-kind-acceptance.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-${CLUSTER_NAME}}" +CREATE_CLUSTER="${CREATE_KIND_CLUSTER:-0}" +SKIP_IMAGE_BUILD="${SKIP_IMAGE_BUILD:-0}" +RUN_EGRESS_TEST="${RUN_EGRESS_TEST:-1}" +RUN_MITM_TEST="${RUN_MITM_TEST:-1}" + +require_cmd() { + local cmd="$1" + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Required command not found: ${cmd}" >&2 + exit 1 + fi +} + +kind_cluster_exists() { + kind get clusters 2>/dev/null | grep -Fxq "${CLUSTER_NAME}" +} + +kubectl_context_exists() { + kubectl config get-contexts -o name | grep -Fxq "${KUBECTL_CONTEXT}" +} + +build_botbox_image() { + echo "[1/5] Building botbox:test from repository Dockerfile..." + if docker build -t botbox:test "${ROOT_DIR}"; then + return 0 + fi + + echo "Primary Dockerfile build failed; retrying with rust:1.93-bookworm fallback..." + local tmp_dockerfile + tmp_dockerfile="$(mktemp)" + cat > "${tmp_dockerfile}" <<'EOF' +FROM rust:1.93-bookworm AS builder + +WORKDIR /app +COPY Cargo.toml Cargo.lock* ./ + +RUN mkdir src && \ + echo 'fn main() {}' > src/main.rs && \ + echo '' > src/lib.rs +RUN cargo build --release +RUN rm -rf src + +COPY src/ src/ +RUN touch src/main.rs src/lib.rs && cargo build --release + +FROM gcr.io/distroless/cc-debian12:nonroot +COPY --from=builder /app/target/release/botbox /botbox +COPY config.yaml /etc/botbox/config.yaml + +EXPOSE 8080 8443 9090 +ENTRYPOINT ["/botbox"] +CMD ["--config", "/etc/botbox/config.yaml"] +EOF + + if ! docker build -f "${tmp_dockerfile}" -t botbox:test "${ROOT_DIR}"; then + rm -f "${tmp_dockerfile}" + return 1 + fi + rm -f "${tmp_dockerfile}" +} + +build_iptables_image() { + echo "[2/5] Building botbox-iptables-init:test..." + docker build --target iptables-init -t botbox-iptables-init:test "${ROOT_DIR}" +} + +run_e2e_tests() { + if [[ "${RUN_EGRESS_TEST}" == "1" ]]; then + echo "[4/5] Running non-MITM E2E test..." + KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" bash "${ROOT_DIR}/tests/e2e/run-egress-test.sh" + fi + + if [[ "${RUN_MITM_TEST}" == "1" ]]; then + echo "[5/5] Running MITM E2E test..." + KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" bash "${ROOT_DIR}/tests/e2e/run-mitm-test.sh" + fi +} + +require_cmd docker +require_cmd kind +require_cmd kubectl + +if [[ "${RUN_EGRESS_TEST}" != "1" && "${RUN_MITM_TEST}" != "1" ]]; then + echo "Nothing to run: both RUN_EGRESS_TEST and RUN_MITM_TEST are disabled." >&2 + exit 1 +fi + +if ! kind_cluster_exists; then + if [[ "${CREATE_CLUSTER}" == "1" ]]; then + echo "kind cluster '${CLUSTER_NAME}' not found; creating it..." + kind create cluster --name "${CLUSTER_NAME}" + else + echo "kind cluster '${CLUSTER_NAME}' not found." >&2 + echo "Create it first or rerun with CREATE_KIND_CLUSTER=1." >&2 + exit 1 + fi +fi + +if ! kubectl_context_exists; then + echo "kubectl context '${KUBECTL_CONTEXT}' not found." >&2 + echo "Set KUBECTL_CONTEXT to a valid context for cluster '${CLUSTER_NAME}'." >&2 + exit 1 +fi + +if [[ "${SKIP_IMAGE_BUILD}" != "1" ]]; then + build_botbox_image + build_iptables_image +else + echo "[1/5] SKIP_IMAGE_BUILD=1 -> skipping image build." +fi + +echo "[3/5] Loading images into kind cluster '${CLUSTER_NAME}'..." +kind load docker-image --name "${CLUSTER_NAME}" botbox:test botbox-iptables-init:test + +run_e2e_tests + +echo "All requested acceptance tests passed on context '${KUBECTL_CONTEXT}'." diff --git a/tests/e2e/run-mitm-test.sh b/tests/e2e/run-mitm-test.sh new file mode 100755 index 0000000..d543384 --- /dev/null +++ b/tests/e2e/run-mitm-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NAMESPACE="mitm-test" +MANIFEST="tests/e2e/manifests/mitm-test.yaml" +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}" + +k() { + if [[ -n "${KUBECTL_CONTEXT}" ]]; then + kubectl --context "${KUBECTL_CONTEXT}" "$@" + else + kubectl "$@" + fi +} + +cleanup() { + k delete namespace "${NAMESPACE}" --wait=false >/dev/null 2>&1 || true +} +trap cleanup EXIT + +k apply -f "${MANIFEST}" +k -n "${NAMESPACE}" wait --for=jsonpath='{.status.phase}'=Succeeded pod/mitm-test --timeout=300s + +logs="$(k -n "${NAMESPACE}" logs mitm-test -c curl-client)" +echo "${logs}" + +openai_code="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_OPENAI_CODE=//p' | tail -n1)" +deny_code="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_DENY_CODE=//p' | tail -n1)" +tcp_code="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_TCP443_CODE=//p' | tail -n1)" +udp_rc="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_UDP443_RC=//p' | tail -n1)" +http3_unsupported="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_HTTP3_UNSUPPORTED=//p' | tail -n1)" + +if [[ -z "${openai_code}" || "${openai_code}" == "000" ]]; then + echo "E2E failed: MITM_OPENAI_CODE is missing or indicates timeout (${openai_code:-missing})" + exit 1 +fi + +if [[ "${deny_code}" != "403" ]]; then + echo "E2E failed: expected MITM_DENY_CODE=403, got '${deny_code:-missing}'" + exit 1 +fi + +if [[ "${tcp_code}" != "403" && "${tcp_code}" != "000" ]]; then + echo "E2E failed: expected MITM_TCP443_CODE to be 403 or 000, got '${tcp_code:-missing}'" + exit 1 +fi + +if [[ -n "${udp_rc}" ]]; then + if [[ "${udp_rc}" == "0" ]]; then + echo "E2E failed: UDP/443 probe succeeded unexpectedly" + exit 1 + fi +elif [[ "${http3_unsupported}" != "1" ]]; then + echo "E2E failed: neither MITM_UDP443_RC nor MITM_HTTP3_UNSUPPORTED marker found" + exit 1 +fi + +echo "MITM E2E checks passed." diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 61313a5..04b1f98 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -182,6 +182,62 @@ async fn test_allowed_host_without_rewrite_forwards() { ); } +#[tokio::test] +async fn test_non_mitm_http_path_regression_allow_and_deny() { + // Regression guard: existing non-MITM HTTP path should keep allow/deny semantics. + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/smoke")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + let mock_addr = mock_server.address(); + let allowed_host = format!("127.0.0.1:{}", mock_addr.port()); + + let ctx = TestProxy::start( + &[TestRule { + host: allowed_host.clone(), + header_rewrites: vec![], + allowed_ports: None, + }], + None, + ) + .await; + + // Allowed host should not be denied (it may still upstream-fail due TLS mismatch in test harness). + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut stream = tokio::net::TcpStream::connect(ctx.proxy_addr) + .await + .unwrap(); + let allowed_req = format!( + "GET http://{}/smoke HTTP/1.1\r\nHost: {}\r\n\r\n", + allowed_host, allowed_host + ); + stream.write_all(allowed_req.as_bytes()).await.unwrap(); + + let mut buf = vec![0u8; 4096]; + let n = stream.read(&mut buf).await.unwrap(); + let allowed_resp = String::from_utf8_lossy(&buf[..n]); + assert!( + !allowed_resp.contains("403"), + "allowed host unexpectedly denied: {}", + allowed_resp + ); + + // Unknown host should still be denied. + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::http(format!("http://{}", ctx.proxy_addr)).unwrap()) + .build() + .unwrap(); + let denied = client + .get("http://definitely-not-allowlisted.example/path") + .send() + .await + .unwrap(); + assert_eq!(denied.status(), 403); +} + #[tokio::test] async fn test_missing_secret_returns_500() { let ctx = TestProxy::start( diff --git a/tests/mitm_unit_test.rs b/tests/mitm_unit_test.rs new file mode 100644 index 0000000..1bdcbb6 --- /dev/null +++ b/tests/mitm_unit_test.rs @@ -0,0 +1,1002 @@ +use anyhow::{anyhow, Context, Result}; +use rustls::pki_types::{pem::PemObject, CertificateDer, ServerName}; +use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +// Long-lived fallback test CA material for environments without openssl. +// Valid until year 2126; generated as EC P-256 with PKCS#8 private key. +const FALLBACK_CA_CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- +MIIBmjCCAUGgAwIBAgIUI4Jb5Mjw1HiB/Ay/jXZt64p6LtAwCgYIKoZIzj0EAwIw +IjEgMB4GA1UEAwwXYm90Ym94LWZhbGxiYWNrLXRlc3QtY2EwIBcNMjYwMjExMDIy +NzQ0WhgPMjEyNjAxMTgwMjI3NDRaMCIxIDAeBgNVBAMMF2JvdGJveC1mYWxsYmFj +ay10ZXN0LWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGl/LQkZ/OiA3llsw +Ce/PKGPQhXO5+rFfPv2uCsdL7Yo52TjjnUkgFstgPOKrn4ra3HZaJ0HfDRNnSc+C +TvfHe6NTMFEwHQYDVR0OBBYEFMvSr2iFXMCpAOcwCI2rhRRrE/elMB8GA1UdIwQY +MBaAFMvSr2iFXMCpAOcwCI2rhRRrE/elMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZI +zj0EAwIDRwAwRAIgHO0fAIMmnYo4xDhp2w2/a7X9/mUU/G0+PdL8afVhSsQCIFBi +M9tLhzGxzKc9S4Tw4l670tl8W730MI/73TFnwupX +-----END CERTIFICATE----- +"#; + +const FALLBACK_CA_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyVt3DfTNlKcB2BSO +CM2bCNh+hdVhQuYsueCZV7MIq7uhRANCAAQaX8tCRn86IDeWWzAJ788oY9CFc7n6 +sV8+/a4Kx0vtijnZOOOdSSAWy2A84qufitrcdlonQd8NE2dJz4JO98d7 +-----END PRIVATE KEY----- +"#; + +struct MitmSpec { + rules_yaml: String, + write_ca_files: bool, + initial_secrets: Vec<(String, String)>, + max_connections: u32, + deny_handshake_on_disallowed_sni: bool, + cert_ttl_seconds: u64, + cert_cache_size: u64, + cert_cache_ttl_seconds: u64, +} + +impl Default for MitmSpec { + fn default() -> Self { + Self { + rules_yaml: rules_allow_hosts(&["localhost"]), + write_ca_files: true, + initial_secrets: Vec::new(), + max_connections: 256, + deny_handshake_on_disallowed_sni: false, + cert_ttl_seconds: 86_400, + cert_cache_size: 1024, + cert_cache_ttl_seconds: 3600, + } + } +} + +fn openssl_available() -> bool { + Command::new("openssl") + .arg("version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn generate_test_ca_material(ca_cert_path: &Path, ca_key_path: &Path) -> Result> { + if openssl_available() { + let key_path = ca_key_path.display().to_string(); + let cert_path = ca_cert_path.display().to_string(); + + let (key_status, _, key_stderr) = run_openssl(&[ + "genpkey", + "-algorithm", + "EC", + "-pkeyopt", + "ec_paramgen_curve:P-256", + "-out", + &key_path, + ])?; + if !key_status.success() { + return Err(anyhow!("openssl genpkey failed: {}", key_stderr)); + } + + let (cert_status, _, cert_stderr) = run_openssl(&[ + "req", + "-x509", + "-new", + "-key", + &key_path, + "-sha256", + "-days", + "3650", + "-subj", + "/CN=botbox-test-ca", + "-out", + &cert_path, + ])?; + if !cert_status.success() { + return Err(anyhow!("openssl req -x509 failed: {}", cert_stderr)); + } + + return std::fs::read(ca_cert_path).with_context(|| { + format!( + "failed to read generated CA cert PEM from {}", + ca_cert_path.display() + ) + }); + } + + std::fs::write(ca_cert_path, FALLBACK_CA_CERT_PEM) + .context("failed to write fallback CA certificate")?; + std::fs::write(ca_key_path, FALLBACK_CA_KEY_PEM) + .context("failed to write fallback CA private key")?; + Ok(FALLBACK_CA_CERT_PEM.as_bytes().to_vec()) +} + +struct BotboxProcess { + child: Child, + _tmp: TempDir, + metrics_addr: SocketAddr, + mitm_addr: SocketAddr, + ca_cert_pem: Vec, + secrets_dir: std::path::PathBuf, + stdout_log_path: PathBuf, + stderr_log_path: PathBuf, +} + +impl Drop for BotboxProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl BotboxProcess { + fn start(spec: MitmSpec) -> Self { + let tmp = TempDir::new().expect("failed to create tempdir"); + let config_path = tmp.path().join("config.yaml"); + let secrets_dir = tmp.path().join("secrets"); + let ca_cert_path = tmp.path().join("ca.crt"); + let ca_key_path = tmp.path().join("ca.key"); + let stdout_log_path = tmp.path().join("botbox.stdout.log"); + let stderr_log_path = tmp.path().join("botbox.stderr.log"); + + std::fs::create_dir_all(&secrets_dir).expect("failed to create secrets dir"); + for (k, v) in &spec.initial_secrets { + std::fs::write(secrets_dir.join(k), v).expect("failed to write initial secret"); + } + + let mut ca_cert_pem = Vec::new(); + if spec.write_ca_files { + ca_cert_pem = generate_test_ca_material(&ca_cert_path, &ca_key_path) + .expect("failed to generate test CA material"); + } + + let listen_port = pick_free_port(); + let metrics_port = pick_free_port(); + let mitm_port = pick_free_port(); + + let config = format!( + r#"listen_addr: "127.0.0.1" +listen_port: {listen_port} +metrics_port: {metrics_port} +secrets_dir: "{secrets_dir}" +max_connections: {max_connections} + +egress_policy: + default_action: deny + rules: +{rules_yaml} + +mitm: + enabled: true + listen_addr: "127.0.0.1" + listen_port: {mitm_port} + ca_cert_path: "{ca_cert_path}" + ca_key_path: "{ca_key_path}" + enforce_sni_host_match: true + deny_handshake_on_disallowed_sni: {deny_handshake_on_disallowed_sni} + cert_ttl_seconds: {cert_ttl_seconds} + cert_cache_size: {cert_cache_size} + cert_cache_ttl_seconds: {cert_cache_ttl_seconds} + handshake_timeout_ms: 5000 +"#, + listen_port = listen_port, + metrics_port = metrics_port, + secrets_dir = secrets_dir.display(), + max_connections = spec.max_connections, + rules_yaml = spec.rules_yaml, + mitm_port = mitm_port, + ca_cert_path = ca_cert_path.display(), + ca_key_path = ca_key_path.display(), + deny_handshake_on_disallowed_sni = spec.deny_handshake_on_disallowed_sni, + cert_ttl_seconds = spec.cert_ttl_seconds, + cert_cache_size = spec.cert_cache_size, + cert_cache_ttl_seconds = spec.cert_cache_ttl_seconds, + ); + + std::fs::write(&config_path, config).expect("failed to write config"); + + let bin_path = env!("CARGO_BIN_EXE_botbox"); + + let stdout_file = std::fs::File::create(&stdout_log_path) + .expect("failed to create botbox stdout log file"); + let stderr_file = std::fs::File::create(&stderr_log_path) + .expect("failed to create botbox stderr log file"); + + let child = Command::new(bin_path) + .arg("--config") + .arg(&config_path) + .stdout(Stdio::from(stdout_file)) + .stderr(Stdio::from(stderr_file)) + .spawn() + .expect("failed to spawn botbox process"); + + Self { + child, + _tmp: tmp, + metrics_addr: SocketAddr::from(([127, 0, 0, 1], metrics_port)), + mitm_addr: SocketAddr::from(([127, 0, 0, 1], mitm_port)), + ca_cert_pem, + secrets_dir, + stdout_log_path, + stderr_log_path, + } + } + + fn write_secret(&self, name: &str, value: &str) { + std::fs::write(self.secrets_dir.join(name), value).expect("failed to write secret"); + } + + fn wait_for_healthz_endpoint(&mut self, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Some(status) = self.try_wait() { + panic!( + "botbox exited before /healthz became available: {}\n{}", + status, + self.log_tail(80) + ); + } + + if healthz_status(self.metrics_addr).is_some() { + return; + } + + thread::sleep(Duration::from_millis(100)); + } + panic!( + "/healthz endpoint did not become reachable within {:?}\n{}", + timeout, + self.log_tail(80) + ); + } + + fn wait_for_exit(&mut self, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Some(status) = self.try_wait() { + return Some(status); + } + thread::sleep(Duration::from_millis(100)); + } + None + } + + fn try_wait(&mut self) -> Option { + self.child + .try_wait() + .expect("failed to poll botbox process status") + } + + fn log_tail(&self, max_lines: usize) -> String { + fn tail_of(path: &Path, max_lines: usize) -> String { + let content = std::fs::read_to_string(path).unwrap_or_else(|_| "".into()); + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + lines[start..].join("\n") + } + + format!( + "--- botbox stdout (tail) ---\n{}\n--- botbox stderr (tail) ---\n{}\n", + tail_of(&self.stdout_log_path, max_lines), + tail_of(&self.stderr_log_path, max_lines) + ) + } +} + +fn pick_free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("failed to bind ephemeral port") + .local_addr() + .expect("failed to read local addr") + .port() +} + +fn rules_allow_hosts(hosts: &[&str]) -> String { + hosts + .iter() + .map(|h| format!(" - host: {}\n action: allow\n", h)) + .collect::() +} + +fn rules_allow_host_with_secret_rewrite(host: &str, secret_ref: &str) -> String { + format!( + r#" - host: {host} + action: allow + header_rewrites: + - name: Authorization + value: "Bearer {{value}}" + secret_ref: {secret_ref} +"# + ) +} + +fn healthz_status(metrics_addr: SocketAddr) -> Option { + let mut stream = TcpStream::connect_timeout(&metrics_addr, Duration::from_millis(250)).ok()?; + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + stream + .set_write_timeout(Some(Duration::from_millis(500))) + .ok()?; + + let req = b"GET /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; + stream.write_all(req).ok()?; + + let mut buf = String::new(); + stream.read_to_string(&mut buf).ok()?; + parse_status_code(&buf) +} + +fn metrics_body(metrics_addr: SocketAddr) -> Option { + let mut stream = TcpStream::connect_timeout(&metrics_addr, Duration::from_millis(250)).ok()?; + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + stream + .set_write_timeout(Some(Duration::from_millis(500))) + .ok()?; + + let req = b"GET /metrics HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; + stream.write_all(req).ok()?; + + let mut raw = String::new(); + stream.read_to_string(&mut raw).ok()?; + let (_, body) = raw.split_once("\r\n\r\n")?; + Some(body.to_string()) +} + +fn tls_handshake_counter(metrics_text: &str, result: &str) -> u64 { + let prefix = format!("botbox_tls_handshakes_total{{result=\"{}\"}} ", result); + metrics_text + .lines() + .find_map(|line| { + line.strip_prefix(&prefix) + .and_then(|v| v.trim().parse::().ok()) + }) + .unwrap_or(0) +} + +fn tls_handshake_counter_total(metrics_text: &str) -> u64 { + metrics_text + .lines() + .filter(|line| line.starts_with("botbox_tls_handshakes_total{")) + .filter_map(|line| line.split_whitespace().nth(1)) + .filter_map(|v| v.parse::().ok()) + .sum() +} + +fn parse_status_code(raw_http: &str) -> Option { + raw_http + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) +} + +fn load_root_store(ca_cert_pem: &[u8]) -> Result { + let mut root_store = RootCertStore::empty(); + let certs = + CertificateDer::pem_slice_iter(ca_cert_pem).collect::, _>>()?; + if certs.is_empty() { + return Err(anyhow!("no CA certificates found in PEM")); + } + + for cert in certs { + root_store + .add(cert) + .context("failed to add test CA cert to root store")?; + } + Ok(root_store) +} + +fn connect_tls_stream( + mitm_addr: SocketAddr, + sni_dns_name: &str, + ca_cert_pem: &[u8], +) -> Result> { + let _ = rustls::crypto::ring::default_provider().install_default(); + let root_store = load_root_store(ca_cert_pem)?; + let client_config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + let server_name = ServerName::try_from(sni_dns_name.to_string()) + .map_err(|_| anyhow!("invalid SNI DNS name: {}", sni_dns_name))?; + + let connection = ClientConnection::new(Arc::new(client_config), server_name) + .context("failed to build rustls client connection")?; + + let socket = TcpStream::connect_timeout(&mitm_addr, Duration::from_secs(2)) + .with_context(|| format!("failed to connect to MITM listener at {}", mitm_addr))?; + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .context("failed to set read timeout")?; + socket + .set_write_timeout(Some(Duration::from_secs(2))) + .context("failed to set write timeout")?; + + let mut tls = StreamOwned::new(connection, socket); + tls.conn + .complete_io(&mut tls.sock) + .context("TLS handshake failed")?; + Ok(tls) +} + +fn tls_handshake_leaf_cert( + mitm_addr: SocketAddr, + sni_dns_name: &str, + ca_cert_pem: &[u8], +) -> Result> { + let tls = connect_tls_stream(mitm_addr, sni_dns_name, ca_cert_pem)?; + let leaf = tls + .conn + .peer_certificates() + .and_then(|certs| certs.first()) + .ok_or_else(|| anyhow!("no peer certificate in TLS connection"))?; + + Ok(leaf.as_ref().to_vec()) +} + +fn tls_http_request( + mitm_addr: SocketAddr, + sni_dns_name: &str, + host_header: &str, + path: &str, + ca_cert_pem: &[u8], +) -> Result { + let mut tls = connect_tls_stream(mitm_addr, sni_dns_name, ca_cert_pem)?; + let req = format!( + "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", + path, host_header + ); + tls.write_all(req.as_bytes()) + .context("failed to write HTTP request over TLS")?; + tls.flush().context("failed to flush TLS stream")?; + + let mut buf = vec![0u8; 16 * 1024]; + let n = tls + .read(&mut buf) + .context("failed to read HTTP response over TLS")?; + if n == 0 { + return Err(anyhow!("received empty HTTP response")); + } + Ok(String::from_utf8_lossy(&buf[..n]).into_owned()) +} + +fn run_openssl(args: &[&str]) -> Result<(ExitStatus, String, String)> { + let output = Command::new("openssl") + .args(args) + .output() + .with_context(|| format!("failed to execute openssl with args: {:?}", args))?; + + Ok(( + output.status, + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + )) +} + +#[test] +fn mitm_config_fails_closed_when_ca_files_are_missing() { + let spec = MitmSpec { + write_ca_files: false, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + + let exit = proc.wait_for_exit(Duration::from_secs(3)); + let status = exit.unwrap_or_else(|| { + panic!( + "expected startup failure when CA files are missing, but process stayed alive\n{}", + proc.log_tail(80) + ) + }); + assert!( + !status.success(), + "MITM enabled must fail startup when CA files are missing" + ); +} + +#[test] +fn mitm_readiness_requires_required_secrets_and_ca_loaded() { + // required secret missing -> /healthz should stay 503 + let spec = MitmSpec { + rules_yaml: rules_allow_host_with_secret_rewrite("localhost", "openai-api-key"), + initial_secrets: Vec::new(), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + assert_eq!( + healthz_status(proc.metrics_addr), + Some(503), + "missing required secret must keep readiness at 503" + ); + + proc.write_secret("openai-api-key", "test-not-a-real-key"); + + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + if healthz_status(proc.metrics_addr) == Some(200) { + return; + } + thread::sleep(Duration::from_millis(250)); + } + + panic!("readiness did not flip to 200 after required secret was added"); +} + +#[test] +fn mitm_sni_validation_accepts_valid_ascii_and_punycode_hosts() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost", "api.openai.com", "xn--bcher-kva.example"]), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + for host in ["localhost", "api.openai.com", "xn--bcher-kva.example"] { + tls_handshake_leaf_cert(proc.mitm_addr, host, &proc.ca_cert_pem).unwrap_or_else(|e| { + panic!("valid SNI host '{}' should complete handshake: {}", host, e) + }); + } +} + +#[test] +fn mitm_sni_validation_rejects_invalid_or_oversized_hosts() { + let spec = MitmSpec::default(); + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let oversized = format!("{}.example.com", "a".repeat(254)); + for host in ["bad host", "bad_host", "*.example.com", &oversized] { + let result = tls_handshake_leaf_cert(proc.mitm_addr, host, &proc.ca_cert_pem); + assert!( + result.is_err(), + "invalid SNI host '{}' must be rejected", + host + ); + } +} + +#[test] +fn mitm_leaf_certificate_contains_san_dns_expected_validity_and_ca_issuer() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + cert_ttl_seconds: 86_400, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let leaf_der = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .unwrap_or_else(|e| { + panic!( + "expected successful TLS handshake for certificate inspection: {}", + e + ) + }); + assert!( + !leaf_der.is_empty(), + "leaf certificate DER must not be empty" + ); + + if !openssl_available() { + eprintln!("openssl is not available; skipping SAN/TTL/CA verify subprocess checks"); + return; + } + + let inspect_tmp = TempDir::new().expect("failed to create tempdir for openssl inspection"); + let leaf_der_path = inspect_tmp.path().join("leaf.der"); + let leaf_pem_path = inspect_tmp.path().join("leaf.pem"); + let ca_pem_path = inspect_tmp.path().join("ca.crt"); + std::fs::write(&leaf_der_path, &leaf_der).expect("failed to write leaf DER"); + std::fs::write(&ca_pem_path, &proc.ca_cert_pem).expect("failed to write CA PEM"); + + let leaf_der_path = leaf_der_path.display().to_string(); + let leaf_pem_path = leaf_pem_path.display().to_string(); + let ca_pem_path = ca_pem_path.display().to_string(); + + let (text_status, text_stdout, text_stderr) = run_openssl(&[ + "x509", + "-inform", + "der", + "-in", + &leaf_der_path, + "-noout", + "-text", + ]) + .expect("failed to inspect leaf cert via openssl"); + assert!( + text_status.success(), + "openssl x509 -text failed: {}", + text_stderr + ); + assert!( + text_stdout.contains("DNS:localhost"), + "leaf cert SAN must contain DNS:localhost" + ); + assert!( + text_stdout.contains("Issuer: CN = botbox-test-ca") + || text_stdout.contains("Issuer: CN=botbox-test-ca") + || text_stdout.contains("issuer=CN = botbox-test-ca") + || text_stdout.contains("issuer=CN=botbox-test-ca"), + "leaf cert issuer must be the configured CA, got:\n{}", + text_stdout + ); + + let (min_ttl_status, _, min_ttl_stderr) = run_openssl(&[ + "x509", + "-inform", + "der", + "-in", + &leaf_der_path, + "-noout", + "-checkend", + "80000", + ]) + .expect("failed to run openssl -checkend (min ttl)"); + assert!( + min_ttl_status.success(), + "leaf cert should be valid for at least ~22h (checkend=80000): {}", + min_ttl_stderr + ); + + let (max_ttl_status, _, _) = run_openssl(&[ + "x509", + "-inform", + "der", + "-in", + &leaf_der_path, + "-noout", + "-checkend", + "200000", + ]) + .expect("failed to run openssl -checkend (max ttl)"); + assert!( + !max_ttl_status.success(), + "leaf cert should not be valid for ~55h when ttl is configured to 24h" + ); + + let (convert_status, _, convert_stderr) = run_openssl(&[ + "x509", + "-inform", + "der", + "-in", + &leaf_der_path, + "-out", + &leaf_pem_path, + ]) + .expect("failed to convert leaf DER to PEM"); + assert!( + convert_status.success(), + "failed to convert leaf DER to PEM: {}", + convert_stderr + ); + + let (verify_status, verify_stdout, verify_stderr) = + run_openssl(&["verify", "-CAfile", &ca_pem_path, &leaf_pem_path]) + .expect("failed to run openssl verify"); + assert!( + verify_status.success(), + "leaf cert must verify against configured CA. stdout='{}' stderr='{}'", + verify_stdout, + verify_stderr + ); +} + +#[test] +fn mitm_cert_cache_exposes_hit_ttl_expiry_and_lru_eviction_behaviour() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost", "api.openai.com", "files.openai.com"]), + cert_cache_size: 1, + cert_cache_ttl_seconds: 1, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let cert_a1 = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("first localhost handshake should succeed"); + let cert_a2 = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("second localhost handshake should succeed"); + assert_eq!(cert_a1, cert_a2, "expected cache hit for repeated same SNI"); + + let _cert_b = tls_handshake_leaf_cert(proc.mitm_addr, "api.openai.com", &proc.ca_cert_pem) + .expect("api.openai.com handshake should succeed"); + let cert_a3 = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("localhost handshake after eviction should succeed"); + assert_ne!( + cert_a1, cert_a3, + "expected LRU eviction with cache_size=1 after another host was issued" + ); + + let cert_c1 = tls_handshake_leaf_cert(proc.mitm_addr, "files.openai.com", &proc.ca_cert_pem) + .expect("files.openai.com handshake should succeed"); + thread::sleep(Duration::from_secs(2)); + let cert_c2 = tls_handshake_leaf_cert(proc.mitm_addr, "files.openai.com", &proc.ca_cert_pem) + .expect("files.openai.com handshake after TTL should succeed"); + assert_ne!( + cert_c1, cert_c2, + "expected cert cache TTL expiry to force re-issuance" + ); +} + +#[test] +fn mitm_integration_trusted_tls_client_can_send_http1_request() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let resp = tls_http_request( + proc.mitm_addr, + "localhost", + "localhost", + "/", + &proc.ca_cert_pem, + ) + .expect("trusted rustls client should complete TLS and receive an HTTP response"); + + let status = parse_status_code(&resp).expect("response must include an HTTP status line"); + assert!( + (100..600).contains(&status), + "expected a valid HTTP status code over MITM TLS listener, got {}", + status + ); +} + +#[test] +fn mitm_integration_rejects_sni_host_mismatch_with_400() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost", "example.com"]), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let resp = tls_http_request( + proc.mitm_addr, + "localhost", + "example.com", + "/", + &proc.ca_cert_pem, + ) + .expect("SNI/Host mismatch request should still return an HTTP response"); + + assert_eq!( + parse_status_code(&resp), + Some(400), + "SNI/Host mismatch must be rejected with HTTP 400" + ); +} + +#[test] +fn mitm_integration_rejects_non_443_host_port() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let resp = tls_http_request( + proc.mitm_addr, + "localhost", + "localhost:8443", + "/", + &proc.ca_cert_pem, + ) + .expect("non-443 Host request should still return an HTTP response"); + + assert_eq!( + parse_status_code(&resp), + Some(403), + "Host header with non-443 explicit port must be rejected" + ); +} + +#[test] +fn mitm_integration_rejects_absolute_form_request() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + // Send an absolute-form request (http://localhost/path) instead of origin-form (/path). + // This should be rejected with 400 to prevent URI authority bypass. + let mut tls = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("TLS handshake should succeed for allowed host"); + let req = + "GET http://localhost:9999/bypass HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + tls.write_all(req.as_bytes()) + .expect("failed to write absolute-form request"); + tls.flush().expect("failed to flush"); + + let mut buf = vec![0u8; 16 * 1024]; + let n = tls.read(&mut buf).expect("failed to read response"); + assert!(n > 0, "expected non-empty response"); + let resp = String::from_utf8_lossy(&buf[..n]); + assert_eq!( + parse_status_code(&resp), + Some(400), + "absolute-form request must be rejected with 400 on MITM listener" + ); +} + +#[test] +fn mitm_integration_rejects_absolute_form_even_with_matching_sni_host_and_port() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let mut tls = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("TLS handshake should succeed for allowed host"); + let req = "GET https://localhost:443/safe HTTP/1.1\r\nHost: localhost:443\r\nConnection: close\r\n\r\n"; + tls.write_all(req.as_bytes()) + .expect("failed to write absolute-form request"); + tls.flush().expect("failed to flush"); + + let mut buf = vec![0u8; 16 * 1024]; + let n = tls.read(&mut buf).expect("failed to read response"); + assert!(n > 0, "expected non-empty response"); + let resp = String::from_utf8_lossy(&buf[..n]); + assert_eq!( + parse_status_code(&resp), + Some(400), + "absolute-form request must be rejected even when Host/SNI/port match" + ); +} + +#[test] +fn mitm_config_rejects_cert_cache_ttl_larger_than_cert_ttl() { + let spec = MitmSpec { + cert_ttl_seconds: 60, + cert_cache_ttl_seconds: 61, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + + let exit = proc.wait_for_exit(Duration::from_secs(3)); + let status = exit.unwrap_or_else(|| { + panic!( + "expected startup failure when cert cache TTL exceeds cert TTL, but process stayed alive\n{}", + proc.log_tail(80) + ) + }); + assert!( + !status.success(), + "MITM config must fail startup when cert_cache_ttl_seconds > cert_ttl_seconds" + ); +} + +#[test] +fn mitm_integration_enforces_connection_limit_on_mitm_listener() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + max_connections: 1, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let first_conn = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("first TLS connection should succeed"); + thread::sleep(Duration::from_millis(150)); + + let second = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem); + drop(first_conn); + assert!( + second.is_err(), + "second MITM TLS connection should be rejected when max_connections=1 and one connection is held open" + ); +} + +#[test] +fn mitm_metrics_tls_handshakes_are_counted_once_per_connection() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + deny_handshake_on_disallowed_sni: true, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + .expect("successful handshake must succeed"); + let disallowed = + tls_handshake_leaf_cert(proc.mitm_addr, "disallowed.example.com", &proc.ca_cert_pem); + assert!( + disallowed.is_err(), + "disallowed SNI handshake should fail when deny_handshake_on_disallowed_sni=true" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut latest_metrics = String::new(); + while Instant::now() < deadline { + if let Some(m) = metrics_body(proc.metrics_addr) { + let ok = tls_handshake_counter(&m, "ok"); + let io_error = tls_handshake_counter(&m, "io_error"); + latest_metrics = m; + if ok >= 1 && io_error >= 1 { + break; + } + } + thread::sleep(Duration::from_millis(100)); + } + + assert_eq!( + tls_handshake_counter(&latest_metrics, "ok"), + 1, + "one successful handshake should be counted exactly once" + ); + assert_eq!( + tls_handshake_counter(&latest_metrics, "io_error"), + 1, + "one failed handshake should be counted exactly once" + ); + assert_eq!( + tls_handshake_counter_total(&latest_metrics), + 2, + "tls_handshakes_total should not be double-counted across resolver + accept loop" + ); +} + +#[test] +fn mitm_integration_disallowed_sni_handshake_can_be_denied() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + deny_handshake_on_disallowed_sni: true, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let result = + tls_handshake_leaf_cert(proc.mitm_addr, "disallowed.example.com", &proc.ca_cert_pem); + assert!( + result.is_err(), + "deny_handshake_on_disallowed_sni=true must fail the TLS handshake for disallowed SNI" + ); +} + +#[test] +fn mitm_integration_disallowed_sni_can_return_http_403_when_handshake_denial_is_disabled() { + let spec = MitmSpec { + rules_yaml: rules_allow_hosts(&["localhost"]), + deny_handshake_on_disallowed_sni: false, + ..MitmSpec::default() + }; + let mut proc = BotboxProcess::start(spec); + proc.wait_for_healthz_endpoint(Duration::from_secs(5)); + + let resp = tls_http_request( + proc.mitm_addr, + "disallowed.example.com", + "disallowed.example.com", + "/", + &proc.ca_cert_pem, + ) + .expect("disallowed SNI should still be able to produce HTTP 403 when handshake denial is off"); + + assert_eq!( + parse_status_code(&resp), + Some(403), + "deny_handshake_on_disallowed_sni=false should preserve HTTP-level deny semantics (403)" + ); +} From a9d83a14f7d93f1d026ba33f4869b7446b467576 Mon Sep 17 00:00:00 2001 From: reoring Date: Wed, 11 Feb 2026 22:40:30 +0900 Subject: [PATCH 2/5] feat: rename MITM mode to HTTPS interception Clarify terminology and update config keys, env vars, metrics, and E2E tests. --- Cargo.toml | 2 +- README.md | 2 +- scripts/iptables-init.sh | 24 +- src/config.rs | 132 +++++----- src/{mitm.rs => https_interception.rs} | 98 +++---- src/lib.rs | 2 +- src/main.rs | 71 +++--- src/metrics.rs | 43 ++-- tests/at/README.md | 2 +- ...test.yaml => https-interception-test.yaml} | 40 +-- tests/e2e/run-egress-test.sh | 4 +- tests/e2e/run-https-interception-test.sh | 59 +++++ tests/e2e/run-kind-acceptance.sh | 14 +- tests/e2e/run-mitm-test.sh | 59 ----- ...est.rs => https_interception_unit_test.rs} | 240 ++++++++++-------- tests/integration_test.rs | 4 +- 16 files changed, 414 insertions(+), 382 deletions(-) rename src/{mitm.rs => https_interception.rs} (84%) rename tests/e2e/manifests/{mitm-test.yaml => https-interception-test.yaml} (83%) create mode 100755 tests/e2e/run-https-interception-test.sh delete mode 100755 tests/e2e/run-mitm-test.sh rename tests/{mitm_unit_test.rs => https_interception_unit_test.rs} (80%) diff --git a/Cargo.toml b/Cargo.toml index 28ed459..4cdd4f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ rustls = "0.23" tokio-rustls = "0.26" webpki-roots = "0.26" -# MITM certificate generation +# HTTPS interception certificate generation rcgen = { version = "0.14", features = ["pem", "x509-parser"] } lru = "0.12" time = "0.3" diff --git a/README.md b/README.md index 800541d..7eb88b3 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ tests/e2e/run-kind-acceptance.sh ```bash tests/e2e/run-egress-test.sh -tests/e2e/run-mitm-test.sh +tests/e2e/run-https-interception-test.sh ``` ### 6. Run unit tests diff --git a/scripts/iptables-init.sh b/scripts/iptables-init.sh index 908f489..61be64c 100644 --- a/scripts/iptables-init.sh +++ b/scripts/iptables-init.sh @@ -12,8 +12,8 @@ set -eu PROXY_UID="${BOTBOX_UID:-1337}" PROXY_PORT="${BOTBOX_PROXY_PORT:-8080}" REDIRECT_FROM_PORT="${BOTBOX_REDIRECT_FROM_PORT:-80}" -ENABLE_MITM="${BOTBOX_ENABLE_MITM:-0}" -MITM_PORT="${BOTBOX_MITM_PORT:-8443}" +ENABLE_HTTPS_INTERCEPTION="${BOTBOX_ENABLE_HTTPS_INTERCEPTION:-0}" +HTTPS_INTERCEPTION_PORT="${BOTBOX_HTTPS_INTERCEPTION_PORT:-8443}" NAT_CHAIN="${BOTBOX_NAT_CHAIN:-EGRESS_REDIRECT}" FILTER_CHAIN="${BOTBOX_FILTER_CHAIN:-EGRESS_FILTER}" @@ -28,14 +28,14 @@ ipt_nat() { iptables -w "${WAIT_SECONDS}" -t nat "$@" } -# Guard: MITM + REDIRECT_FROM_PORT=443 conflict. +# Guard: HTTPS interception + REDIRECT_FROM_PORT=443 conflict. # When both are set, the HTTP redirect rule matches port 443 first and sends -# traffic to the plain-HTTP proxy, making the MITM REDIRECT rule unreachable. -if [ "${ENABLE_MITM}" = "1" ] && [ "${REDIRECT_FROM_PORT}" = "443" ]; then - echo "ERROR: BOTBOX_ENABLE_MITM=1 and BOTBOX_REDIRECT_FROM_PORT=443 conflict." >&2 +# traffic to the plain-HTTP proxy, making the HTTPS interception REDIRECT rule unreachable. +if [ "${ENABLE_HTTPS_INTERCEPTION}" = "1" ] && [ "${REDIRECT_FROM_PORT}" = "443" ]; then + echo "ERROR: BOTBOX_ENABLE_HTTPS_INTERCEPTION=1 and BOTBOX_REDIRECT_FROM_PORT=443 conflict." >&2 echo " Port 443 traffic would be redirected to the HTTP proxy (port ${PROXY_PORT})" >&2 - echo " instead of the MITM listener (port ${MITM_PORT})." >&2 - echo " Use BOTBOX_REDIRECT_FROM_PORT=80 (default) with BOTBOX_ENABLE_MITM=1." >&2 + echo " instead of the HTTPS interception listener (port ${HTTPS_INTERCEPTION_PORT})." >&2 + echo " Use BOTBOX_REDIRECT_FROM_PORT=80 (default) with BOTBOX_ENABLE_HTTPS_INTERCEPTION=1." >&2 exit 1 fi @@ -55,10 +55,10 @@ ipt_nat -A "${NAT_CHAIN}" -o lo -j RETURN ipt_nat -A "${NAT_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN ipt_nat -A "${NAT_CHAIN}" -p tcp --dport "${REDIRECT_FROM_PORT}" -j REDIRECT --to-port "${PROXY_PORT}" -# MITM: redirect outbound HTTPS (port 443) to MITM listener -if [ "${ENABLE_MITM}" = "1" ]; then - echo " mitm_port=${MITM_PORT} (MITM enabled)" - ipt_nat -A "${NAT_CHAIN}" -p tcp --dport 443 -j REDIRECT --to-port "${MITM_PORT}" +# HTTPS interception: redirect outbound HTTPS (port 443) to interception listener +if [ "${ENABLE_HTTPS_INTERCEPTION}" = "1" ]; then + echo " https_interception_port=${HTTPS_INTERCEPTION_PORT} (HTTPS interception enabled)" + ipt_nat -A "${NAT_CHAIN}" -p tcp --dport 443 -j REDIRECT --to-port "${HTTPS_INTERCEPTION_PORT}" fi ipt_nat -I OUTPUT 1 -p tcp -j "${NAT_CHAIN}" diff --git a/src/config.rs b/src/config.rs index a25a432..5f8ac15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,7 +21,7 @@ pub struct Config { pub max_connections: Option, pub allow_non_loopback: Option, pub egress_policy: EgressPolicy, - pub mitm: Option, + pub https_interception: Option, } #[derive(Debug, Deserialize, Clone)] @@ -47,7 +47,7 @@ pub struct HeaderRewrite { } #[derive(Debug, Deserialize, Clone)] -pub struct MitmConfig { +pub struct HttpsInterceptionConfig { pub enabled: bool, pub listen_addr: Option, pub listen_port: Option, @@ -61,7 +61,7 @@ pub struct MitmConfig { pub handshake_timeout_ms: Option, } -impl MitmConfig { +impl HttpsInterceptionConfig { pub fn listen_addr(&self) -> &str { self.listen_addr.as_deref().unwrap_or("127.0.0.1") } @@ -277,81 +277,81 @@ impl Config { } } - // MITM validation - if let Some(mitm) = &self.mitm { - if mitm.enabled { - // MITM listen_addr must be loopback (hard requirement, even with allow_non_loopback) - let mitm_addr = mitm.listen_addr(); - let mitm_ip: IpAddr = mitm_addr.parse().with_context(|| { + // HTTPS interception validation + if let Some(cfg) = &self.https_interception { + if cfg.enabled { + // HTTPS interception listen_addr must be loopback (hard requirement, even with allow_non_loopback) + let listen_addr = cfg.listen_addr(); + let listen_ip: IpAddr = listen_addr.parse().with_context(|| { format!( - "mitm.listen_addr must be an IP literal, got '{}'", - mitm_addr + "https_interception.listen_addr must be an IP literal, got '{}'", + listen_addr ) })?; - if !mitm_ip.is_loopback() { + if !listen_ip.is_loopback() { bail!( - "mitm.listen_addr '{}' must be loopback; MITM listener must bind to loopback only", - mitm_addr + "https_interception.listen_addr '{}' must be loopback; HTTPS interception listener must bind to loopback only", + listen_addr ); } // Port collision checks - let mitm_port = mitm.listen_port(); - if mitm_port == self.listen_port() { + let port = cfg.listen_port(); + if port == self.listen_port() { bail!( - "mitm.listen_port {} collides with listen_port {}", - mitm_port, + "https_interception.listen_port {} collides with listen_port {}", + port, self.listen_port() ); } - if mitm_port == self.metrics_port() { + if port == self.metrics_port() { bail!( - "mitm.listen_port {} collides with metrics_port {}", - mitm_port, + "https_interception.listen_port {} collides with metrics_port {}", + port, self.metrics_port() ); } // CA path validation - let cert_empty = mitm.ca_cert_path.trim().is_empty(); - let key_empty = mitm.ca_key_path.trim().is_empty(); + let cert_empty = cfg.ca_cert_path.trim().is_empty(); + let key_empty = cfg.ca_key_path.trim().is_empty(); if cert_empty && key_empty { - bail!("mitm.ca_cert_path and mitm.ca_key_path must not be empty"); + bail!("https_interception.ca_cert_path and https_interception.ca_key_path must not be empty"); } else if cert_empty { - bail!("mitm.ca_cert_path must not be empty"); + bail!("https_interception.ca_cert_path must not be empty"); } else if key_empty { - bail!("mitm.ca_key_path must not be empty"); + bail!("https_interception.ca_key_path must not be empty"); } // cert_cache_size > 0 - if mitm.cert_cache_size() == 0 { - bail!("mitm.cert_cache_size must be greater than 0"); + if cfg.cert_cache_size() == 0 { + bail!("https_interception.cert_cache_size must be greater than 0"); } // cert_ttl_seconds in 60..604800 - let ttl = mitm.cert_ttl_seconds(); + let ttl = cfg.cert_ttl_seconds(); if !(60..=604800).contains(&ttl) { bail!( - "mitm.cert_ttl_seconds {} must be between 60 and 604800", + "https_interception.cert_ttl_seconds {} must be between 60 and 604800", ttl ); } // handshake_timeout_ms in 100..60000 - let hs_timeout = mitm.handshake_timeout_ms(); + let hs_timeout = cfg.handshake_timeout_ms(); if !(100..=60000).contains(&hs_timeout) { bail!( - "mitm.handshake_timeout_ms {} must be between 100 and 60000", + "https_interception.handshake_timeout_ms {} must be between 100 and 60000", hs_timeout ); } // cert_cache_ttl_seconds must not exceed cert_ttl_seconds // (otherwise expired certificates could be served from cache) - let cache_ttl = mitm.cert_cache_ttl_seconds(); + let cache_ttl = cfg.cert_cache_ttl_seconds(); if cache_ttl > ttl { bail!( - "mitm.cert_cache_ttl_seconds ({}) must not exceed mitm.cert_ttl_seconds ({})", + "https_interception.cert_cache_ttl_seconds ({}) must not exceed https_interception.cert_ttl_seconds ({})", cache_ttl, ttl ); @@ -620,73 +620,73 @@ egress_policy: assert!(err.to_string().contains("cannot be used in rewrites")); } - // --- MITM design contract tests (WIP docs/wip/mitm/*) --- + // --- HTTPS interception design contract tests (WIP docs/wip/*) --- #[test] - fn test_mitm_enabled_rejects_non_loopback_listener_even_with_global_override() { + fn test_https_interception_enabled_rejects_non_loopback_listener_even_with_global_override() { let yaml = r#" allow_non_loopback: true egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "0.0.0.0" listen_port: 8443 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("mitm.listen_addr")); + assert!(err.to_string().contains("https_interception.listen_addr")); assert!(err.to_string().contains("loopback")); } #[test] - fn test_mitm_enabled_rejects_port_collision_with_http_listener() { + fn test_https_interception_enabled_rejects_port_collision_with_http_listener() { let yaml = r#" listen_port: 8080 metrics_port: 9090 egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 8080 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("mitm.listen_port")); + assert!(err.to_string().contains("https_interception.listen_port")); assert!(err.to_string().contains("listen_port")); } #[test] - fn test_mitm_enabled_rejects_port_collision_with_metrics_listener() { + fn test_https_interception_enabled_rejects_port_collision_with_metrics_listener() { let yaml = r#" listen_port: 8080 metrics_port: 9090 egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 9090 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("mitm.listen_port")); + assert!(err.to_string().contains("https_interception.listen_port")); assert!(err.to_string().contains("metrics_port")); } #[test] - fn test_mitm_enabled_requires_non_empty_ca_paths() { + fn test_https_interception_enabled_requires_non_empty_ca_paths() { let yaml = r#" egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 8443 @@ -695,21 +695,21 @@ mitm: "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("mitm.ca_cert_path")); - assert!(err.to_string().contains("mitm.ca_key_path")); + assert!(err.to_string().contains("https_interception.ca_cert_path")); + assert!(err.to_string().contains("https_interception.ca_key_path")); } #[test] - fn test_mitm_enabled_rejects_zero_cert_cache_size() { + fn test_https_interception_enabled_rejects_zero_cert_cache_size() { let yaml = r#" egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 8443 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" cert_cache_size: 0 "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); @@ -718,16 +718,16 @@ mitm: } #[test] - fn test_mitm_enabled_rejects_out_of_range_cert_ttl_seconds() { + fn test_https_interception_enabled_rejects_out_of_range_cert_ttl_seconds() { let yaml = r#" egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 8443 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" cert_ttl_seconds: 30 "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); @@ -736,16 +736,16 @@ mitm: } #[test] - fn test_mitm_enabled_rejects_out_of_range_handshake_timeout_ms() { + fn test_https_interception_enabled_rejects_out_of_range_handshake_timeout_ms() { let yaml = r#" egress_policy: rules: [] -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 8443 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" handshake_timeout_ms: 5 "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); diff --git a/src/mitm.rs b/src/https_interception.rs similarity index 84% rename from src/mitm.rs rename to src/https_interception.rs index 546d867..d739f90 100644 --- a/src/mitm.rs +++ b/src/https_interception.rs @@ -1,5 +1,5 @@ use crate::allowlist::{Allowlist, Decision}; -use crate::config::{extract_port, normalize_policy_host, MitmConfig}; +use crate::config::{extract_port, normalize_policy_host, HttpsInterceptionConfig}; use crate::metrics::Metrics; use crate::proxy::{ProxyBody, ProxyHandler}; use http_body_util::Full; @@ -24,14 +24,14 @@ use time::OffsetDateTime; use tokio::net::TcpListener; use tracing::{error, info, warn}; -/// CA material loaded from PEM files. -pub struct MitmCa { +/// Certificate-authority (CA) material loaded from PEM files. +pub struct HttpsInterceptionCa { pub ca_cert_pem: String, pub ca_cert_der: Vec, pub ca_key_pair: KeyPair, } -impl MitmCa { +impl HttpsInterceptionCa { pub fn load(cert_path: &str, key_path: &str) -> anyhow::Result { let cert_pem = std::fs::read_to_string(cert_path) .map_err(|e| anyhow::anyhow!("failed to read CA cert from '{}': {}", cert_path, e))?; @@ -43,7 +43,7 @@ impl MitmCa { let ca_cert_der = pem_to_der(&cert_pem)?; - Ok(MitmCa { + Ok(HttpsInterceptionCa { ca_cert_pem: cert_pem, ca_cert_der, ca_key_pair, @@ -123,13 +123,13 @@ pub fn validate_sni(sni: &str) -> Result { Ok(host) } -/// MITM certificate resolver implementing rustls `ResolvesServerCert`. +/// HTTPS interception certificate resolver implementing rustls `ResolvesServerCert`. /// /// SNI is always required — without it, no hostname is available and no /// certificate can be issued. Invalid SNI always causes handshake failure /// for the same reason. -pub struct MitmCertResolver { - ca: Arc, +pub struct HttpsInterceptionCertResolver { + ca: Arc, cache: CertCache, cert_ttl: Duration, deny_handshake_on_disallowed_sni: bool, @@ -137,9 +137,9 @@ pub struct MitmCertResolver { metrics: Metrics, } -impl fmt::Debug for MitmCertResolver { +impl fmt::Debug for HttpsInterceptionCertResolver { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MitmCertResolver") + f.debug_struct("HttpsInterceptionCertResolver") .field( "deny_handshake_on_disallowed_sni", &self.deny_handshake_on_disallowed_sni, @@ -148,14 +148,14 @@ impl fmt::Debug for MitmCertResolver { } } -impl MitmCertResolver { +impl HttpsInterceptionCertResolver { pub fn new( - ca: Arc, - config: &MitmConfig, + ca: Arc, + config: &HttpsInterceptionConfig, allowlist: Arc, metrics: Metrics, ) -> Self { - MitmCertResolver { + HttpsInterceptionCertResolver { ca, cache: CertCache::new( config.cert_cache_size() as usize, @@ -215,12 +215,12 @@ impl MitmCertResolver { } } -impl ResolvesServerCert for MitmCertResolver { +impl ResolvesServerCert for HttpsInterceptionCertResolver { /// Resolve a certificate for the given ClientHello. /// /// Metrics policy: this function tracks **cert issuance** and **cache** counters /// only. The **handshake result** counter (`tls_handshakes_total`) is owned - /// exclusively by the accept loop in `run_mitm_listener()` so that each TCP + /// exclusively by the accept loop in `run_https_interception_listener()` so that each TCP /// connection is counted exactly once. fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { // SNI is always required — without a hostname we cannot issue a cert. @@ -231,7 +231,7 @@ impl ResolvesServerCert for MitmCertResolver { Ok(h) => h, Err(_reason) => { self.metrics - .mitm_cert_issued_total + .https_interception_cert_issued_total .with_label_values(&["skipped_invalid"]) .inc(); return None; @@ -243,7 +243,7 @@ impl ResolvesServerCert for MitmCertResolver { let decision = self.allowlist.check(&hostname); if matches!(decision, Decision::Deny) { self.metrics - .mitm_cert_issued_total + .https_interception_cert_issued_total .with_label_values(&["skipped_disallowed"]) .inc(); return None; @@ -253,13 +253,13 @@ impl ResolvesServerCert for MitmCertResolver { // Check cache if let Some(key) = self.cache.get(&hostname) { self.metrics - .mitm_cert_cache_total + .https_interception_cert_cache_total .with_label_values(&["hit"]) .inc(); return Some(key); } self.metrics - .mitm_cert_cache_total + .https_interception_cert_cache_total .with_label_values(&["miss"]) .inc(); @@ -268,21 +268,23 @@ impl ResolvesServerCert for MitmCertResolver { Ok(key) => { self.cache.insert(hostname, key.clone()); self.metrics - .mitm_cert_issued_total + .https_interception_cert_issued_total .with_label_values(&["issued_allow"]) .inc(); Some(key) } Err(e) => { - error!(error = %e, "failed to issue MITM leaf certificate"); + error!(error = %e, "failed to issue HTTPS interception leaf certificate"); None } } } } -/// Build a rustls ServerConfig for the MITM TLS listener. -pub fn build_mitm_server_config(resolver: Arc) -> Arc { +/// Build a rustls ServerConfig for the HTTPS interception TLS listener. +pub fn build_https_interception_server_config( + resolver: Arc, +) -> Arc { let mut config = ServerConfig::builder() .with_no_client_auth() .with_cert_resolver(resolver); @@ -291,9 +293,9 @@ pub fn build_mitm_server_config(resolver: Arc) -> Arc) -> Arc( +pub fn validate_https_interception_request( req: &Request, sni_host: &str, enforce_sni_host_match: bool, metrics: &Metrics, ) -> Result<(), Response> { // Reject absolute-form requests (scheme/authority in URI). - // MITM traffic must use origin-form only; absolute-form could bypass + // HTTPS interception traffic must use origin-form only; absolute-form could bypass // the Host/SNI checks because ProxyHandler prioritizes URI authority. if req.uri().scheme().is_some() || req.uri().authority().is_some() { return Err(error_response( 400, - "absolute-form request not allowed on MITM listener", + "absolute-form request not allowed on HTTPS interception listener", )); } @@ -334,7 +336,7 @@ pub fn validate_mitm_request( if p != 443 { return Err(error_response( 403, - "MITM listener only accepts port 443 traffic", + "HTTPS interception listener only accepts port 443 traffic", )); } } @@ -344,7 +346,7 @@ pub fn validate_mitm_request( let host_only = normalize_policy_host(host_header); let sni_normalized = sni_host.trim().to_lowercase(); if host_only != sni_normalized { - metrics.mitm_host_mismatch_total.inc(); + metrics.https_interception_host_mismatch_total.inc(); return Err(error_response(400, "SNI/Host header mismatch")); } } @@ -362,19 +364,19 @@ fn error_response(status: u16, body: &str) -> Response { .unwrap() } -/// Run the MITM TLS listener. -pub async fn run_mitm_listener( +/// Run the HTTPS interception TLS listener. +pub async fn run_https_interception_listener( listener: TcpListener, tls_config: Arc, handler: Arc, - mitm_config: MitmConfig, + cfg: HttpsInterceptionConfig, metrics: Metrics, semaphore: Arc, mut shutdown_rx: tokio::sync::watch::Receiver<()>, ) { let acceptor = tokio_rustls::TlsAcceptor::from(tls_config); - let handshake_timeout = Duration::from_millis(mitm_config.handshake_timeout_ms()); - let enforce_sni_host_match = mitm_config.enforce_sni_host_match(); + let handshake_timeout = Duration::from_millis(cfg.handshake_timeout_ms()); + let enforce_sni_host_match = cfg.enforce_sni_host_match(); let mut connections = tokio::task::JoinSet::new(); @@ -386,7 +388,7 @@ pub async fn run_mitm_listener( let permit = match Arc::clone(&semaphore).try_acquire_owned() { Ok(permit) => permit, Err(_) => { - warn!(peer = %addr, "MITM connection limit reached, dropping connection"); + warn!(peer = %addr, "HTTPS interception connection limit reached, dropping connection"); drop(stream); continue; } @@ -405,7 +407,7 @@ pub async fn run_mitm_listener( Ok(Ok(tls)) => tls, Ok(Err(e)) => { if !e.to_string().contains("connection closed") { - warn!(peer = %addr, error = %e, "MITM TLS handshake failed"); + warn!(peer = %addr, error = %e, "HTTPS interception TLS handshake failed"); } metrics .tls_handshakes_total @@ -414,7 +416,7 @@ pub async fn run_mitm_listener( return; } Err(_) => { - warn!(peer = %addr, "MITM TLS handshake timed out"); + warn!(peer = %addr, "HTTPS interception TLS handshake timed out"); metrics .tls_handshakes_total .with_label_values(&["timeout"]) @@ -442,8 +444,8 @@ pub async fn run_mitm_listener( let sni = sni_host.clone(); let metrics = metrics.clone(); async move { - // Validate MITM-specific constraints - if let Err(resp) = validate_mitm_request( + // Validate HTTPS interception-specific constraints + if let Err(resp) = validate_https_interception_request( &req, &sni, enforce_sni_host_match, @@ -454,7 +456,7 @@ pub async fn run_mitm_listener( // Delegate to the existing proxy handler handler.handle(req).await.or_else(|e| { - error!(error = %e, "MITM request handling error"); + error!(error = %e, "HTTPS interception request handling error"); Ok::<_, hyper::Error>( Response::builder() .status(500) @@ -477,14 +479,14 @@ pub async fn run_mitm_listener( error!( peer = %addr, error = %e, - "MITM connection error" + "HTTPS interception connection error" ); } } }); } Err(e) => { - error!(error = %e, "MITM accept error"); + error!(error = %e, "HTTPS interception accept error"); } } } @@ -494,9 +496,9 @@ pub async fn run_mitm_listener( } } - // Drain in-flight MITM connections + // Drain in-flight HTTPS interception connections info!( - "MITM listener shutting down, draining {} connections", + "HTTPS interception listener shutting down, draining {} connections", connections.len() ); let drain_result = tokio::time::timeout(Duration::from_secs(30), async { @@ -505,10 +507,10 @@ pub async fn run_mitm_listener( .await; if drain_result.is_err() { warn!( - "MITM drain timeout, aborting {} remaining connections", + "HTTPS interception drain timeout, aborting {} remaining connections", connections.len() ); connections.abort_all(); } - info!("MITM listener stopped"); + info!("HTTPS interception listener stopped"); } diff --git a/src/lib.rs b/src/lib.rs index 3129de7..dcd23a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,9 +2,9 @@ pub mod allowlist; pub mod config; pub mod error; pub mod header_rewrite; +pub mod https_interception; pub mod logging; pub mod metrics; -pub mod mitm; pub mod proxy; pub mod secrets; pub mod tls; diff --git a/src/main.rs b/src/main.rs index ca64fec..537691a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ use anyhow::Result; use botbox::allowlist::Allowlist; use botbox::config::Config; +use botbox::https_interception; use botbox::metrics::{handle_metrics_request, Metrics}; -use botbox::mitm; use botbox::proxy::{ProxyBody, ProxyHandler}; use botbox::{logging, secrets, tls}; use clap::Parser; @@ -117,7 +117,7 @@ async fn main() -> Result<()> { std::time::Duration::from_secs(30), )); - // Shared shutdown channel for metrics + MITM listeners + // Shared shutdown channel for metrics + HTTPS interception listeners let (shutdown_tx, _) = tokio::sync::watch::channel(()); // Start metrics server @@ -137,49 +137,46 @@ async fn main() -> Result<()> { .await; }); - // Shared connection semaphore (HTTP proxy + MITM share the same pool) + // Shared connection semaphore (HTTP proxy + HTTPS interception share the same pool) let semaphore = Arc::new(Semaphore::new(config.max_connections() as usize)); - // MITM TLS listener (if enabled) - let mitm_handle = if let Some(ref mitm_config) = config.mitm { - if mitm_config.enabled { - info!("MITM mode enabled, loading CA material"); - let ca = mitm::MitmCa::load(&mitm_config.ca_cert_path, &mitm_config.ca_key_path)?; + // HTTPS interception TLS listener (if enabled) + let https_interception_handle = if let Some(ref cfg) = config.https_interception { + if cfg.enabled { + info!("HTTPS interception mode enabled, loading CA material"); + let ca = + https_interception::HttpsInterceptionCa::load(&cfg.ca_cert_path, &cfg.ca_key_path)?; let ca = Arc::new(ca); - let resolver = Arc::new(mitm::MitmCertResolver::new( + let resolver = Arc::new(https_interception::HttpsInterceptionCertResolver::new( ca, - mitm_config, + cfg, allowlist, metrics.clone(), )); - let tls_config = mitm::build_mitm_server_config(resolver); - - let mitm_addr: SocketAddr = format!( - "{}:{}", - mitm_config.listen_addr(), - mitm_config.listen_port() - ) - .parse() - .unwrap(); - info!(addr = %mitm_addr, "starting MITM TLS listener"); - let mitm_listener = TcpListener::bind(mitm_addr).await?; - - let mitm_handler = handler.clone(); - let mitm_metrics = metrics.clone(); - let mitm_semaphore = semaphore.clone(); - let mitm_shutdown_rx = shutdown_tx.subscribe(); - let mitm_cfg = mitm_config.clone(); + let tls_config = https_interception::build_https_interception_server_config(resolver); + + let addr: SocketAddr = format!("{}:{}", cfg.listen_addr(), cfg.listen_port()) + .parse() + .unwrap(); + info!(addr = %addr, "starting HTTPS interception TLS listener"); + let listener = TcpListener::bind(addr).await?; + + let handler = handler.clone(); + let metrics = metrics.clone(); + let semaphore = semaphore.clone(); + let shutdown_rx = shutdown_tx.subscribe(); + let cfg = cfg.clone(); Some(tokio::spawn(async move { - mitm::run_mitm_listener( - mitm_listener, + https_interception::run_https_interception_listener( + listener, tls_config, - mitm_handler, - mitm_cfg, - mitm_metrics, - mitm_semaphore, - mitm_shutdown_rx, + handler, + cfg, + metrics, + semaphore, + shutdown_rx, ) .await; })) @@ -306,13 +303,13 @@ async fn main() -> Result<()> { } } - // Signal metrics + MITM servers to shut down + // Signal metrics + HTTPS interception servers to shut down drop(shutdown_tx); info!("waiting for metrics server to stop"); let _ = metrics_handle.await; - if let Some(handle) = mitm_handle { - info!("waiting for MITM listener to stop"); + if let Some(handle) = https_interception_handle { + info!("waiting for HTTPS interception listener to stop"); let _ = handle.await; } diff --git a/src/metrics.rs b/src/metrics.rs index a8c71f9..4286abb 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -14,11 +14,11 @@ pub struct Metrics { pub header_rewrites_total: IntCounterVec, pub upstream_errors_total: IntCounterVec, pub request_duration_seconds: HistogramVec, - // MITM metrics + // HTTPS interception metrics pub tls_handshakes_total: IntCounterVec, - pub mitm_cert_issued_total: IntCounterVec, - pub mitm_cert_cache_total: IntCounterVec, - pub mitm_host_mismatch_total: IntCounter, + pub https_interception_cert_issued_total: IntCounterVec, + pub https_interception_cert_cache_total: IntCounterVec, + pub https_interception_host_mismatch_total: IntCounter, } impl Metrics { @@ -59,32 +59,35 @@ impl Metrics { .unwrap(); let tls_handshakes_total = IntCounterVec::new( - Opts::new("botbox_tls_handshakes_total", "Total MITM TLS handshakes"), + Opts::new( + "botbox_tls_handshakes_total", + "Total HTTPS interception TLS handshakes", + ), &["result"], ) .unwrap(); - let mitm_cert_issued_total = IntCounterVec::new( + let https_interception_cert_issued_total = IntCounterVec::new( Opts::new( - "botbox_mitm_cert_issued_total", - "Total MITM certificates issued", + "botbox_https_interception_cert_issued_total", + "Total HTTPS interception certificates issued", ), &["decision"], ) .unwrap(); - let mitm_cert_cache_total = IntCounterVec::new( + let https_interception_cert_cache_total = IntCounterVec::new( Opts::new( - "botbox_mitm_cert_cache_total", - "Total MITM cert cache operations", + "botbox_https_interception_cert_cache_total", + "Total HTTPS interception cert cache operations", ), &["result"], ) .unwrap(); - let mitm_host_mismatch_total = IntCounter::new( - "botbox_mitm_host_mismatch_total", - "Total MITM SNI/Host mismatches", + let https_interception_host_mismatch_total = IntCounter::new( + "botbox_https_interception_host_mismatch_total", + "Total HTTPS interception SNI/Host mismatches", ) .unwrap(); @@ -102,13 +105,13 @@ impl Metrics { .register(Box::new(tls_handshakes_total.clone())) .unwrap(); registry - .register(Box::new(mitm_cert_issued_total.clone())) + .register(Box::new(https_interception_cert_issued_total.clone())) .unwrap(); registry - .register(Box::new(mitm_cert_cache_total.clone())) + .register(Box::new(https_interception_cert_cache_total.clone())) .unwrap(); registry - .register(Box::new(mitm_host_mismatch_total.clone())) + .register(Box::new(https_interception_host_mismatch_total.clone())) .unwrap(); Metrics { @@ -118,9 +121,9 @@ impl Metrics { upstream_errors_total, request_duration_seconds, tls_handshakes_total, - mitm_cert_issued_total, - mitm_cert_cache_total, - mitm_host_mismatch_total, + https_interception_cert_issued_total, + https_interception_cert_cache_total, + https_interception_host_mismatch_total, } } } diff --git a/tests/at/README.md b/tests/at/README.md index 4322e22..82239c4 100644 --- a/tests/at/README.md +++ b/tests/at/README.md @@ -23,7 +23,7 @@ Notes: ## Automated Execution (kind E2E) -For an automated acceptance run (image build + kind image load + non-MITM E2E + MITM E2E), use: +For an automated acceptance run (image build + kind image load + HTTP-mode E2E + HTTPS interception E2E), use: ```bash tests/e2e/run-kind-acceptance.sh diff --git a/tests/e2e/manifests/mitm-test.yaml b/tests/e2e/manifests/https-interception-test.yaml similarity index 83% rename from tests/e2e/manifests/mitm-test.yaml rename to tests/e2e/manifests/https-interception-test.yaml index 116ca66..0beca47 100644 --- a/tests/e2e/manifests/mitm-test.yaml +++ b/tests/e2e/manifests/https-interception-test.yaml @@ -2,13 +2,13 @@ apiVersion: v1 kind: Namespace metadata: - name: mitm-test + name: https-interception-test --- apiVersion: v1 kind: Secret metadata: name: botbox-secrets - namespace: mitm-test + namespace: https-interception-test type: Opaque stringData: openai-api-key: "REPLACE-ME-NOT-A-REAL-KEY" @@ -17,7 +17,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: botbox-config - namespace: mitm-test + namespace: https-interception-test data: config.yaml: | listen_addr: "127.0.0.1" @@ -36,12 +36,12 @@ data: value: "Bearer {value}" secret_ref: openai-api-key - mitm: + https_interception: enabled: true listen_addr: "127.0.0.1" listen_port: 8443 - ca_cert_path: "/etc/botbox/mitm/ca.crt" - ca_key_path: "/etc/botbox/mitm/ca.key" + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" enforce_sni_host_match: true deny_handshake_on_disallowed_sni: false cert_ttl_seconds: 86400 @@ -52,8 +52,8 @@ data: apiVersion: v1 kind: Pod metadata: - name: mitm-test - namespace: mitm-test + name: https-interception-test + namespace: https-interception-test spec: restartPolicy: Never initContainers: @@ -80,9 +80,9 @@ spec: image: botbox-iptables-init:test imagePullPolicy: Never env: - - name: BOTBOX_REDIRECT_FROM_PORT - value: "443" - - name: BOTBOX_PROXY_PORT + - name: BOTBOX_ENABLE_HTTPS_INTERCEPTION + value: "1" + - name: BOTBOX_HTTPS_INTERCEPTION_PORT value: "8443" securityContext: capabilities: @@ -104,7 +104,7 @@ spec: - containerPort: 8080 name: proxy - containerPort: 8443 - name: mitm + name: https-intercept - containerPort: 9090 name: metrics readinessProbe: @@ -121,7 +121,7 @@ spec: mountPath: /etc/botbox readOnly: true - name: botbox-ca - mountPath: /etc/botbox/mitm + mountPath: /etc/botbox/https_interception readOnly: true containers: @@ -153,18 +153,18 @@ spec: exit 1 fi - echo "--- MITM positive path (expect upstream reachability, often 401 with dummy key) ---" + echo "--- HTTPS interception positive path (expect upstream reachability, often 401 with dummy key) ---" code="$(curl -sS -o /tmp/openai.out -w '%{http_code}' https://api.openai.com/v1/models || true)" - echo "MITM_OPENAI_CODE=${code}" + echo "HTTPS_INTERCEPTION_OPENAI_CODE=${code}" cat /tmp/openai.out || true if [ "${code}" = "000" ]; then echo "openai request timed out or could not connect" exit 1 fi - echo "--- MITM negative allowlist path (expect deny) ---" + echo "--- HTTPS interception negative allowlist path (expect deny) ---" deny_code="$(curl -sS -o /tmp/deny.out -w '%{http_code}' https://example.com/ -m 8 || true)" - echo "MITM_DENY_CODE=${deny_code}" + echo "HTTPS_INTERCEPTION_DENY_CODE=${deny_code}" if [ "${deny_code}" != "403" ]; then echo "expected deny code 403 for non-allowlisted host" cat /tmp/deny.out || true @@ -174,7 +174,7 @@ spec: echo "--- Direct bypass checks ---" echo "TCP/443 direct probe" tcp_code="$(curl -sS -o /tmp/tcp.out -w '%{http_code}' --connect-timeout 2 -m 6 https://cloudflare.com/ || true)" - echo "MITM_TCP443_CODE=${tcp_code}" + echo "HTTPS_INTERCEPTION_TCP443_CODE=${tcp_code}" if [ "${tcp_code}" != "403" ] && [ "${tcp_code}" != "000" ]; then echo "unexpected TCP/443 probe result" cat /tmp/tcp.out || true @@ -187,14 +187,14 @@ spec: curl -sS --http3 --connect-timeout 2 -m 6 https://cloudflare-quic.com/ >/tmp/udp.out 2>&1 udp_rc=$? set -e - echo "MITM_UDP443_RC=${udp_rc}" + echo "HTTPS_INTERCEPTION_UDP443_RC=${udp_rc}" if [ "${udp_rc}" -eq 0 ]; then echo "expected UDP/443 probe to fail" cat /tmp/udp.out || true exit 1 fi else - echo "MITM_HTTP3_UNSUPPORTED=1" + echo "HTTPS_INTERCEPTION_HTTP3_UNSUPPORTED=1" fi volumeMounts: - name: botbox-ca diff --git a/tests/e2e/run-egress-test.sh b/tests/e2e/run-egress-test.sh index 3b7c358..de798a5 100755 --- a/tests/e2e/run-egress-test.sh +++ b/tests/e2e/run-egress-test.sh @@ -26,8 +26,8 @@ logs="$(k -n "${NAMESPACE}" logs egress-test -c curl-client)" echo "${logs}" if ! printf '%s\n' "${logs}" | grep -q -- "--- curl exit code: 0 ---"; then - echo "E2E failed: non-MITM egress smoke test did not complete successfully" + echo "E2E failed: HTTP-mode egress smoke test did not complete successfully" exit 1 fi -echo "Non-MITM E2E checks passed." +echo "HTTP-mode E2E checks passed." diff --git a/tests/e2e/run-https-interception-test.sh b/tests/e2e/run-https-interception-test.sh new file mode 100755 index 0000000..9c51ce3 --- /dev/null +++ b/tests/e2e/run-https-interception-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NAMESPACE="https-interception-test" +MANIFEST="tests/e2e/manifests/https-interception-test.yaml" +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}" + +k() { + if [[ -n "${KUBECTL_CONTEXT}" ]]; then + kubectl --context "${KUBECTL_CONTEXT}" "$@" + else + kubectl "$@" + fi +} + +cleanup() { + k delete namespace "${NAMESPACE}" --wait=false >/dev/null 2>&1 || true +} +trap cleanup EXIT + +k apply -f "${MANIFEST}" +k -n "${NAMESPACE}" wait --for=jsonpath='{.status.phase}'=Succeeded pod/https-interception-test --timeout=300s + +logs="$(k -n "${NAMESPACE}" logs https-interception-test -c curl-client)" +echo "${logs}" + +openai_code="$(printf '%s\n' "${logs}" | sed -n 's/^HTTPS_INTERCEPTION_OPENAI_CODE=//p' | tail -n1)" +deny_code="$(printf '%s\n' "${logs}" | sed -n 's/^HTTPS_INTERCEPTION_DENY_CODE=//p' | tail -n1)" +tcp_code="$(printf '%s\n' "${logs}" | sed -n 's/^HTTPS_INTERCEPTION_TCP443_CODE=//p' | tail -n1)" +udp_rc="$(printf '%s\n' "${logs}" | sed -n 's/^HTTPS_INTERCEPTION_UDP443_RC=//p' | tail -n1)" +http3_unsupported="$(printf '%s\n' "${logs}" | sed -n 's/^HTTPS_INTERCEPTION_HTTP3_UNSUPPORTED=//p' | tail -n1)" + +if [[ -z "${openai_code}" || "${openai_code}" == "000" ]]; then + echo "E2E failed: HTTPS_INTERCEPTION_OPENAI_CODE is missing or indicates timeout (${openai_code:-missing})" + exit 1 +fi + +if [[ "${deny_code}" != "403" ]]; then + echo "E2E failed: expected HTTPS_INTERCEPTION_DENY_CODE=403, got '${deny_code:-missing}'" + exit 1 +fi + +if [[ "${tcp_code}" != "403" && "${tcp_code}" != "000" ]]; then + echo "E2E failed: expected HTTPS_INTERCEPTION_TCP443_CODE to be 403 or 000, got '${tcp_code:-missing}'" + exit 1 +fi + +if [[ -n "${udp_rc}" ]]; then + if [[ "${udp_rc}" == "0" ]]; then + echo "E2E failed: UDP/443 probe succeeded unexpectedly" + exit 1 + fi +elif [[ "${http3_unsupported}" != "1" ]]; then + echo "E2E failed: neither HTTPS_INTERCEPTION_UDP443_RC nor HTTPS_INTERCEPTION_HTTP3_UNSUPPORTED marker found" + exit 1 +fi + +echo "HTTPS interception E2E checks passed." diff --git a/tests/e2e/run-kind-acceptance.sh b/tests/e2e/run-kind-acceptance.sh index 1692e5c..649ff08 100755 --- a/tests/e2e/run-kind-acceptance.sh +++ b/tests/e2e/run-kind-acceptance.sh @@ -8,7 +8,7 @@ KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-${CLUSTER_NAME}}" CREATE_CLUSTER="${CREATE_KIND_CLUSTER:-0}" SKIP_IMAGE_BUILD="${SKIP_IMAGE_BUILD:-0}" RUN_EGRESS_TEST="${RUN_EGRESS_TEST:-1}" -RUN_MITM_TEST="${RUN_MITM_TEST:-1}" +RUN_HTTPS_INTERCEPTION_TEST="${RUN_HTTPS_INTERCEPTION_TEST:-1}" require_cmd() { local cmd="$1" @@ -73,13 +73,13 @@ build_iptables_image() { run_e2e_tests() { if [[ "${RUN_EGRESS_TEST}" == "1" ]]; then - echo "[4/5] Running non-MITM E2E test..." + echo "[4/5] Running HTTP-mode E2E test (no interception)..." KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" bash "${ROOT_DIR}/tests/e2e/run-egress-test.sh" fi - if [[ "${RUN_MITM_TEST}" == "1" ]]; then - echo "[5/5] Running MITM E2E test..." - KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" bash "${ROOT_DIR}/tests/e2e/run-mitm-test.sh" + if [[ "${RUN_HTTPS_INTERCEPTION_TEST}" == "1" ]]; then + echo "[5/5] Running HTTPS interception E2E test..." + KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" bash "${ROOT_DIR}/tests/e2e/run-https-interception-test.sh" fi } @@ -87,8 +87,8 @@ require_cmd docker require_cmd kind require_cmd kubectl -if [[ "${RUN_EGRESS_TEST}" != "1" && "${RUN_MITM_TEST}" != "1" ]]; then - echo "Nothing to run: both RUN_EGRESS_TEST and RUN_MITM_TEST are disabled." >&2 +if [[ "${RUN_EGRESS_TEST}" != "1" && "${RUN_HTTPS_INTERCEPTION_TEST}" != "1" ]]; then + echo "Nothing to run: both RUN_EGRESS_TEST and RUN_HTTPS_INTERCEPTION_TEST are disabled." >&2 exit 1 fi diff --git a/tests/e2e/run-mitm-test.sh b/tests/e2e/run-mitm-test.sh deleted file mode 100755 index d543384..0000000 --- a/tests/e2e/run-mitm-test.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -NAMESPACE="mitm-test" -MANIFEST="tests/e2e/manifests/mitm-test.yaml" -KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}" - -k() { - if [[ -n "${KUBECTL_CONTEXT}" ]]; then - kubectl --context "${KUBECTL_CONTEXT}" "$@" - else - kubectl "$@" - fi -} - -cleanup() { - k delete namespace "${NAMESPACE}" --wait=false >/dev/null 2>&1 || true -} -trap cleanup EXIT - -k apply -f "${MANIFEST}" -k -n "${NAMESPACE}" wait --for=jsonpath='{.status.phase}'=Succeeded pod/mitm-test --timeout=300s - -logs="$(k -n "${NAMESPACE}" logs mitm-test -c curl-client)" -echo "${logs}" - -openai_code="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_OPENAI_CODE=//p' | tail -n1)" -deny_code="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_DENY_CODE=//p' | tail -n1)" -tcp_code="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_TCP443_CODE=//p' | tail -n1)" -udp_rc="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_UDP443_RC=//p' | tail -n1)" -http3_unsupported="$(printf '%s\n' "${logs}" | sed -n 's/^MITM_HTTP3_UNSUPPORTED=//p' | tail -n1)" - -if [[ -z "${openai_code}" || "${openai_code}" == "000" ]]; then - echo "E2E failed: MITM_OPENAI_CODE is missing or indicates timeout (${openai_code:-missing})" - exit 1 -fi - -if [[ "${deny_code}" != "403" ]]; then - echo "E2E failed: expected MITM_DENY_CODE=403, got '${deny_code:-missing}'" - exit 1 -fi - -if [[ "${tcp_code}" != "403" && "${tcp_code}" != "000" ]]; then - echo "E2E failed: expected MITM_TCP443_CODE to be 403 or 000, got '${tcp_code:-missing}'" - exit 1 -fi - -if [[ -n "${udp_rc}" ]]; then - if [[ "${udp_rc}" == "0" ]]; then - echo "E2E failed: UDP/443 probe succeeded unexpectedly" - exit 1 - fi -elif [[ "${http3_unsupported}" != "1" ]]; then - echo "E2E failed: neither MITM_UDP443_RC nor MITM_HTTP3_UNSUPPORTED marker found" - exit 1 -fi - -echo "MITM E2E checks passed." diff --git a/tests/mitm_unit_test.rs b/tests/https_interception_unit_test.rs similarity index 80% rename from tests/mitm_unit_test.rs rename to tests/https_interception_unit_test.rs index 1bdcbb6..6079298 100644 --- a/tests/mitm_unit_test.rs +++ b/tests/https_interception_unit_test.rs @@ -32,7 +32,7 @@ sV8+/a4Kx0vtijnZOOOdSSAWy2A84qufitrcdlonQd8NE2dJz4JO98d7 -----END PRIVATE KEY----- "#; -struct MitmSpec { +struct HttpsInterceptionSpec { rules_yaml: String, write_ca_files: bool, initial_secrets: Vec<(String, String)>, @@ -43,7 +43,7 @@ struct MitmSpec { cert_cache_ttl_seconds: u64, } -impl Default for MitmSpec { +impl Default for HttpsInterceptionSpec { fn default() -> Self { Self { rules_yaml: rules_allow_hosts(&["localhost"]), @@ -123,7 +123,7 @@ struct BotboxProcess { child: Child, _tmp: TempDir, metrics_addr: SocketAddr, - mitm_addr: SocketAddr, + https_interception_addr: SocketAddr, ca_cert_pem: Vec, secrets_dir: std::path::PathBuf, stdout_log_path: PathBuf, @@ -138,7 +138,7 @@ impl Drop for BotboxProcess { } impl BotboxProcess { - fn start(spec: MitmSpec) -> Self { + fn start(spec: HttpsInterceptionSpec) -> Self { let tmp = TempDir::new().expect("failed to create tempdir"); let config_path = tmp.path().join("config.yaml"); let secrets_dir = tmp.path().join("secrets"); @@ -160,7 +160,7 @@ impl BotboxProcess { let listen_port = pick_free_port(); let metrics_port = pick_free_port(); - let mitm_port = pick_free_port(); + let https_interception_port = pick_free_port(); let config = format!( r#"listen_addr: "127.0.0.1" @@ -174,10 +174,10 @@ egress_policy: rules: {rules_yaml} -mitm: +https_interception: enabled: true listen_addr: "127.0.0.1" - listen_port: {mitm_port} + listen_port: {https_interception_port} ca_cert_path: "{ca_cert_path}" ca_key_path: "{ca_key_path}" enforce_sni_host_match: true @@ -192,7 +192,7 @@ mitm: secrets_dir = secrets_dir.display(), max_connections = spec.max_connections, rules_yaml = spec.rules_yaml, - mitm_port = mitm_port, + https_interception_port = https_interception_port, ca_cert_path = ca_cert_path.display(), ca_key_path = ca_key_path.display(), deny_handshake_on_disallowed_sni = spec.deny_handshake_on_disallowed_sni, @@ -222,7 +222,7 @@ mitm: child, _tmp: tmp, metrics_addr: SocketAddr::from(([127, 0, 0, 1], metrics_port)), - mitm_addr: SocketAddr::from(([127, 0, 0, 1], mitm_port)), + https_interception_addr: SocketAddr::from(([127, 0, 0, 1], https_interception_port)), ca_cert_pem, secrets_dir, stdout_log_path, @@ -398,7 +398,7 @@ fn load_root_store(ca_cert_pem: &[u8]) -> Result { } fn connect_tls_stream( - mitm_addr: SocketAddr, + https_interception_addr: SocketAddr, sni_dns_name: &str, ca_cert_pem: &[u8], ) -> Result> { @@ -414,8 +414,13 @@ fn connect_tls_stream( let connection = ClientConnection::new(Arc::new(client_config), server_name) .context("failed to build rustls client connection")?; - let socket = TcpStream::connect_timeout(&mitm_addr, Duration::from_secs(2)) - .with_context(|| format!("failed to connect to MITM listener at {}", mitm_addr))?; + let socket = TcpStream::connect_timeout(&https_interception_addr, Duration::from_secs(2)) + .with_context(|| { + format!( + "failed to connect to HTTPS interception listener at {}", + https_interception_addr + ) + })?; socket .set_read_timeout(Some(Duration::from_secs(2))) .context("failed to set read timeout")?; @@ -431,11 +436,11 @@ fn connect_tls_stream( } fn tls_handshake_leaf_cert( - mitm_addr: SocketAddr, + https_interception_addr: SocketAddr, sni_dns_name: &str, ca_cert_pem: &[u8], ) -> Result> { - let tls = connect_tls_stream(mitm_addr, sni_dns_name, ca_cert_pem)?; + let tls = connect_tls_stream(https_interception_addr, sni_dns_name, ca_cert_pem)?; let leaf = tls .conn .peer_certificates() @@ -446,13 +451,13 @@ fn tls_handshake_leaf_cert( } fn tls_http_request( - mitm_addr: SocketAddr, + https_interception_addr: SocketAddr, sni_dns_name: &str, host_header: &str, path: &str, ca_cert_pem: &[u8], ) -> Result { - let mut tls = connect_tls_stream(mitm_addr, sni_dns_name, ca_cert_pem)?; + let mut tls = connect_tls_stream(https_interception_addr, sni_dns_name, ca_cert_pem)?; let req = format!( "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", path, host_header @@ -485,10 +490,10 @@ fn run_openssl(args: &[&str]) -> Result<(ExitStatus, String, String)> { } #[test] -fn mitm_config_fails_closed_when_ca_files_are_missing() { - let spec = MitmSpec { +fn https_interception_config_fails_closed_when_ca_files_are_missing() { + let spec = HttpsInterceptionSpec { write_ca_files: false, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); @@ -501,17 +506,17 @@ fn mitm_config_fails_closed_when_ca_files_are_missing() { }); assert!( !status.success(), - "MITM enabled must fail startup when CA files are missing" + "HTTPS interception enabled must fail startup when CA files are missing" ); } #[test] -fn mitm_readiness_requires_required_secrets_and_ca_loaded() { +fn https_interception_readiness_requires_required_secrets_and_ca_loaded() { // required secret missing -> /healthz should stay 503 - let spec = MitmSpec { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_host_with_secret_rewrite("localhost", "openai-api-key"), initial_secrets: Vec::new(), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); @@ -535,30 +540,31 @@ fn mitm_readiness_requires_required_secrets_and_ca_loaded() { } #[test] -fn mitm_sni_validation_accepts_valid_ascii_and_punycode_hosts() { - let spec = MitmSpec { +fn https_interception_sni_validation_accepts_valid_ascii_and_punycode_hosts() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost", "api.openai.com", "xn--bcher-kva.example"]), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); for host in ["localhost", "api.openai.com", "xn--bcher-kva.example"] { - tls_handshake_leaf_cert(proc.mitm_addr, host, &proc.ca_cert_pem).unwrap_or_else(|e| { - panic!("valid SNI host '{}' should complete handshake: {}", host, e) - }); + tls_handshake_leaf_cert(proc.https_interception_addr, host, &proc.ca_cert_pem) + .unwrap_or_else(|e| { + panic!("valid SNI host '{}' should complete handshake: {}", host, e) + }); } } #[test] -fn mitm_sni_validation_rejects_invalid_or_oversized_hosts() { - let spec = MitmSpec::default(); +fn https_interception_sni_validation_rejects_invalid_or_oversized_hosts() { + let spec = HttpsInterceptionSpec::default(); let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); let oversized = format!("{}.example.com", "a".repeat(254)); for host in ["bad host", "bad_host", "*.example.com", &oversized] { - let result = tls_handshake_leaf_cert(proc.mitm_addr, host, &proc.ca_cert_pem); + let result = tls_handshake_leaf_cert(proc.https_interception_addr, host, &proc.ca_cert_pem); assert!( result.is_err(), "invalid SNI host '{}' must be rejected", @@ -568,22 +574,23 @@ fn mitm_sni_validation_rejects_invalid_or_oversized_hosts() { } #[test] -fn mitm_leaf_certificate_contains_san_dns_expected_validity_and_ca_issuer() { - let spec = MitmSpec { +fn https_interception_leaf_certificate_contains_san_dns_expected_validity_and_ca_issuer() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), cert_ttl_seconds: 86_400, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); - let leaf_der = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) - .unwrap_or_else(|e| { - panic!( - "expected successful TLS handshake for certificate inspection: {}", - e - ) - }); + let leaf_der = + tls_handshake_leaf_cert(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) + .unwrap_or_else(|e| { + panic!( + "expected successful TLS handshake for certificate inspection: {}", + e + ) + }); assert!( !leaf_der.is_empty(), "leaf certificate DER must not be empty" @@ -694,36 +701,51 @@ fn mitm_leaf_certificate_contains_san_dns_expected_validity_and_ca_issuer() { } #[test] -fn mitm_cert_cache_exposes_hit_ttl_expiry_and_lru_eviction_behaviour() { - let spec = MitmSpec { +fn https_interception_cert_cache_exposes_hit_ttl_expiry_and_lru_eviction_behaviour() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost", "api.openai.com", "files.openai.com"]), cert_cache_size: 1, cert_cache_ttl_seconds: 1, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); - let cert_a1 = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) - .expect("first localhost handshake should succeed"); - let cert_a2 = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) - .expect("second localhost handshake should succeed"); + let cert_a1 = + tls_handshake_leaf_cert(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) + .expect("first localhost handshake should succeed"); + let cert_a2 = + tls_handshake_leaf_cert(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) + .expect("second localhost handshake should succeed"); assert_eq!(cert_a1, cert_a2, "expected cache hit for repeated same SNI"); - let _cert_b = tls_handshake_leaf_cert(proc.mitm_addr, "api.openai.com", &proc.ca_cert_pem) - .expect("api.openai.com handshake should succeed"); - let cert_a3 = tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) - .expect("localhost handshake after eviction should succeed"); + let _cert_b = tls_handshake_leaf_cert( + proc.https_interception_addr, + "api.openai.com", + &proc.ca_cert_pem, + ) + .expect("api.openai.com handshake should succeed"); + let cert_a3 = + tls_handshake_leaf_cert(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) + .expect("localhost handshake after eviction should succeed"); assert_ne!( cert_a1, cert_a3, "expected LRU eviction with cache_size=1 after another host was issued" ); - let cert_c1 = tls_handshake_leaf_cert(proc.mitm_addr, "files.openai.com", &proc.ca_cert_pem) - .expect("files.openai.com handshake should succeed"); + let cert_c1 = tls_handshake_leaf_cert( + proc.https_interception_addr, + "files.openai.com", + &proc.ca_cert_pem, + ) + .expect("files.openai.com handshake should succeed"); thread::sleep(Duration::from_secs(2)); - let cert_c2 = tls_handshake_leaf_cert(proc.mitm_addr, "files.openai.com", &proc.ca_cert_pem) - .expect("files.openai.com handshake after TTL should succeed"); + let cert_c2 = tls_handshake_leaf_cert( + proc.https_interception_addr, + "files.openai.com", + &proc.ca_cert_pem, + ) + .expect("files.openai.com handshake after TTL should succeed"); assert_ne!( cert_c1, cert_c2, "expected cert cache TTL expiry to force re-issuance" @@ -731,16 +753,16 @@ fn mitm_cert_cache_exposes_hit_ttl_expiry_and_lru_eviction_behaviour() { } #[test] -fn mitm_integration_trusted_tls_client_can_send_http1_request() { - let spec = MitmSpec { +fn https_interception_integration_trusted_tls_client_can_send_http1_request() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); let resp = tls_http_request( - proc.mitm_addr, + proc.https_interception_addr, "localhost", "localhost", "/", @@ -751,22 +773,22 @@ fn mitm_integration_trusted_tls_client_can_send_http1_request() { let status = parse_status_code(&resp).expect("response must include an HTTP status line"); assert!( (100..600).contains(&status), - "expected a valid HTTP status code over MITM TLS listener, got {}", + "expected a valid HTTP status code over HTTPS interception TLS listener, got {}", status ); } #[test] -fn mitm_integration_rejects_sni_host_mismatch_with_400() { - let spec = MitmSpec { +fn https_interception_integration_rejects_sni_host_mismatch_with_400() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost", "example.com"]), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); let resp = tls_http_request( - proc.mitm_addr, + proc.https_interception_addr, "localhost", "example.com", "/", @@ -782,16 +804,16 @@ fn mitm_integration_rejects_sni_host_mismatch_with_400() { } #[test] -fn mitm_integration_rejects_non_443_host_port() { - let spec = MitmSpec { +fn https_interception_integration_rejects_non_443_host_port() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); let resp = tls_http_request( - proc.mitm_addr, + proc.https_interception_addr, "localhost", "localhost:8443", "/", @@ -807,17 +829,17 @@ fn mitm_integration_rejects_non_443_host_port() { } #[test] -fn mitm_integration_rejects_absolute_form_request() { - let spec = MitmSpec { +fn https_interception_integration_rejects_absolute_form_request() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); // Send an absolute-form request (http://localhost/path) instead of origin-form (/path). // This should be rejected with 400 to prevent URI authority bypass. - let mut tls = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + let mut tls = connect_tls_stream(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) .expect("TLS handshake should succeed for allowed host"); let req = "GET http://localhost:9999/bypass HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; @@ -832,20 +854,20 @@ fn mitm_integration_rejects_absolute_form_request() { assert_eq!( parse_status_code(&resp), Some(400), - "absolute-form request must be rejected with 400 on MITM listener" + "absolute-form request must be rejected with 400 on HTTPS interception listener" ); } #[test] -fn mitm_integration_rejects_absolute_form_even_with_matching_sni_host_and_port() { - let spec = MitmSpec { +fn https_interception_integration_rejects_absolute_form_even_with_matching_sni_host_and_port() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); - let mut tls = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + let mut tls = connect_tls_stream(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) .expect("TLS handshake should succeed for allowed host"); let req = "GET https://localhost:443/safe HTTP/1.1\r\nHost: localhost:443\r\nConnection: close\r\n\r\n"; tls.write_all(req.as_bytes()) @@ -864,11 +886,11 @@ fn mitm_integration_rejects_absolute_form_even_with_matching_sni_host_and_port() } #[test] -fn mitm_config_rejects_cert_cache_ttl_larger_than_cert_ttl() { - let spec = MitmSpec { +fn https_interception_config_rejects_cert_cache_ttl_larger_than_cert_ttl() { + let spec = HttpsInterceptionSpec { cert_ttl_seconds: 60, cert_cache_ttl_seconds: 61, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); @@ -881,46 +903,50 @@ fn mitm_config_rejects_cert_cache_ttl_larger_than_cert_ttl() { }); assert!( !status.success(), - "MITM config must fail startup when cert_cache_ttl_seconds > cert_ttl_seconds" + "HTTPS interception config must fail startup when cert_cache_ttl_seconds > cert_ttl_seconds" ); } #[test] -fn mitm_integration_enforces_connection_limit_on_mitm_listener() { - let spec = MitmSpec { +fn https_interception_integration_enforces_connection_limit_on_https_interception_listener() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), max_connections: 1, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); - let first_conn = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem) - .expect("first TLS connection should succeed"); + let first_conn = + connect_tls_stream(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) + .expect("first TLS connection should succeed"); thread::sleep(Duration::from_millis(150)); - let second = connect_tls_stream(proc.mitm_addr, "localhost", &proc.ca_cert_pem); + let second = connect_tls_stream(proc.https_interception_addr, "localhost", &proc.ca_cert_pem); drop(first_conn); assert!( second.is_err(), - "second MITM TLS connection should be rejected when max_connections=1 and one connection is held open" + "second HTTPS interception TLS connection should be rejected when max_connections=1 and one connection is held open" ); } #[test] -fn mitm_metrics_tls_handshakes_are_counted_once_per_connection() { - let spec = MitmSpec { +fn https_interception_metrics_tls_handshakes_are_counted_once_per_connection() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), deny_handshake_on_disallowed_sni: true, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); - tls_handshake_leaf_cert(proc.mitm_addr, "localhost", &proc.ca_cert_pem) + tls_handshake_leaf_cert(proc.https_interception_addr, "localhost", &proc.ca_cert_pem) .expect("successful handshake must succeed"); - let disallowed = - tls_handshake_leaf_cert(proc.mitm_addr, "disallowed.example.com", &proc.ca_cert_pem); + let disallowed = tls_handshake_leaf_cert( + proc.https_interception_addr, + "disallowed.example.com", + &proc.ca_cert_pem, + ); assert!( disallowed.is_err(), "disallowed SNI handshake should fail when deny_handshake_on_disallowed_sni=true" @@ -958,17 +984,20 @@ fn mitm_metrics_tls_handshakes_are_counted_once_per_connection() { } #[test] -fn mitm_integration_disallowed_sni_handshake_can_be_denied() { - let spec = MitmSpec { +fn https_interception_integration_disallowed_sni_handshake_can_be_denied() { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), deny_handshake_on_disallowed_sni: true, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); - let result = - tls_handshake_leaf_cert(proc.mitm_addr, "disallowed.example.com", &proc.ca_cert_pem); + let result = tls_handshake_leaf_cert( + proc.https_interception_addr, + "disallowed.example.com", + &proc.ca_cert_pem, + ); assert!( result.is_err(), "deny_handshake_on_disallowed_sni=true must fail the TLS handshake for disallowed SNI" @@ -976,17 +1005,18 @@ fn mitm_integration_disallowed_sni_handshake_can_be_denied() { } #[test] -fn mitm_integration_disallowed_sni_can_return_http_403_when_handshake_denial_is_disabled() { - let spec = MitmSpec { +fn https_interception_integration_disallowed_sni_can_return_http_403_when_handshake_denial_is_disabled( +) { + let spec = HttpsInterceptionSpec { rules_yaml: rules_allow_hosts(&["localhost"]), deny_handshake_on_disallowed_sni: false, - ..MitmSpec::default() + ..HttpsInterceptionSpec::default() }; let mut proc = BotboxProcess::start(spec); proc.wait_for_healthz_endpoint(Duration::from_secs(5)); let resp = tls_http_request( - proc.mitm_addr, + proc.https_interception_addr, "disallowed.example.com", "disallowed.example.com", "/", diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 04b1f98..61c654f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -183,8 +183,8 @@ async fn test_allowed_host_without_rewrite_forwards() { } #[tokio::test] -async fn test_non_mitm_http_path_regression_allow_and_deny() { - // Regression guard: existing non-MITM HTTP path should keep allow/deny semantics. +async fn test_http_mode_path_regression_allow_and_deny() { + // Regression guard: existing HTTP-mode path (no interception) should keep allow/deny semantics. let mock_server = MockServer::start().await; Mock::given(method("GET")) .and(path("/smoke")) From 7623976da9c6da3cd7dde46bd8f7e807e3b2a65e Mon Sep 17 00:00:00 2001 From: reoring Date: Thu, 12 Feb 2026 08:43:15 +0900 Subject: [PATCH 3/5] feat: require explicit BOTBOX_ENABLE_IPV6 for iptables init Make IPv6 enforcement an explicit opt-in/out and fail fast when enabled but ip6tables or ip6table_nat is unavailable. Update docs and test manifests to set BOTBOX_ENABLE_IPV6 explicitly, pin curl images, and improve HTTPS interception certificate metrics. --- Dockerfile | 2 +- README.md | 77 ++++++++++++ docs/architecture.md | 60 +++++++-- docs/security.md | 51 ++++++++ scripts/iptables-init.sh | 115 ++++++++++++++---- src/https_interception.rs | 10 +- src/metrics.rs | 16 ++- tests/at/README.md | 1 + tests/at/manifests/at-pod.yaml | 4 +- tests/e2e/manifests/egress-test.yaml | 9 +- .../manifests/https-interception-test.yaml | 12 +- tests/e2e/run-kind-acceptance.sh | 4 +- 12 files changed, 312 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27e0c01..dc6f814 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ FROM gcr.io/distroless/cc-debian12:nonroot COPY --from=builder /app/target/release/botbox /botbox COPY config.yaml /etc/botbox/config.yaml -EXPOSE 8080 9090 +EXPOSE 8080 8443 9090 # Kubernetes health probes: # readinessProbe: diff --git a/README.md b/README.md index 7eb88b3..3da2b6e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ This makes BotBox a natural fit for any scenario where you need to **run untrust ## How it Works +### Modes of Operation + +BotBox supports two modes: + +1. **HTTP-only (default)** -- Intercepts plaintext HTTP on port 80, rewrites headers, and originates TLS to upstream. App containers make `http://` requests and BotBox upgrades them to HTTPS. +2. **HTTPS Interception** -- Additionally intercepts outbound HTTPS on port 443 via a TLS-terminating listener on port 8443. BotBox dynamically issues short-lived leaf certificates signed by a local CA, decrypts the traffic, applies the same allowlist and header-rewrite pipeline, then re-encrypts to the upstream. This allows credential injection into HTTPS requests without requiring the app to use plaintext HTTP. + ### Request Processing ```mermaid @@ -73,11 +80,78 @@ flowchart TD NAT -- "loopback" --> SKIP1["RETURN"] NAT -- "UID 1337" --> SKIP2["RETURN"] NAT -- "TCP :80" --> REDIR[":80 → :8080
REDIRECT to BotBox"] + NAT -- "TCP :443" --> REDIR443[":443 → :8443
REDIRECT to HTTPS
interception
"] style DROP fill:#fee2e2,stroke:#dc2626 style REDIR fill:#dbeafe,stroke:#2563eb + style REDIR443 fill:#dbeafe,stroke:#2563eb +``` + +## HTTPS Interception Mode + +When enabled, BotBox intercepts outbound HTTPS traffic by terminating TLS at the sidecar and re-encrypting to the upstream. This lets BotBox inspect and rewrite headers inside HTTPS requests -- the same allowlist, header-rewrite, and credential-injection pipeline applies. + +### How It Works + +1. iptables redirects outbound TCP port 443 to BotBox's HTTPS interception listener on port 8443. +2. BotBox terminates TLS using a dynamically issued leaf certificate (signed by a local CA). +3. The decrypted HTTP request passes through the standard proxy pipeline (allowlist check, header rewrite, secret injection). +4. BotBox re-encrypts and forwards the request to the upstream over TLS. + +The app container sees a valid TLS connection (signed by the local CA) and does not need any proxy configuration. + +### Configuration + +Add an `https_interception` block to your config: + +```yaml +https_interception: + enabled: true + listen_addr: "127.0.0.1" + listen_port: 8443 + ca_cert_path: "/etc/botbox/https_interception/ca.crt" + ca_key_path: "/etc/botbox/https_interception/ca.key" + enforce_sni_host_match: true # default: true -- reject requests where Host header != SNI + deny_handshake_on_disallowed_sni: false # default: false -- when true, refuse TLS handshake for non-allowlisted hosts + cert_ttl_seconds: 86400 # default: 86400 (24h) -- leaf cert validity period + cert_cache_size: 1024 # default: 1024 -- LRU cache capacity + cert_cache_ttl_seconds: 3600 # default: 3600 (1h) -- cache entry TTL + handshake_timeout_ms: 5000 # default: 5000 -- TLS handshake timeout +``` + +Environment variable overrides: + +| Variable | Description | +|---|---| +| `BOTBOX_ENABLE_HTTPS_INTERCEPTION` | Set to `1` in the iptables init container to add the port-443 NAT redirect | +| `BOTBOX_HTTPS_INTERCEPTION_PORT` | Override the HTTPS interception listen port (default: 8443) | + +> **Note:** HTTPS interception requires **both** the config file setting (`https_interception.enabled: true`) and the iptables environment variable (`BOTBOX_ENABLE_HTTPS_INTERCEPTION=1`). The config tells BotBox to start the TLS listener; the environment variable tells the init container to install the NAT redirect rule. + +> **Note:** `BOTBOX_ENABLE_IPV6` is a **required** environment variable for the iptables init container (no default). Set to `1` for dual-stack environments (mirrors all rules via ip6tables) or `0` for IPv4-only. The script exits with an error if this variable is not set. + +### iptables Rules for HTTPS Interception + +The init container must add a NAT redirect for port 443 in addition to the existing port 80 rule: + +```bash +iptables -t nat -A EGRESS_REDIRECT -p tcp --dport 443 -j REDIRECT --to-port 8443 ``` +### App-side CA Trust + +The app container must trust the BotBox CA certificate. Mount the CA cert (NOT the private key) into the app container and configure the runtime: + +| Runtime / Library | Environment Variable or Flag | +|---|---| +| curl / OpenSSL | `CURL_CA_BUNDLE=/etc/botbox/https_interception/ca.crt` or `SSL_CERT_FILE=/etc/botbox/https_interception/ca.crt` | +| Node.js | `NODE_EXTRA_CA_CERTS=/etc/botbox/https_interception/ca.crt` | +| Python requests | `REQUESTS_CA_BUNDLE=/etc/botbox/https_interception/ca.crt` | +| JVM (Java, Kotlin) | `-Djavax.net.ssl.trustStore=/path/to/truststore.jks` (import the CA cert into a JKS truststore) | +| Go (net/http) | `SSL_CERT_FILE=/etc/botbox/https_interception/ca.crt` | + +**Security note:** The CA **private key** must NOT be mounted into app containers. Only the CA certificate (public) should be shared. The private key must be in a separate volume accessible only to the BotBox sidecar. + ## Quickstart ### Prerequisites @@ -116,6 +190,9 @@ egress_policy: initContainers: - name: iptables-init # installs the recommended iptables NAT+filter rules image: botbox-iptables-init:test + env: + - name: BOTBOX_ENABLE_IPV6 + value: "1" # required — set to "0" if ip6tables or ip6table_nat is unavailable securityContext: capabilities: { add: [NET_ADMIN] } runAsUser: 0 diff --git a/docs/architecture.md b/docs/architecture.md index 2f62dab..3fa2443 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,6 +21,38 @@ sequenceDiagram BotBox-->>App: 200 OK (streamed) ``` +### HTTPS Interception Flow + +When HTTPS interception is enabled, outbound port-443 traffic follows this path: + +```mermaid +sequenceDiagram + participant App as App Container + participant IPT as iptables + participant TLS as BotBox HTTPS Interception
(port 8443) + participant Proxy as BotBox Proxy Handler + participant API as Upstream API + + App->>IPT: TLS ClientHello
SNI: api.openai.com + IPT->>TLS: REDIRECT :443 → :8443 + + Note over TLS: Issue leaf cert for api.openai.com
(signed by local CA, cached in LRU) + TLS->>App: ServerHello + leaf cert + + App->>TLS: GET /v1/models
Host: api.openai.com + Note over TLS: Validate: origin-form only,
SNI/Host match, port 443 + + TLS->>Proxy: Decrypted HTTP request + Note over Proxy: Allowlist check → allow + Note over Proxy: Header rewrite → Authorization: Bearer sk-... + Note over Proxy: TLS origination → HTTPS + + Proxy->>API: GET /v1/models
Authorization: Bearer sk-... + API-->>Proxy: 200 OK (response body) + Proxy-->>TLS: 200 OK + TLS-->>App: 200 OK (re-encrypted) +``` + The proxy handler executes these steps for each request: 1. **Extract host** — from URI authority (explicit proxy) or Host header (transparent proxy) @@ -39,6 +71,7 @@ src/ ├── config.rs # YAML config parsing with serde validation ├── error.rs # ProxyError: SecretNotFound, InvalidHeaderName, InvalidHeaderValue ├── header_rewrite.rs # Delete-then-add pattern to prevent header smuggling +├── https_interception.rs # HTTPS interception: CA loading, cert cache, TLS listener, request validation ├── lib.rs # Module re-exports ├── logging.rs # tracing JSON subscriber setup ├── main.rs # Server startup, signal handling, graceful shutdown @@ -63,23 +96,27 @@ Example init container: ```yaml - name: iptables-init image: botbox-iptables-init:test + env: + - name: BOTBOX_ENABLE_IPV6 + value: "1" # required — set to "0" if ip6tables or ip6table_nat is unavailable + # - name: BOTBOX_UID + # value: "1337" + # - name: BOTBOX_PROXY_PORT + # value: "8080" securityContext: runAsUser: 0 runAsNonRoot: false capabilities: add: [NET_ADMIN] - # If you change the BotBox UID/port, set these to match. - # env: - # - name: BOTBOX_UID - # value: "1337" - # - name: BOTBOX_PROXY_PORT - # value: "8080" ``` The init script supports overrides via environment variables: - `BOTBOX_UID` (default `1337`) - `BOTBOX_PROXY_PORT` (default `8080`) - `BOTBOX_REDIRECT_FROM_PORT` (default `80`) +- `BOTBOX_ENABLE_HTTPS_INTERCEPTION` (default `0` — set to `1` to add the port-443 → 8443 NAT redirect) +- `BOTBOX_HTTPS_INTERCEPTION_PORT` (default `8443`) +- `BOTBOX_ENABLE_IPV6` (**required**, no default — `1` to mirror all rules via ip6tables, `0` for IPv4-only) - `BOTBOX_NAT_CHAIN` (default `EGRESS_REDIRECT`) - `BOTBOX_FILTER_CHAIN` (default `EGRESS_FILTER`) - `BOTBOX_IPTABLES_WAIT_SECONDS` (default `5`) @@ -106,6 +143,7 @@ iptables -I OUTPUT 1 -j EGRESS_FILTER | NAT: `-o lo -j RETURN` | Skip loopback traffic (healthz probes, metrics scraping) | | NAT: `--uid-owner 1337 -j RETURN` | Skip proxy's own outbound connections (prevents redirect loops) | | NAT: `--dport 80 -j REDIRECT --to-port 8080` | Redirect HTTP to the proxy | +| NAT: `--dport 443 -j REDIRECT --to-port 8443` | Redirect HTTPS to the interception listener (when enabled) | | NAT: `-I OUTPUT 1 -j EGRESS_REDIRECT` | Insert the NAT redirect chain at the top of OUTPUT (ensures priority over existing rules) | | Filter: `-o lo -j RETURN` | Allow loopback traffic | | Filter: `--uid-owner 1337 -j RETURN` | Allow proxy's upstream HTTPS connections | @@ -193,7 +231,8 @@ The secrets directory is watched via `inotify` (Linux) using the `notify` crate. | Port | Path | Description | |------|------|-------------| -| 8080 | — | Proxy listener (loopback only) | +| 8080 | — | HTTP proxy listener (loopback only) | +| 8443 | — | HTTPS interception TLS listener (loopback only, when enabled) | | 9090 | `/healthz` | Returns 200 when ready, 503 otherwise | | 9090 | `/metrics` | Prometheus exposition format | | 9090 | other | Returns 404 | @@ -206,8 +245,13 @@ All metrics are exposed in Prometheus text format on the metrics port. |---|---|---|---| | `botbox_requests_total` | Counter | `host`, `decision` | Total requests by host and allow/deny decision | | `botbox_request_duration_seconds` | Histogram | `host` | Upstream request duration | -| `botbox_upstream_errors_total` | Counter | `host`, `error_type` | Upstream errors (connection failures, timeouts, HTTP errors) | +| `botbox_upstream_errors_total` | Counter | `host`, `status_code` | Upstream errors (connection failures, timeouts, HTTP errors) | | `botbox_header_rewrites_total` | Counter | `host` | Header rewrite operations | +| `botbox_tls_handshakes_total` | Counter | `result` | HTTPS interception TLS handshakes (`ok`, `io_error`, `timeout`) | +| `botbox_https_interception_cert_issued_total` | Counter | `result` | Certificates issued or denied (`issued`, `denied`) | +| `botbox_https_interception_cert_error_total` | Counter | `error_type` | Certificate generation errors (`signing_failed`) | +| `botbox_https_interception_cert_cache_total` | Counter | `result` | Cert cache lookups (`hit`, `miss`) | +| `botbox_https_interception_host_mismatch_total` | Counter | — | SNI/Host header mismatch detections | ## Graceful Shutdown diff --git a/docs/security.md b/docs/security.md index 2621ceb..05a9821 100644 --- a/docs/security.md +++ b/docs/security.md @@ -96,6 +96,48 @@ Security impact: - Safely supports Kubernetes Secret volume layouts (symlink-based atomic updates). - Avoids partial reload states. +### 6. HTTPS Interception Security Considerations + +When HTTPS interception is enabled, BotBox terminates and re-originates TLS for outbound HTTPS traffic. This introduces additional security surface that must be carefully managed. + +#### CA Key Protection + +The CA private key is the root of trust for all dynamically issued leaf certificates. If compromised, an attacker could issue certificates for arbitrary hosts. + +- The CA private key (`ca_key_path`) must be stored in a **separate volume** that is mounted only into the BotBox sidecar container. +- The CA private key must **never** be mounted into app containers. Only the CA certificate (public key) should be shared with app containers for trust configuration. +- Use Kubernetes Secret volumes with restrictive file permissions (e.g., `defaultMode: 0400`). +- Consider using a dedicated Kubernetes Secret (separate from the API key secrets) for CA material. + +#### Short-lived Certificates + +BotBox issues leaf certificates dynamically for each intercepted hostname. The `cert_ttl_seconds` configuration controls the validity period. + +- Use the shortest practical TTL for your workload. The default is 86400 seconds (24 hours). +- Shorter TTLs limit the window of exposure if a leaf certificate's private key is extracted from memory. +- The `cert_cache_ttl_seconds` must not exceed `cert_ttl_seconds` to prevent serving expired certificates from cache. +- Valid range: 60 seconds to 604800 seconds (7 days). + +#### deny_handshake_on_disallowed_sni + +When `deny_handshake_on_disallowed_sni` is set to `true`, BotBox refuses to complete the TLS handshake for hostnames that are not in the allowlist. The connection is dropped before any certificate is issued. + +- **Default:** `false` (the TLS handshake completes and the request is denied at the HTTP layer with 403). +- **When enabled:** Non-allowlisted hosts never receive a leaf certificate, providing defense-in-depth and reducing unnecessary cert issuance. +- Trade-off: with this enabled, the app container receives a TLS error instead of a clear 403 HTTP response for denied hosts, which may complicate debugging. + +#### enforce_sni_host_match + +When `enforce_sni_host_match` is `true` (the default), BotBox validates that the `Host` header in the decrypted HTTP request matches the SNI hostname from the TLS handshake. + +- This prevents an attacker from establishing a TLS connection to an allowed host and then sending HTTP requests with a different `Host` header to bypass allowlist checks. +- Mismatches are logged and tracked via the `botbox_https_interception_host_mismatch_total` metric. +- The request is rejected with HTTP 400. + +#### Absolute-form Request Rejection + +HTTPS interception connections are transparently redirected, so the app container believes it is talking directly to the upstream server. Requests must use origin-form (`GET /path`). Absolute-form requests (`GET http://host/path`) are rejected because the URI authority could override the Host header and bypass security checks. + ## Secure Baseline Configuration ```yaml @@ -134,13 +176,22 @@ egress_policy: - If `allow_non_loopback` must be enabled, deploy compensating controls (mTLS or shared-secret authentication) and document the justification. - Ensure app containers do NOT run as UID 1337 (BotBox's UID); otherwise they can bypass iptables owner-match rules. - Use `/healthz` for readiness only (it is gated on required secrets). For liveness, prefer a TCP socket probe on port 9090, or add a separate always-200 endpoint. +- **HTTPS Interception:** Mount the CA private key in a separate Kubernetes Secret volume accessible only to the BotBox sidecar (`readOnly: true`, `defaultMode: 0400`). Never mount the CA private key into the app container. +- **HTTPS Interception:** Only share the CA certificate (public) with app containers for trust configuration. +- **HTTPS Interception:** Enable `enforce_sni_host_match: true` (default) to prevent Host header spoofing after TLS termination. +- **HTTPS Interception:** Consider enabling `deny_handshake_on_disallowed_sni: true` for defense-in-depth (blocks TLS handshake for non-allowlisted hosts). +- **HTTPS Interception:** Set `cert_ttl_seconds` to the shortest practical value for your workload to limit exposure from compromised leaf keys. ## Residual Risks and Limitations - Policy is hostname-based; DNS trust remains part of the security boundary. - If `default_action: allow` is used, unknown hosts are reachable by design. - BotBox does not provide upstream certificate pinning (uses system/web PKI roots). +- **IPv6 bypass:** iptables rules only cover IPv4. `BOTBOX_ENABLE_IPV6=1` mirrors rules via ip6tables, but requires kernel ip6table_nat support. When `BOTBOX_ENABLE_IPV6=0`, IPv6 traffic bypasses the proxy entirely — disable IPv6 at the node level (`net.ipv6.conf.all.disable_ipv6=1`) or block it with NetworkPolicy. - BotBox does not inspect payload content for exfiltration. +- **HTTPS Interception — dynamic cert issuance:** Leaf certificates are generated at runtime and their private keys exist in BotBox process memory. A compromised BotBox process or memory-dump attack could expose them. Mitigate with short `cert_ttl_seconds`, restrictive container security context, and file system access controls. +- **HTTPS Interception — CA key compromise:** If the CA private key is extracted (e.g., via container escape or volume misconfiguration), an attacker can issue trusted certificates for any host within the Pod. Mitigate by strict volume mount isolation, short CA key lifetimes, monitoring `botbox_https_interception_cert_issued_total` for unexpected issuance, and rotating the CA immediately if compromise is suspected. +- **HTTPS Interception — in-memory plaintext:** After TLS termination, request/response data exists in plaintext in BotBox's process memory. This is inherent to the interception design and equivalent to the HTTP-only mode's threat surface. ## Validation and Testing diff --git a/scripts/iptables-init.sh b/scripts/iptables-init.sh index 61be64c..51aaaff 100644 --- a/scripts/iptables-init.sh +++ b/scripts/iptables-init.sh @@ -8,6 +8,14 @@ set -eu # Requirements: # - run as root # - CAP_NET_ADMIN +# +# IPv6 support: +# BOTBOX_ENABLE_IPV6 must be explicitly set to 1 or 0. +# When 1, every iptables rule is mirrored via ip6tables. If ip6tables is +# unavailable the script exits with an error (fail-fast). +# When 0, only IPv4 rules are applied. In dual-stack environments IPv6 +# traffic may bypass the proxy — secure IPv6 separately (e.g. NetworkPolicy, +# sysctl net.ipv6.conf.all.disable_ipv6=1). PROXY_UID="${BOTBOX_UID:-1337}" PROXY_PORT="${BOTBOX_PROXY_PORT:-8080}" @@ -15,19 +23,59 @@ REDIRECT_FROM_PORT="${BOTBOX_REDIRECT_FROM_PORT:-80}" ENABLE_HTTPS_INTERCEPTION="${BOTBOX_ENABLE_HTTPS_INTERCEPTION:-0}" HTTPS_INTERCEPTION_PORT="${BOTBOX_HTTPS_INTERCEPTION_PORT:-8443}" +# BOTBOX_ENABLE_IPV6 is required — no default. +if [ -z "${BOTBOX_ENABLE_IPV6+x}" ]; then + echo "ERROR: BOTBOX_ENABLE_IPV6 is not set." >&2 + echo " Set BOTBOX_ENABLE_IPV6=1 to mirror rules via ip6tables (recommended for dual-stack)." >&2 + echo " Set BOTBOX_ENABLE_IPV6=0 for IPv4-only (ensure IPv6 is disabled or blocked separately)." >&2 + exit 1 +fi +ENABLE_IPV6="${BOTBOX_ENABLE_IPV6}" + +if [ "$ENABLE_IPV6" != "0" ] && [ "$ENABLE_IPV6" != "1" ]; then + echo "ERROR: BOTBOX_ENABLE_IPV6 must be 0 or 1 (got '${ENABLE_IPV6}')." >&2 + exit 1 +fi + NAT_CHAIN="${BOTBOX_NAT_CHAIN:-EGRESS_REDIRECT}" FILTER_CHAIN="${BOTBOX_FILTER_CHAIN:-EGRESS_FILTER}" WAIT_SECONDS="${BOTBOX_IPTABLES_WAIT_SECONDS:-5}" -ipt() { +# Helper: run iptables, and optionally ip6tables, with the same arguments. +# Used for rules that are identical across IPv4 and IPv6 (e.g. -o lo, --uid-owner). +run_ipt() { iptables -w "${WAIT_SECONDS}" "$@" + if [ "$ENABLE_IPV6" = "1" ]; then + ip6tables -w "${WAIT_SECONDS}" "$@" + fi } -ipt_nat() { +run_ipt_nat() { iptables -w "${WAIT_SECONDS}" -t nat "$@" + if [ "$ENABLE_IPV6" = "1" ]; then + ip6tables -w "${WAIT_SECONDS}" -t nat "$@" + fi } +# IPv6: verify ip6tables is available when enabled; warn when disabled. +if [ "$ENABLE_IPV6" = "1" ]; then + if ! command -v ip6tables >/dev/null 2>&1; then + echo "ERROR: BOTBOX_ENABLE_IPV6=1 but ip6tables is not available." >&2 + echo " Install ip6tables or set BOTBOX_ENABLE_IPV6=0." >&2 + exit 1 + fi + if ! ip6tables -w "${WAIT_SECONDS}" -t nat -L -n >/dev/null 2>&1; then + echo "ERROR: BOTBOX_ENABLE_IPV6=1 but ip6tables NAT table is not available." >&2 + echo " The kernel may not support ip6table_nat. Set BOTBOX_ENABLE_IPV6=0 or load the module." >&2 + exit 1 + fi +else + echo "[WARN] BOTBOX_ENABLE_IPV6=0: IPv6 traffic control is DISABLED." >&2 + echo " In dual-stack environments, IPv6 traffic may bypass the proxy." >&2 + echo " Ensure IPv6 is disabled (sysctl) or blocked via NetworkPolicy." >&2 +fi + # Guard: HTTPS interception + REDIRECT_FROM_PORT=443 conflict. # When both are set, the HTTP redirect rule matches port 443 first and sends # traffic to the plain-HTTP proxy, making the HTTPS interception REDIRECT rule unreachable. @@ -40,49 +88,66 @@ if [ "${ENABLE_HTTPS_INTERCEPTION}" = "1" ] && [ "${REDIRECT_FROM_PORT}" = "443" fi echo "Installing BotBox iptables rules..." -echo " proxy_uid=${PROXY_UID} proxy_port=${PROXY_PORT} redirect_from_port=${REDIRECT_FROM_PORT}" +echo " proxy_uid=${PROXY_UID} proxy_port=${PROXY_PORT} redirect_from_port=${REDIRECT_FROM_PORT} ipv6=${ENABLE_IPV6}" # --- NAT rules: redirect app HTTP to BotBox --- # Create chain (ignore if it already exists), then flush for idempotency. -ipt_nat -N "${NAT_CHAIN}" 2>/dev/null || true -ipt_nat -F "${NAT_CHAIN}" +run_ipt_nat -N "${NAT_CHAIN}" 2>/dev/null || true +run_ipt_nat -F "${NAT_CHAIN}" # Ensure OUTPUT jump exists exactly once and is first. -while ipt_nat -D OUTPUT -p tcp -j "${NAT_CHAIN}" 2>/dev/null; do :; done +# Remove all existing jumps before re-inserting. +while iptables -w "${WAIT_SECONDS}" -t nat -D OUTPUT -p tcp -j "${NAT_CHAIN}" 2>/dev/null; do :; done +if [ "$ENABLE_IPV6" = "1" ]; then + while ip6tables -w "${WAIT_SECONDS}" -t nat -D OUTPUT -p tcp -j "${NAT_CHAIN}" 2>/dev/null; do :; done +fi -ipt_nat -A "${NAT_CHAIN}" -o lo -j RETURN -ipt_nat -A "${NAT_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN -ipt_nat -A "${NAT_CHAIN}" -p tcp --dport "${REDIRECT_FROM_PORT}" -j REDIRECT --to-port "${PROXY_PORT}" +# -o lo: loopback interface is the same for IPv4 and IPv6, safe to use run_ipt_nat. +run_ipt_nat -A "${NAT_CHAIN}" -o lo -j RETURN +run_ipt_nat -A "${NAT_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN +run_ipt_nat -A "${NAT_CHAIN}" -p tcp --dport "${REDIRECT_FROM_PORT}" -j REDIRECT --to-port "${PROXY_PORT}" # HTTPS interception: redirect outbound HTTPS (port 443) to interception listener if [ "${ENABLE_HTTPS_INTERCEPTION}" = "1" ]; then echo " https_interception_port=${HTTPS_INTERCEPTION_PORT} (HTTPS interception enabled)" - ipt_nat -A "${NAT_CHAIN}" -p tcp --dport 443 -j REDIRECT --to-port "${HTTPS_INTERCEPTION_PORT}" + run_ipt_nat -A "${NAT_CHAIN}" -p tcp --dport 443 -j REDIRECT --to-port "${HTTPS_INTERCEPTION_PORT}" fi -ipt_nat -I OUTPUT 1 -p tcp -j "${NAT_CHAIN}" +run_ipt_nat -I OUTPUT 1 -p tcp -j "${NAT_CHAIN}" # --- Filter rules: block direct outbound from non-BotBox processes --- -ipt -N "${FILTER_CHAIN}" 2>/dev/null || true -ipt -F "${FILTER_CHAIN}" +run_ipt -N "${FILTER_CHAIN}" 2>/dev/null || true +run_ipt -F "${FILTER_CHAIN}" -while ipt -D OUTPUT -j "${FILTER_CHAIN}" 2>/dev/null; do :; done +while iptables -w "${WAIT_SECONDS}" -D OUTPUT -j "${FILTER_CHAIN}" 2>/dev/null; do :; done +if [ "$ENABLE_IPV6" = "1" ]; then + while ip6tables -w "${WAIT_SECONDS}" -D OUTPUT -j "${FILTER_CHAIN}" 2>/dev/null; do :; done +fi -ipt -A "${FILTER_CHAIN}" -o lo -j RETURN -ipt -A "${FILTER_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN -# On some kernels (≥6.x), REDIRECT changes the destination to 127.0.0.1 but +run_ipt -A "${FILTER_CHAIN}" -o lo -j RETURN +run_ipt -A "${FILTER_CHAIN}" -m owner --uid-owner "${PROXY_UID}" -j RETURN +# On some kernels (>=6.x), REDIRECT changes the destination to loopback but # does not update the output interface to lo before the filter chain runs. # Allow packets whose destination was rewritten to loopback by NAT REDIRECT. -ipt -A "${FILTER_CHAIN}" -d 127.0.0.0/8 -j RETURN -ipt -A "${FILTER_CHAIN}" -p udp --dport 53 -j RETURN -ipt -A "${FILTER_CHAIN}" -p tcp --dport 53 -j RETURN -ipt -A "${FILTER_CHAIN}" -p tcp -j DROP -ipt -A "${FILTER_CHAIN}" -p udp -j DROP +# IPv4 loopback is 127.0.0.0/8; IPv6 loopback is ::1/128. These must be separate calls. +iptables -w "${WAIT_SECONDS}" -A "${FILTER_CHAIN}" -d 127.0.0.0/8 -j RETURN +if [ "$ENABLE_IPV6" = "1" ]; then + ip6tables -w "${WAIT_SECONDS}" -A "${FILTER_CHAIN}" -d ::1/128 -j RETURN +fi +run_ipt -A "${FILTER_CHAIN}" -p udp --dport 53 -j RETURN +run_ipt -A "${FILTER_CHAIN}" -p tcp --dport 53 -j RETURN +run_ipt -A "${FILTER_CHAIN}" -p tcp -j DROP +run_ipt -A "${FILTER_CHAIN}" -p udp -j DROP -ipt -I OUTPUT 1 -j "${FILTER_CHAIN}" +run_ipt -I OUTPUT 1 -j "${FILTER_CHAIN}" echo "iptables rules installed:" -ipt_nat -L "${NAT_CHAIN}" -v -n -ipt -L "${FILTER_CHAIN}" -v -n +iptables -w "${WAIT_SECONDS}" -t nat -L "${NAT_CHAIN}" -v -n +iptables -w "${WAIT_SECONDS}" -L "${FILTER_CHAIN}" -v -n +if [ "$ENABLE_IPV6" = "1" ]; then + echo "ip6tables rules installed:" + ip6tables -w "${WAIT_SECONDS}" -t nat -L "${NAT_CHAIN}" -v -n + ip6tables -w "${WAIT_SECONDS}" -L "${FILTER_CHAIN}" -v -n +fi diff --git a/src/https_interception.rs b/src/https_interception.rs index d739f90..464651c 100644 --- a/src/https_interception.rs +++ b/src/https_interception.rs @@ -232,7 +232,7 @@ impl ResolvesServerCert for HttpsInterceptionCertResolver { Err(_reason) => { self.metrics .https_interception_cert_issued_total - .with_label_values(&["skipped_invalid"]) + .with_label_values(&["denied"]) .inc(); return None; } @@ -244,7 +244,7 @@ impl ResolvesServerCert for HttpsInterceptionCertResolver { if matches!(decision, Decision::Deny) { self.metrics .https_interception_cert_issued_total - .with_label_values(&["skipped_disallowed"]) + .with_label_values(&["denied"]) .inc(); return None; } @@ -269,12 +269,16 @@ impl ResolvesServerCert for HttpsInterceptionCertResolver { self.cache.insert(hostname, key.clone()); self.metrics .https_interception_cert_issued_total - .with_label_values(&["issued_allow"]) + .with_label_values(&["issued"]) .inc(); Some(key) } Err(e) => { error!(error = %e, "failed to issue HTTPS interception leaf certificate"); + self.metrics + .https_interception_cert_error_total + .with_label_values(&["signing_failed"]) + .inc(); None } } diff --git a/src/metrics.rs b/src/metrics.rs index 4286abb..fad8bb9 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -17,6 +17,7 @@ pub struct Metrics { // HTTPS interception metrics pub tls_handshakes_total: IntCounterVec, pub https_interception_cert_issued_total: IntCounterVec, + pub https_interception_cert_error_total: IntCounterVec, pub https_interception_cert_cache_total: IntCounterVec, pub https_interception_host_mismatch_total: IntCounter, } @@ -72,7 +73,16 @@ impl Metrics { "botbox_https_interception_cert_issued_total", "Total HTTPS interception certificates issued", ), - &["decision"], + &["result"], + ) + .unwrap(); + + let https_interception_cert_error_total = IntCounterVec::new( + Opts::new( + "botbox_https_interception_cert_error_total", + "Total HTTPS interception certificate generation errors", + ), + &["error_type"], ) .unwrap(); @@ -107,6 +117,9 @@ impl Metrics { registry .register(Box::new(https_interception_cert_issued_total.clone())) .unwrap(); + registry + .register(Box::new(https_interception_cert_error_total.clone())) + .unwrap(); registry .register(Box::new(https_interception_cert_cache_total.clone())) .unwrap(); @@ -122,6 +135,7 @@ impl Metrics { request_duration_seconds, tls_handshakes_total, https_interception_cert_issued_total, + https_interception_cert_error_total, https_interception_cert_cache_total, https_interception_host_mismatch_total, } diff --git a/tests/at/README.md b/tests/at/README.md index 82239c4..c63dff7 100644 --- a/tests/at/README.md +++ b/tests/at/README.md @@ -20,6 +20,7 @@ The tests validate the end-to-end behavior in a Kubernetes cluster (kind recomme Notes: - These tests require Pod-level iptables (`CAP_NET_ADMIN`) to be permitted. - The test pod reaches `httpbin.org` over the public internet. +- `BOTBOX_ENABLE_IPV6` is required by the iptables init script. The AT manifest sets it to `0` (kind clusters typically lack ip6table_nat). For production dual-stack environments, set it to `1`. ## Automated Execution (kind E2E) diff --git a/tests/at/manifests/at-pod.yaml b/tests/at/manifests/at-pod.yaml index defd403..08eefca 100644 --- a/tests/at/manifests/at-pod.yaml +++ b/tests/at/manifests/at-pod.yaml @@ -54,6 +54,8 @@ spec: value: "1337" - name: BOTBOX_PROXY_PORT value: "8080" + - name: BOTBOX_ENABLE_IPV6 + value: "0" securityContext: runAsUser: 0 runAsNonRoot: false @@ -89,7 +91,7 @@ spec: mountPath: /etc/botbox readOnly: true - name: client - image: curlimages/curl:latest + image: curlimages/curl:8.11.1 imagePullPolicy: IfNotPresent command: ["/bin/sh", "-c", "sleep infinity"] securityContext: diff --git a/tests/e2e/manifests/egress-test.yaml b/tests/e2e/manifests/egress-test.yaml index 87decf6..ba25f20 100644 --- a/tests/e2e/manifests/egress-test.yaml +++ b/tests/e2e/manifests/egress-test.yaml @@ -47,6 +47,9 @@ spec: - name: iptables-init image: botbox-iptables-init:test imagePullPolicy: Never + env: + - name: BOTBOX_ENABLE_IPV6 + value: "0" securityContext: capabilities: add: [NET_ADMIN] @@ -82,7 +85,7 @@ spec: readOnly: true containers: - name: curl-client - image: curlimages/curl:latest + image: curlimages/curl:8.11.1 securityContext: runAsUser: 1000 runAsNonRoot: true @@ -93,7 +96,7 @@ spec: - | echo "Waiting for botbox to be ready..." for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:9090/healthz > /dev/null 2>&1; then + if curl -sf --connect-timeout 10 --max-time 30 http://127.0.0.1:9090/healthz > /dev/null 2>&1; then echo "Proxy is ready!" break fi @@ -101,7 +104,7 @@ spec: sleep 2 done echo "--- Sending request through proxy ---" - curl -v http://api.openai.com/v1/models 2>&1 + curl -v --connect-timeout 10 --max-time 30 http://api.openai.com/v1/models 2>&1 EXIT_CODE=$? echo "" echo "--- curl exit code: $EXIT_CODE ---" diff --git a/tests/e2e/manifests/https-interception-test.yaml b/tests/e2e/manifests/https-interception-test.yaml index 0beca47..f892884 100644 --- a/tests/e2e/manifests/https-interception-test.yaml +++ b/tests/e2e/manifests/https-interception-test.yaml @@ -84,6 +84,8 @@ spec: value: "1" - name: BOTBOX_HTTPS_INTERCEPTION_PORT value: "8443" + - name: BOTBOX_ENABLE_IPV6 + value: "0" securityContext: capabilities: add: [NET_ADMIN] @@ -142,7 +144,7 @@ spec: echo "Waiting for botbox readiness..." ready=0 for i in $(seq 1 40); do - if curl -sf http://127.0.0.1:9090/healthz >/dev/null 2>&1; then + if curl -sf --connect-timeout 10 --max-time 30 http://127.0.0.1:9090/healthz >/dev/null 2>&1; then ready=1 break fi @@ -154,7 +156,7 @@ spec: fi echo "--- HTTPS interception positive path (expect upstream reachability, often 401 with dummy key) ---" - code="$(curl -sS -o /tmp/openai.out -w '%{http_code}' https://api.openai.com/v1/models || true)" + code="$(curl -v -sS --connect-timeout 10 --max-time 30 -o /tmp/openai.out -w '%{http_code}' https://api.openai.com/v1/models || true)" echo "HTTPS_INTERCEPTION_OPENAI_CODE=${code}" cat /tmp/openai.out || true if [ "${code}" = "000" ]; then @@ -163,7 +165,7 @@ spec: fi echo "--- HTTPS interception negative allowlist path (expect deny) ---" - deny_code="$(curl -sS -o /tmp/deny.out -w '%{http_code}' https://example.com/ -m 8 || true)" + deny_code="$(curl -v -sS --connect-timeout 10 --max-time 30 -o /tmp/deny.out -w '%{http_code}' https://example.com/ || true)" echo "HTTPS_INTERCEPTION_DENY_CODE=${deny_code}" if [ "${deny_code}" != "403" ]; then echo "expected deny code 403 for non-allowlisted host" @@ -173,7 +175,7 @@ spec: echo "--- Direct bypass checks ---" echo "TCP/443 direct probe" - tcp_code="$(curl -sS -o /tmp/tcp.out -w '%{http_code}' --connect-timeout 2 -m 6 https://cloudflare.com/ || true)" + tcp_code="$(curl -v -sS -o /tmp/tcp.out -w '%{http_code}' --connect-timeout 10 --max-time 30 https://cloudflare.com/ || true)" echo "HTTPS_INTERCEPTION_TCP443_CODE=${tcp_code}" if [ "${tcp_code}" != "403" ] && [ "${tcp_code}" != "000" ]; then echo "unexpected TCP/443 probe result" @@ -184,7 +186,7 @@ spec: echo "UDP/443 direct probe (HTTP/3 if available)" if curl --help all 2>/dev/null | grep -q -- '--http3'; then set +e - curl -sS --http3 --connect-timeout 2 -m 6 https://cloudflare-quic.com/ >/tmp/udp.out 2>&1 + curl -v -sS --http3 --connect-timeout 10 --max-time 30 https://cloudflare-quic.com/ >/tmp/udp.out 2>&1 udp_rc=$? set -e echo "HTTPS_INTERCEPTION_UDP443_RC=${udp_rc}" diff --git a/tests/e2e/run-kind-acceptance.sh b/tests/e2e/run-kind-acceptance.sh index 649ff08..9b595fb 100755 --- a/tests/e2e/run-kind-acceptance.sh +++ b/tests/e2e/run-kind-acceptance.sh @@ -32,11 +32,11 @@ build_botbox_image() { return 0 fi - echo "Primary Dockerfile build failed; retrying with rust:1.93-bookworm fallback..." + echo "Primary Dockerfile build failed; retrying with rust:1.85-bookworm fallback..." local tmp_dockerfile tmp_dockerfile="$(mktemp)" cat > "${tmp_dockerfile}" <<'EOF' -FROM rust:1.93-bookworm AS builder +FROM rust:1.85-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ From 7d1615888dee3561a76ccd8eed5899e194f81d5f Mon Sep 17 00:00:00 2001 From: reoring Date: Thu, 12 Feb 2026 09:24:17 +0900 Subject: [PATCH 4/5] fix: bump Docker rust base to 1.88 time 0.3.47 requires rustc 1.88; keep kind acceptance and Docker builds working. --- Dockerfile | 2 +- tests/e2e/run-kind-acceptance.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index dc6f814..2b329a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.85-bookworm AS builder +FROM rust:1.88-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ diff --git a/tests/e2e/run-kind-acceptance.sh b/tests/e2e/run-kind-acceptance.sh index 9b595fb..514cd64 100755 --- a/tests/e2e/run-kind-acceptance.sh +++ b/tests/e2e/run-kind-acceptance.sh @@ -32,11 +32,11 @@ build_botbox_image() { return 0 fi - echo "Primary Dockerfile build failed; retrying with rust:1.85-bookworm fallback..." + echo "Primary Dockerfile build failed; retrying with rust:1.88-bookworm fallback..." local tmp_dockerfile tmp_dockerfile="$(mktemp)" cat > "${tmp_dockerfile}" <<'EOF' -FROM rust:1.85-bookworm AS builder +FROM rust:1.88-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ From 1737d5d2280dfd844154e987a95ef7b80be3303d Mon Sep 17 00:00:00 2001 From: reoring Date: Thu, 12 Feb 2026 09:50:13 +0900 Subject: [PATCH 5/5] chore: pin Rust toolchain to 1.93.0 Align CI and Docker builds on Rust 1.93.0, and declare the toolchain in rust-toolchain.toml and Cargo.toml. --- .github/workflows/ci.yml | 6 +++--- Cargo.toml | 1 + Dockerfile | 2 +- README.md | 2 +- rust-toolchain.toml | 4 ++++ tests/e2e/run-kind-acceptance.sh | 4 ++-- 6 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a07d633..72f0692 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 with: components: rustfmt - run: cargo fmt --all -- --check @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 with: components: clippy - uses: Swatinem/rust-cache@v2 @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 - uses: Swatinem/rust-cache@v2 - run: cargo test diff --git a/Cargo.toml b/Cargo.toml index 4cdd4f8..029c065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "botbox" version = "0.1.0" edition = "2021" +rust-version = "1.93.0" [dependencies] # Async runtime diff --git a/Dockerfile b/Dockerfile index 2b329a6..f817bc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.88-bookworm AS builder +FROM rust:1.93.0-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ diff --git a/README.md b/README.md index 3da2b6e..6e55e05 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/reoring/botbox/actions/workflows/ci.yml/badge.svg)](https://github.com/reoring/botbox/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/Rust-1.93.0-orange.svg)](https://www.rust-lang.org/)

BotBox diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..adb65fe --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.93.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/tests/e2e/run-kind-acceptance.sh b/tests/e2e/run-kind-acceptance.sh index 514cd64..d88102f 100755 --- a/tests/e2e/run-kind-acceptance.sh +++ b/tests/e2e/run-kind-acceptance.sh @@ -32,11 +32,11 @@ build_botbox_image() { return 0 fi - echo "Primary Dockerfile build failed; retrying with rust:1.88-bookworm fallback..." + echo "Primary Dockerfile build failed; retrying with rust:1.93.0-bookworm fallback..." local tmp_dockerfile tmp_dockerfile="$(mktemp)" cat > "${tmp_dockerfile}" <<'EOF' -FROM rust:1.88-bookworm AS builder +FROM rust:1.93.0-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./