diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eead76a..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,6 +36,18 @@ 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
+
+ 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..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
@@ -16,8 +17,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"
+# HTTPS interception 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/Dockerfile b/Dockerfile
index 27e0c01..f817bc2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM rust:1.85-bookworm AS builder
+FROM rust:1.93.0-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
@@ -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 4972a89..6e55e05 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/reoring/botbox/actions/workflows/ci.yml)
[](LICENSE)
-[](https://www.rust-lang.org/)
+[](https://www.rust-lang.org/)
@@ -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
@@ -138,16 +215,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
+tests/e2e/run-kind-acceptance.sh
+```
+
+### 5. Run individual E2E tests (optional)
```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-egress-test.sh
+tests/e2e/run-https-interception-test.sh
```
-### 5. Run unit tests
+### 6. Run unit tests
```bash
cargo test
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/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/scripts/iptables-init.sh b/scripts/iptables-init.sh
index e832b85..51aaaff 100644
--- a/scripts/iptables-init.sh
+++ b/scripts/iptables-init.sh
@@ -8,62 +8,146 @@ 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}"
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.
+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 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
+
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}"
-ipt_nat -I OUTPUT 1 -p tcp -j "${NAT_CHAIN}"
+# 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)"
+ run_ipt_nat -A "${NAT_CHAIN}" -p tcp --dport 443 -j REDIRECT --to-port "${HTTPS_INTERCEPTION_PORT}"
+fi
+
+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
-
-ipt -I OUTPUT 1 -j "${FILTER_CHAIN}"
+# 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
+
+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/config.rs b/src/config.rs
index f148c37..5f8ac15 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 https_interception: Option,
}
#[derive(Debug, Deserialize, Clone)]
@@ -45,6 +46,55 @@ pub struct HeaderRewrite {
pub secret_ref: Option,
}
+#[derive(Debug, Deserialize, Clone)]
+pub struct HttpsInterceptionConfig {
+ 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 HttpsInterceptionConfig {
+ 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 {
}
}
+ // 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!(
+ "https_interception.listen_addr must be an IP literal, got '{}'",
+ listen_addr
+ )
+ })?;
+ if !listen_ip.is_loopback() {
+ bail!(
+ "https_interception.listen_addr '{}' must be loopback; HTTPS interception listener must bind to loopback only",
+ listen_addr
+ );
+ }
+
+ // Port collision checks
+ let port = cfg.listen_port();
+ if port == self.listen_port() {
+ bail!(
+ "https_interception.listen_port {} collides with listen_port {}",
+ port,
+ self.listen_port()
+ );
+ }
+ if port == self.metrics_port() {
+ bail!(
+ "https_interception.listen_port {} collides with metrics_port {}",
+ port,
+ self.metrics_port()
+ );
+ }
+
+ // CA path validation
+ 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!("https_interception.ca_cert_path and https_interception.ca_key_path must not be empty");
+ } else if cert_empty {
+ bail!("https_interception.ca_cert_path must not be empty");
+ } else if key_empty {
+ bail!("https_interception.ca_key_path must not be empty");
+ }
+
+ // cert_cache_size > 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 = cfg.cert_ttl_seconds();
+ if !(60..=604800).contains(&ttl) {
+ bail!(
+ "https_interception.cert_ttl_seconds {} must be between 60 and 604800",
+ ttl
+ );
+ }
+
+ // handshake_timeout_ms in 100..60000
+ let hs_timeout = cfg.handshake_timeout_ms();
+ if !(100..=60000).contains(&hs_timeout) {
+ bail!(
+ "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 = cfg.cert_cache_ttl_seconds();
+ if cache_ttl > ttl {
+ bail!(
+ "https_interception.cert_cache_ttl_seconds ({}) must not exceed https_interception.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"));
}
+
+ // --- HTTPS interception design contract tests (WIP docs/wip/*) ---
+
+ #[test]
+ fn test_https_interception_enabled_rejects_non_loopback_listener_even_with_global_override() {
+ let yaml = r#"
+allow_non_loopback: true
+egress_policy:
+ rules: []
+https_interception:
+ enabled: true
+ listen_addr: "0.0.0.0"
+ listen_port: 8443
+ 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("https_interception.listen_addr"));
+ assert!(err.to_string().contains("loopback"));
+ }
+
+ #[test]
+ fn test_https_interception_enabled_rejects_port_collision_with_http_listener() {
+ let yaml = r#"
+listen_port: 8080
+metrics_port: 9090
+egress_policy:
+ rules: []
+https_interception:
+ enabled: true
+ listen_addr: "127.0.0.1"
+ listen_port: 8080
+ 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("https_interception.listen_port"));
+ assert!(err.to_string().contains("listen_port"));
+ }
+
+ #[test]
+ fn test_https_interception_enabled_rejects_port_collision_with_metrics_listener() {
+ let yaml = r#"
+listen_port: 8080
+metrics_port: 9090
+egress_policy:
+ rules: []
+https_interception:
+ enabled: true
+ listen_addr: "127.0.0.1"
+ listen_port: 9090
+ 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("https_interception.listen_port"));
+ assert!(err.to_string().contains("metrics_port"));
+ }
+
+ #[test]
+ fn test_https_interception_enabled_requires_non_empty_ca_paths() {
+ let yaml = r#"
+egress_policy:
+ rules: []
+https_interception:
+ 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("https_interception.ca_cert_path"));
+ assert!(err.to_string().contains("https_interception.ca_key_path"));
+ }
+
+ #[test]
+ fn test_https_interception_enabled_rejects_zero_cert_cache_size() {
+ let yaml = r#"
+egress_policy:
+ rules: []
+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"
+ 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_https_interception_enabled_rejects_out_of_range_cert_ttl_seconds() {
+ let yaml = r#"
+egress_policy:
+ rules: []
+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"
+ 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_https_interception_enabled_rejects_out_of_range_handshake_timeout_ms() {
+ let yaml = r#"
+egress_policy:
+ rules: []
+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"
+ 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/https_interception.rs b/src/https_interception.rs
new file mode 100644
index 0000000..464651c
--- /dev/null
+++ b/src/https_interception.rs
@@ -0,0 +1,520 @@
+use crate::allowlist::{Allowlist, Decision};
+use crate::config::{extract_port, normalize_policy_host, HttpsInterceptionConfig};
+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};
+
+/// 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 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))?;
+ 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(HttpsInterceptionCa {
+ 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)
+}
+
+/// 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 HttpsInterceptionCertResolver {
+ ca: Arc,
+ cache: CertCache,
+ cert_ttl: Duration,
+ deny_handshake_on_disallowed_sni: bool,
+ allowlist: Arc,
+ metrics: Metrics,
+}
+
+impl fmt::Debug for HttpsInterceptionCertResolver {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("HttpsInterceptionCertResolver")
+ .field(
+ "deny_handshake_on_disallowed_sni",
+ &self.deny_handshake_on_disallowed_sni,
+ )
+ .finish()
+ }
+}
+
+impl HttpsInterceptionCertResolver {
+ pub fn new(
+ ca: Arc,
+ config: &HttpsInterceptionConfig,
+ allowlist: Arc,
+ metrics: Metrics,
+ ) -> Self {
+ HttpsInterceptionCertResolver {
+ 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 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_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.
+ let sni = client_hello.server_name()?;
+
+ // Validate SNI
+ let hostname = match validate_sni(sni) {
+ Ok(h) => h,
+ Err(_reason) => {
+ self.metrics
+ .https_interception_cert_issued_total
+ .with_label_values(&["denied"])
+ .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
+ .https_interception_cert_issued_total
+ .with_label_values(&["denied"])
+ .inc();
+ return None;
+ }
+ }
+
+ // Check cache
+ if let Some(key) = self.cache.get(&hostname) {
+ self.metrics
+ .https_interception_cert_cache_total
+ .with_label_values(&["hit"])
+ .inc();
+ return Some(key);
+ }
+ self.metrics
+ .https_interception_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
+ .https_interception_cert_issued_total
+ .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
+ }
+ }
+ }
+}
+
+/// 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);
+
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
+ Arc::new(config)
+}
+
+/// Validate an HTTP request arriving over the HTTPS interception TLS listener.
+///
+/// HTTPS interception 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_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).
+ // 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 HTTPS interception 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,
+ "HTTPS interception 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.https_interception_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 HTTPS interception TLS listener.
+pub async fn run_https_interception_listener(
+ listener: TcpListener,
+ tls_config: Arc,
+ handler: Arc,
+ 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(cfg.handshake_timeout_ms());
+ let enforce_sni_host_match = cfg.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, "HTTPS interception 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, "HTTPS interception TLS handshake failed");
+ }
+ metrics
+ .tls_handshakes_total
+ .with_label_values(&["io_error"])
+ .inc();
+ return;
+ }
+ Err(_) => {
+ warn!(peer = %addr, "HTTPS interception 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 HTTPS interception-specific constraints
+ if let Err(resp) = validate_https_interception_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, "HTTPS interception 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,
+ "HTTPS interception connection error"
+ );
+ }
+ }
+ });
+ }
+ Err(e) => {
+ error!(error = %e, "HTTPS interception accept error");
+ }
+ }
+ }
+ _ = shutdown_rx.changed() => {
+ break;
+ }
+ }
+ }
+
+ // Drain in-flight HTTPS interception connections
+ info!(
+ "HTTPS interception 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!(
+ "HTTPS interception drain timeout, aborting {} remaining connections",
+ connections.len()
+ );
+ connections.abort_all();
+ }
+ info!("HTTPS interception listener stopped");
+}
diff --git a/src/lib.rs b/src/lib.rs
index d59f7cb..dcd23a9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,6 +2,7 @@ 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 proxy;
diff --git a/src/main.rs b/src/main.rs
index b3bd7b8..537691a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
use anyhow::Result;
use botbox::allowlist::Allowlist;
use botbox::config::Config;
+use botbox::https_interception;
use botbox::metrics::{handle_metrics_request, Metrics};
use botbox::proxy::{ProxyBody, ProxyHandler};
use botbox::{logging, secrets, tls};
@@ -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 + HTTPS interception 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,56 @@ async fn main() -> Result<()> {
.await;
});
+ // Shared connection semaphore (HTTP proxy + HTTPS interception share the same pool)
+ let semaphore = Arc::new(Semaphore::new(config.max_connections() as usize));
+
+ // 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(https_interception::HttpsInterceptionCertResolver::new(
+ ca,
+ cfg,
+ allowlist,
+ metrics.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 {
+ https_interception::run_https_interception_listener(
+ listener,
+ tls_config,
+ handler,
+ cfg,
+ metrics,
+ semaphore,
+ shutdown_rx,
+ )
+ .await;
+ }))
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
// Start proxy server
let proxy_addr: SocketAddr = format!("{}:{}", config.listen_addr(), config.listen_port())
.parse()
@@ -158,7 +212,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 +303,16 @@ async fn main() -> Result<()> {
}
}
- // Signal metrics server to shut down
- drop(metrics_shutdown_tx);
+ // 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) = https_interception_handle {
+ info!("waiting for HTTPS interception listener to stop");
+ let _ = handle.await;
+ }
+
info!("proxy server stopped");
Ok(())
}
diff --git a/src/metrics.rs b/src/metrics.rs
index 20da049..fad8bb9 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,12 @@ pub struct Metrics {
pub header_rewrites_total: IntCounterVec,
pub upstream_errors_total: IntCounterVec,
pub request_duration_seconds: HistogramVec,
+ // 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,
}
impl Metrics {
@@ -53,6 +59,48 @@ impl Metrics {
)
.unwrap();
+ let tls_handshakes_total = IntCounterVec::new(
+ Opts::new(
+ "botbox_tls_handshakes_total",
+ "Total HTTPS interception TLS handshakes",
+ ),
+ &["result"],
+ )
+ .unwrap();
+
+ let https_interception_cert_issued_total = IntCounterVec::new(
+ Opts::new(
+ "botbox_https_interception_cert_issued_total",
+ "Total HTTPS interception certificates issued",
+ ),
+ &["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();
+
+ let https_interception_cert_cache_total = IntCounterVec::new(
+ Opts::new(
+ "botbox_https_interception_cert_cache_total",
+ "Total HTTPS interception cert cache operations",
+ ),
+ &["result"],
+ )
+ .unwrap();
+
+ let https_interception_host_mismatch_total = IntCounter::new(
+ "botbox_https_interception_host_mismatch_total",
+ "Total HTTPS interception SNI/Host mismatches",
+ )
+ .unwrap();
+
registry.register(Box::new(requests_total.clone())).unwrap();
registry
.register(Box::new(header_rewrites_total.clone()))
@@ -63,6 +111,21 @@ impl Metrics {
registry
.register(Box::new(request_duration_seconds.clone()))
.unwrap();
+ registry
+ .register(Box::new(tls_handshakes_total.clone()))
+ .unwrap();
+ 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();
+ registry
+ .register(Box::new(https_interception_host_mismatch_total.clone()))
+ .unwrap();
Metrics {
registry: Arc::new(registry),
@@ -70,6 +133,11 @@ impl Metrics {
header_rewrites_total,
upstream_errors_total,
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 2d131e0..c63dff7 100644
--- a/tests/at/README.md
+++ b/tests/at/README.md
@@ -20,6 +20,22 @@ 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)
+
+For an automated acceptance run (image build + kind image load + HTTP-mode E2E + HTTPS interception 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)
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
new file mode 100644
index 0000000..f892884
--- /dev/null
+++ b/tests/e2e/manifests/https-interception-test.yaml
@@ -0,0 +1,214 @@
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: https-interception-test
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: botbox-secrets
+ namespace: https-interception-test
+type: Opaque
+stringData:
+ openai-api-key: "REPLACE-ME-NOT-A-REAL-KEY"
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: botbox-config
+ namespace: https-interception-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
+
+ 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
+ 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: https-interception-test
+ namespace: https-interception-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_ENABLE_HTTPS_INTERCEPTION
+ value: "1"
+ - name: BOTBOX_HTTPS_INTERCEPTION_PORT
+ value: "8443"
+ - name: BOTBOX_ENABLE_IPV6
+ value: "0"
+ 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: https-intercept
+ - 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/https_interception
+ 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 --connect-timeout 10 --max-time 30 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 "--- HTTPS interception positive path (expect upstream reachability, often 401 with dummy key) ---"
+ 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
+ echo "openai request timed out or could not connect"
+ exit 1
+ fi
+
+ echo "--- HTTPS interception negative allowlist path (expect deny) ---"
+ 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"
+ cat /tmp/deny.out || true
+ exit 1
+ fi
+
+ echo "--- Direct bypass checks ---"
+ echo "TCP/443 direct probe"
+ 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"
+ 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 -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}"
+ if [ "${udp_rc}" -eq 0 ]; then
+ echo "expected UDP/443 probe to fail"
+ cat /tmp/udp.out || true
+ exit 1
+ fi
+ else
+ echo "HTTPS_INTERCEPTION_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..de798a5
--- /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: HTTP-mode egress smoke test did not complete successfully"
+ exit 1
+fi
+
+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
new file mode 100755
index 0000000..d88102f
--- /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_HTTPS_INTERCEPTION_TEST="${RUN_HTTPS_INTERCEPTION_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.0-bookworm fallback..."
+ local tmp_dockerfile
+ tmp_dockerfile="$(mktemp)"
+ cat > "${tmp_dockerfile}" <<'EOF'
+FROM rust:1.93.0-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 HTTP-mode E2E test (no interception)..."
+ KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" bash "${ROOT_DIR}/tests/e2e/run-egress-test.sh"
+ fi
+
+ 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
+}
+
+require_cmd docker
+require_cmd kind
+require_cmd kubectl
+
+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
+
+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/https_interception_unit_test.rs b/tests/https_interception_unit_test.rs
new file mode 100644
index 0000000..6079298
--- /dev/null
+++ b/tests/https_interception_unit_test.rs
@@ -0,0 +1,1032 @@
+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 HttpsInterceptionSpec {
+ 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 HttpsInterceptionSpec {
+ 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,
+ https_interception_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: 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");
+ 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 https_interception_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}
+
+https_interception:
+ enabled: true
+ listen_addr: "127.0.0.1"
+ listen_port: {https_interception_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,
+ 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,
+ 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)),
+ https_interception_addr: SocketAddr::from(([127, 0, 0, 1], https_interception_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(
+ https_interception_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(&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")?;
+ 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(
+ https_interception_addr: SocketAddr,
+ sni_dns_name: &str,
+ ca_cert_pem: &[u8],
+) -> Result> {
+ let tls = connect_tls_stream(https_interception_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(
+ https_interception_addr: SocketAddr,
+ sni_dns_name: &str,
+ host_header: &str,
+ path: &str,
+ ca_cert_pem: &[u8],
+) -> Result {
+ 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
+ );
+ 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 https_interception_config_fails_closed_when_ca_files_are_missing() {
+ let spec = HttpsInterceptionSpec {
+ write_ca_files: false,
+ ..HttpsInterceptionSpec::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(),
+ "HTTPS interception enabled must fail startup when CA files are missing"
+ );
+}
+
+#[test]
+fn https_interception_readiness_requires_required_secrets_and_ca_loaded() {
+ // required secret missing -> /healthz should stay 503
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_host_with_secret_rewrite("localhost", "openai-api-key"),
+ initial_secrets: Vec::new(),
+ ..HttpsInterceptionSpec::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 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"]),
+ ..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.https_interception_addr, host, &proc.ca_cert_pem)
+ .unwrap_or_else(|e| {
+ panic!("valid SNI host '{}' should complete handshake: {}", host, e)
+ });
+ }
+}
+
+#[test]
+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.https_interception_addr, host, &proc.ca_cert_pem);
+ assert!(
+ result.is_err(),
+ "invalid SNI host '{}' must be rejected",
+ host
+ );
+ }
+}
+
+#[test]
+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,
+ ..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.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"
+ );
+
+ 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 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,
+ ..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.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.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.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.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"
+ );
+}
+
+#[test]
+fn https_interception_integration_trusted_tls_client_can_send_http1_request() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost"]),
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ let resp = tls_http_request(
+ proc.https_interception_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 HTTPS interception TLS listener, got {}",
+ status
+ );
+}
+
+#[test]
+fn https_interception_integration_rejects_sni_host_mismatch_with_400() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost", "example.com"]),
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ let resp = tls_http_request(
+ proc.https_interception_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 https_interception_integration_rejects_non_443_host_port() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost"]),
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ let resp = tls_http_request(
+ proc.https_interception_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 https_interception_integration_rejects_absolute_form_request() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost"]),
+ ..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.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";
+ 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 HTTPS interception listener"
+ );
+}
+
+#[test]
+fn https_interception_integration_rejects_absolute_form_even_with_matching_sni_host_and_port() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost"]),
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ 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())
+ .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 https_interception_config_rejects_cert_cache_ttl_larger_than_cert_ttl() {
+ let spec = HttpsInterceptionSpec {
+ cert_ttl_seconds: 60,
+ cert_cache_ttl_seconds: 61,
+ ..HttpsInterceptionSpec::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(),
+ "HTTPS interception config must fail startup when cert_cache_ttl_seconds > cert_ttl_seconds"
+ );
+}
+
+#[test]
+fn https_interception_integration_enforces_connection_limit_on_https_interception_listener() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost"]),
+ max_connections: 1,
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ 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.https_interception_addr, "localhost", &proc.ca_cert_pem);
+ drop(first_conn);
+ assert!(
+ second.is_err(),
+ "second HTTPS interception TLS connection should be rejected when max_connections=1 and one connection is held open"
+ );
+}
+
+#[test]
+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,
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ 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.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"
+ );
+
+ 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 https_interception_integration_disallowed_sni_handshake_can_be_denied() {
+ let spec = HttpsInterceptionSpec {
+ rules_yaml: rules_allow_hosts(&["localhost"]),
+ deny_handshake_on_disallowed_sni: true,
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ 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"
+ );
+}
+
+#[test]
+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,
+ ..HttpsInterceptionSpec::default()
+ };
+ let mut proc = BotboxProcess::start(spec);
+ proc.wait_for_healthz_endpoint(Duration::from_secs(5));
+
+ let resp = tls_http_request(
+ proc.https_interception_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)"
+ );
+}
diff --git a/tests/integration_test.rs b/tests/integration_test.rs
index 61313a5..61c654f 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_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"))
+ .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(