diff --git a/Cargo.lock b/Cargo.lock index 8c0ed6e..93e2662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3789,7 +3789,9 @@ dependencies = [ "anyhow", "async-stream", "axum", + "base64 0.22.1", "blake3", + "bytes", "candle-core", "clap", "dialoguer", @@ -3797,22 +3799,31 @@ dependencies = [ "futures-util", "heck", "hf-hub", + "http-body-util", + "hyper", + "hyper-util", "ignore", "indexmap 2.14.0", "jiff", "mistralrs-core", "nix 0.29.0", "rand 0.8.6", + "rcgen", "regex", "reqwest 0.12.28", "rig-core", "rmcp", + "rustls", + "rustls-native-certs", + "rustls-pemfile", "schemars 0.8.22", "serde", "serde_json", "tempfile", "thiserror 1.0.69", + "time", "tokio", + "tokio-rustls", "tokio-util", "toml 0.8.23", "toml_edit", @@ -3871,6 +3882,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4427,6 +4448,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "realfft" version = "3.5.0" @@ -4825,6 +4859,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -7508,6 +7551,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[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.7.5" diff --git a/Cargo.toml b/Cargo.toml index 613b654..bdd6c5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,9 @@ required-features = ["e2e"] anyhow = "1.0" async-stream = "0.3" axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +base64 = "0.22" blake3 = "1.5" +bytes = "1.5" candle-core = { version = "=0.10.2", optional = true, default-features = false } clap = { version = "4.5", features = ["derive"] } dialoguer = { version = "0.12", features = ["fuzzy-select"] } @@ -36,20 +38,29 @@ directories = "5.0" futures-util = "0.3" heck = "0.5" hf-hub = { version = "=0.4.3", optional = true, default-features = false } +http-body-util = "0.1" +hyper = { version = "1.5", features = ["http1", "http2", "server", "client"] } +hyper-util = { version = "0.1", features = ["tokio", "server", "client", "client-legacy", "http1", "http2"] } ignore = "0.4" indexmap = { version = "2.14", optional = true } jiff = "0.2" mistralrs-core = { version = "=0.8.1", optional = true, default-features = false } nix = { version = "0.29", features = ["user"] } rand = "0.8" +rcgen = { version = "0.13", default-features = false, features = ["pem", "ring"] } regex = "1.10" reqwest = { version = "0.12", optional = true, default-features = false, features = ["json", "rustls-tls"] } rig-core = { version = "0.36", default-features = false, features = ["reqwest", "rustls"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-native-certs = "0.8" +rustls-pemfile = "2.2" schemars = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3.10" thiserror = "1.0" +time = "0.3" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } toml = "0.8" toml_edit = "0.22" tracing = "0.1" diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index 61704f0..68d13ec 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -10,6 +10,7 @@ - [MCP Servers](concepts/mcp-servers.md) - [MCP Trust Model](concepts/mcp-trust-model.md) - [Workspace](concepts/workspace.md) + - [Network MITM](concepts/network-mitm.md) - [Providers, Models, and Agents](concepts/llm-providers.md) - [In-process LLMs](concepts/in-process-llm.md) diff --git a/doc/concepts/network-mitm.md b/doc/concepts/network-mitm.md new file mode 100644 index 0000000..4de9a1a --- /dev/null +++ b/doc/concepts/network-mitm.md @@ -0,0 +1,127 @@ +# Network MITM + +> Opt-in HTTPS interception. Off unless you explicitly enable it. The session CA is generated +> fresh per session, lives only under the session directory, and is never installed as a host +> trust anchor. + +## What MITM does + +The 0059/0060 network interceptor sees HTTPS as opaque bytes. It can record the destination IP +and the TLS SNI, but URL, method, status, and body all stay inside the encrypted tunnel. +Policy stops at the connection layer: a single allow for `github.com:443` lets the container +hit every URL on the host. + +MITM mode flips that. With MITM on, the interceptor: + +1. Generates a per-session ECDSA P-256 CA at startup, writes the public cert to + `/tls/ca.crt` and the private key to `/tls/ca.key` (mode `0600`). +2. Installs the CA into the session container's trust stores (system bundle + standalone PEM + + language env vars). The host trust anchors are untouched. +3. Terminates incoming TLS on each HTTPS-bearing port, minting a leaf certificate keyed on the + client's SNI and signed by the session CA. +4. Opens its own TLS connection upstream, validated against the host's native trust anchors. +5. Inspects each HTTP request, applies URL-aware policy, optionally captures bodies, then + forwards or rejects. +6. Removes the private key during teardown. The public cert stays so audit-log readers can + verify recorded handshakes after the session ends. + +ALPN advertises both `h2` and `http/1.1` in each direction, so HTTP/1.1 and HTTP/2 clients +both work transparently. + +## Enabling MITM + +MITM composes with `mode = "audit"` or `mode = "filter"`. It is a separate toggle, not a +fourth value for `mode`. Enable it under `[network.mitm]`: + +```toml +[network] +mode = "filter" +default = "deny" +allow = ["github.com:443"] + +[network.mitm] +enable = true +``` + +See [Reference -> Config](../reference/config.md) for the full schema, including +`capture-bodies`, `max-body-bytes`, and `ports`. + +## URL-aware policy + +Entries in `[network].allow` and `[network].deny` may carry an optional `path` glob using the +inline-table form. Entries without `path` keep their 0060 host/port semantics. Entries with +`path` are skipped at TCP-open time and only fire after MITM has parsed the HTTP request +line: + +```toml +[network] +mode = "filter" +default = "deny" +allow = [ + "api.github.com:443", + { host = "api.github.com", port = 443, path = "/repos/*" }, +] +deny = [ + { host = "api.github.com", port = 443, path = "/admin/*" }, +] +``` + +`deny` wins over `allow`, and unmatched requests fall through to `default`. Path-bearing +entries require `enable = true`; the validator rejects them otherwise. + +## Audit log shape + +Every HTTPS request handled by MITM produces one record in `/logs/network.jsonl` +(non-MITM connections produce the 0060 shape unchanged): + +```json +{"ts": 1700000000.0, "uid": "C...", "id.orig_h": "10.0.2.100", "id.orig_p": 50123, + "id.resp_h": "140.82.121.5", "id.resp_p": 443, "proto": "tcp", "service": "ssl-mitm", + "duration": 0.082, "orig_bytes": 0, "resp_bytes": 0, "conn_state": "SF", + "local_orig": true, "local_resp": false, "missed_bytes": 0, + "server_name": "api.github.com", + "method": "GET", "url": "https://api.github.com/repos/foo/bar", "status": 200, + "outrig.session_id": "...", "outrig.container": "...", + "outrig.host": "api.github.com", "outrig.action": "allow", "outrig.rule": "allow[1]"} +``` + +The Zeek `uid` matches the TCP-level audit record's `uid` for the same flow, so consumers can +group request records with their underlying TCP connection. `service` is `ssl-mitm` (vs. +`ssl` for opaque-pass-through TLS) to make the two record types easy to filter. + +When `capture-bodies = true`, the record also carries `outrig.request_body_b64` and/or +`outrig.response_body_b64` (base64-encoded, capped at `max-body-bytes` each) plus an +`outrig.body_truncated` flag (`"request"`, `"response"`, or `"both"`) when the cap fired. +Bodies past the cap still pass on the wire; only the captured copy is truncated. + +## Limitations + +- **HTTPS to IP literals (no SNI)** passes through opaquely, exactly as it would with MITM + off. The interceptor cannot honestly mint a name-bearing leaf for an IP destination, so it + declines to terminate TLS in that case. URL/method/status are not captured for those + connections. +- **HTTP/2 streams are proxied, not tunneled.** Hyper handles header decoding, flow control, + and stream multiplexing under the hood, so end-to-end HTTP/2 features (server push, custom + trailers beyond what hyper exposes) may not round-trip. +- **Client certificate authentication is not supported.** The interceptor accepts client TLS + with `with_no_client_auth`, so any upstream that requires a client cert from the agent + side will fail with MITM enabled. +- **CA validity is 30 days from session start.** Sessions running past that window will see + TLS handshake failures inside the container. + +## Cleanup + +When the session ends, `NetworkInterceptor::shutdown` removes +`/tls/ca.key`. The public certificate stays at +`/tls/ca.crt` for post-mortem analysis. The container is destroyed at the same +time, so the trust-store installation goes with it. + +If you need to inspect the CA after the session, `openssl x509 -in /tls/ca.crt +-text -noout` shows the subject, validity, and basic constraints. + +## See also + +- [Concepts -> Workspace](workspace.md) -- audit/filter modes and the broader network model. +- [Reference -> Config](../reference/config.md) -- the `[network]` and `[network.mitm]` schema. +- [Usage -> Sessions](../usage/sessions.md) -- session directory layout and `network.jsonl` + fields. diff --git a/doc/concepts/workspace.md b/doc/concepts/workspace.md index 356ff97..8dbb408 100644 --- a/doc/concepts/workspace.md +++ b/doc/concepts/workspace.md @@ -147,8 +147,9 @@ mode = "audit" Audit mode writes one Zeek `conn.log`-style JSON object per connection to `/logs/network.jsonl`. Audit mode is allow-and-log only: every connection is still allowed, but records include the best known host, destination IP and port, transport, service, -byte counts, and duration. HTTPS remains opaque except for TLS SNI. URL, method, status, and -body inspection are deferred. +byte counts, and duration. By default HTTPS remains opaque except for TLS SNI; enable the +[MITM mode](network-mitm.md) to terminate TLS, record method/URL/status, and apply URL-aware +policy. Filter mode uses the same interceptor and audit log, then applies global host/port policy before opening upstream TCP connections: diff --git a/doc/reference/config.md b/doc/reference/config.md index 0ccbafb..287e484 100644 --- a/doc/reference/config.md +++ b/doc/reference/config.md @@ -132,6 +132,58 @@ override this setting for one fresh session. `--network audit` and `--network fi rejected with `outrig mcp --attach` because borrowed containers are not retrofitted with a new interceptor. +### `[network.mitm]` + +HTTPS MITM is an opt-in extension that composes with `mode = "audit"` or `mode = "filter"`. +When enabled, the interceptor generates a per-session CA, installs its public certificate into +the session container's trust store, terminates incoming TLS on the configured ports, and +re-encrypts upstream. Audit records gain `method`, `url`, and `status`. See +[Concepts -> Network MITM](../concepts/network-mitm.md) for the full security model. + +```toml +[network.mitm] +enable = true # default false; master switch for HTTPS MITM +ports = [443] # default [443]; ports treated as HTTPS-bearing +capture-bodies = false # default false; opt-in body capture +max-body-bytes = 65536 # default 65536; per-body cap when capture is on +``` + +| Key | Type | Required | Default | Description | +|------------------|----------|----------|---------|------------------------------------------| +| `enable` | bool | no | `false` | Toggle MITM on/off. | +| `ports` | int list | no | `[443]` | Ports treated as HTTPS-bearing. | +| `capture-bodies` | bool | no | `false` | Include base64 bodies in audit records. | +| `max-body-bytes` | integer | no | `65536` | Per-body cap (bytes) when capture is on. | + +Repo configs may set `[network.mitm].enable` (so a repo can opt out of MITM even when the +global config opts in), but cannot set `ports`, `capture-bodies`, or `max-body-bytes`. Those +are global-only, like the other policy keys. + +When MITM is enabled, `allow` and `deny` entries may carry an optional `path` glob (inline-table +form only). URL rules ride the same lists as host/port rules; entries without `path` keep the +host-only behavior from `mode = "filter"`. Entries with `path` are skipped at TCP-open time and +only fire after MITM has parsed the request line: + +```toml +[network] +mode = "filter" +default = "deny" +allow = [ + "github.com:443", + { host = "api.github.com", port = 443, path = "/repos/*" }, +] +deny = [ + "*:22", + { host = "api.github.com", port = 443, path = "/admin/*" }, +] + +[network.mitm] +enable = true +``` + +The path glob uses the same `*` semantics as host globs and is case-sensitive. A `path` entry +without `[network.mitm].enable = true` is rejected at config validation. + ## `[providers.]` A provider tells outrig how to reach a model -- either a remote HTTPS endpoint that speaks diff --git a/doc/usage/sessions.md b/doc/usage/sessions.md index cb6ecc1..1199396 100644 --- a/doc/usage/sessions.md +++ b/doc/usage/sessions.md @@ -146,6 +146,13 @@ the sniffed application (`http`, `ssl`, `ssh`, or `-`), `duration` is seconds, a metadata is namespaced under `outrig.*`, including `outrig.action`, `outrig.rule`, and `outrig.host`. +When MITM is enabled, each HTTPS request produces an additional record with +`service = "ssl-mitm"` carrying `method`, `url`, and `status` (and, with `capture-bodies` on, +`outrig.request_body_b64`, `outrig.response_body_b64`, and `outrig.body_truncated`). The `uid` +on the MITM record matches the TCP-level record for the same connection, so grouping with +`jq 'group_by(.uid)'` recovers the request flow. See +[Concepts -> Network MITM](../concepts/network-mitm.md). + ## `outrig discard` Delete a session's entire on-disk record (including logs): diff --git a/plan/done/0064-network-interceptor-mitm.md b/plan/done/0064-network-interceptor-mitm.md new file mode 100644 index 0000000..65b2f2c --- /dev/null +++ b/plan/done/0064-network-interceptor-mitm.md @@ -0,0 +1,121 @@ +# 0064 -- Network interceptor MITM + +## Context + +Tasks 0059 and 0060 give outrig audited and enforceable host:port egress policy. +They cannot see HTTPS URLs, methods, statuses, or bodies because TLS remains +opaque. This final phase adds opt-in MITM TLS so policy and audit can apply at +the URL/body level. + +This is deliberately queued after the other post-v0 work because it changes TLS +trust inside the session container and has a larger failure surface than +host:port interception. + +## Goal + +Add opt-in HTTPS MITM mode for session containers, with per-session trust roots, +URL-aware policy, and expanded audit records. + +## Deliverables + +- Per-session CA generation with private material stored only under the session + directory. +- Container trust-store installation during startup, including common language + stores where practical. +- TLS termination in the interceptor plus per-host leaf certificate minting. +- Upstream TLS connection from the interceptor to the real destination. +- URL-aware rule extensions for policy matching. +- Optional request/response body capture with an explicit opt-in flag and size + cap. +- Documentation for the security model, limitations, cleanup behavior, and how + to inspect the expanded audit records. + +## Runtime Behavior + +MITM mode is off by default. When enabled, outrig generates a CA for the session, +writes the public cert into the container trust store, and uses the private key +only in the session's interceptor process. The CA is not trusted by the host and +is removed with the session. + +The interceptor terminates incoming TLS using per-host leaf certificates signed +by the session CA, inspects the request, applies URL-aware policy, and opens its +own TLS connection upstream. + +Audit records gain `url`, `method`, and `status`. If body inspection is enabled, +records may also include capped `request_body` and `response_body` fields. + +## Acceptance + +- MITM mode is opt-in and leaves 0060 behavior unchanged when disabled. +- A session with MITM enabled can make HTTPS requests from inside the container + without certificate validation failures for common system tools. +- URL-aware allow/deny rules apply to HTTPS requests after TLS termination. +- Audit records include URL, method, and status for inspected HTTPS requests. +- Body capture is disabled by default and capped when enabled. +- The generated CA private key is scoped to the session directory and is not + installed as a host trust anchor. + +## Dependencies + +- **Hard: 0060**. MITM builds on the host:port enforcement path and extends its + rule matcher and audit record shape. + +## See also + +- `0059-network-interceptor-plumbing.md` -- traffic capture and audit logging. +- `0060-network-interceptor-enforcement.md` -- host:port policy enforcement. +- `doc/concepts/workspace.md` -- existing workspace/network concept page. + +## Decisions + +- **MITM is an orthogonal toggle, not a fourth `mode` value.** Filter and MITM compose -- filter + handles host-level denies pre-TLS, MITM handles URL-level denies post-TLS. Adding a fourth + mode would have forced an unhelpful choice between observability and enforcement. + +- **HTTP/1.1 and HTTP/2 both supported.** Hyper 1.x serves both protocols cleanly; ALPN + advertises `[h2, http/1.1]` in each direction. The audit record shape is identical between + the two. + +- **URL rules share the existing `allow`/`deny` lists via an optional `path` glob.** Inline- + table form only; the string sugar (`"host:port"`) is *not* extended. Entries with `path` are + skipped by `decide_pre_mitm` so they only fire post-TLS termination. A `path` entry without + `[network.mitm].enable = true` is a schema error so the config stays coherent. + +- **Bodies stored inline as base64 in `network.jsonl`.** Self-contained log, easy to grep/jq, + capped at 64 KiB default. Side files would scale further but complicate cleanup. The + `_b64` suffix on field names tells consumers what to do. + +- **CA validity is 30 days.** Long enough for any plausible session length, short enough to + limit blast radius if `ca.key` leaks. The key is 0600 inside the session directory and + removed by `NetworkInterceptor::shutdown`; `ca.crt` stays so audit-log readers can verify + recorded handshakes after the session ends. + +- **IP-only HTTPS (no SNI) passes through opaquely.** The interceptor cannot honestly mint a + name-bearing leaf for an IP destination, so it declines to terminate TLS in that case. + URL/method/status are not captured for those connections; service stays `ssl` (not + `ssl-mitm`) so audit consumers can filter cleanly. + +- **`reject_repo_network_policy` extended to allow `[network.mitm].enable` but reject `ports`, + `capture-bodies`, `max-body-bytes`.** A repo can opt *out* of MITM (or opt in if the global + hasn't), but cannot widen the capture footprint. Mirrors the 0060 decision that the + expensive `allow`/`deny` lists are global-only. + +- **Trust-store install writes three PEMs and sets four env vars** via a single + `podman exec --user=0:0` heredoc script. Both `update-ca-certificates` and + `update-ca-trust extract` run with `|| true` so the wrong-distro updater fails silently. + `NODE_EXTRA_CA_CERTS` points at the standalone PEM (Node ignores the system bundle); the + other bundle vars point at the system bundle path that `update-ca-certificates` / + `update-ca-trust` rebuild after our anchor is added. + +- **MCP servers spawned via `podman exec` get the trust-store env explicitly,** not via + `/etc/profile.d` (which `podman exec` doesn't source). `session_setup::mitm_session_env` + produces the map; `connect_mcp_clients` merges it under per-server CLI overlay (so explicit + user intent wins). + +- **One audit record per HTTP request, sharing the Zeek `uid` of the TCP flow.** Keep-alive + and HTTP/2 multiplexing both carry many requests over one connection; grouping back to the + TCP flow via `jq 'group_by(.uid)'` is the documented consumer pattern. Service is + `ssl-mitm` (vs. `ssl`) on MITM records to distinguish them. + +- **CA generation is lazy, at interceptor start time -- not eager at session create.** Sessions + with MITM off pay no rcgen / rustls overhead and never touch `/tls/`. diff --git a/plan/todo/0064-network-interceptor-mitm.md b/plan/todo/0064-network-interceptor-mitm.md deleted file mode 100644 index 59034d9..0000000 --- a/plan/todo/0064-network-interceptor-mitm.md +++ /dev/null @@ -1,67 +0,0 @@ -# 0064 -- Network interceptor MITM - -## Context - -Tasks 0059 and 0060 give outrig audited and enforceable host:port egress policy. -They cannot see HTTPS URLs, methods, statuses, or bodies because TLS remains -opaque. This final phase adds opt-in MITM TLS so policy and audit can apply at -the URL/body level. - -This is deliberately queued after the other post-v0 work because it changes TLS -trust inside the session container and has a larger failure surface than -host:port interception. - -## Goal - -Add opt-in HTTPS MITM mode for session containers, with per-session trust roots, -URL-aware policy, and expanded audit records. - -## Deliverables - -- Per-session CA generation with private material stored only under the session - directory. -- Container trust-store installation during startup, including common language - stores where practical. -- TLS termination in the interceptor plus per-host leaf certificate minting. -- Upstream TLS connection from the interceptor to the real destination. -- URL-aware rule extensions for policy matching. -- Optional request/response body capture with an explicit opt-in flag and size - cap. -- Documentation for the security model, limitations, cleanup behavior, and how - to inspect the expanded audit records. - -## Runtime Behavior - -MITM mode is off by default. When enabled, outrig generates a CA for the session, -writes the public cert into the container trust store, and uses the private key -only in the session's interceptor process. The CA is not trusted by the host and -is removed with the session. - -The interceptor terminates incoming TLS using per-host leaf certificates signed -by the session CA, inspects the request, applies URL-aware policy, and opens its -own TLS connection upstream. - -Audit records gain `url`, `method`, and `status`. If body inspection is enabled, -records may also include capped `request_body` and `response_body` fields. - -## Acceptance - -- MITM mode is opt-in and leaves 0060 behavior unchanged when disabled. -- A session with MITM enabled can make HTTPS requests from inside the container - without certificate validation failures for common system tools. -- URL-aware allow/deny rules apply to HTTPS requests after TLS termination. -- Audit records include URL, method, and status for inspected HTTPS requests. -- Body capture is disabled by default and capped when enabled. -- The generated CA private key is scoped to the session directory and is not - installed as a host trust anchor. - -## Dependencies - -- **Hard: 0060**. MITM builds on the host:port enforcement path and extends its - rule matcher and audit record shape. - -## See also - -- `0059-network-interceptor-plumbing.md` -- traffic capture and audit logging. -- `0060-network-interceptor-enforcement.md` -- host:port policy enforcement. -- `doc/concepts/workspace.md` -- existing workspace/network concept page. diff --git a/plan/todo/README.md b/plan/todo/README.md index fdfde4d..841bc76 100644 --- a/plan/todo/README.md +++ b/plan/todo/README.md @@ -6,6 +6,3 @@ branch; run `/groom-plan` to maintain ordering after edits or `plan/next/` pulls See [`.claude/CLAUDE.md`](../../.claude/CLAUDE.md) for the workflow conventions. -## Phase J -- post-v0 features and fixes - -- **[0064](0064-network-interceptor-mitm.md)** Network interceptor MITM. diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index 6b8fa38..4e96353 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -157,7 +157,7 @@ async fn serve( store, attached, network, - cfg: _, + cfg, session: _, session_dir: _, } = setup; @@ -185,6 +185,7 @@ async fn serve( &cli_env, attached, listen, + &cfg, ) .await; @@ -260,8 +261,11 @@ async fn serve_inner( cli_env: &CliEnvEntries, attached: bool, listen: Option<&ListenAddr>, + cfg: &crate::config::Config, ) -> Result { - let connected = session_setup::connect_mcp_clients(container, mcp, log_dir, cli_env).await?; + let session_env = session_setup::mitm_session_env(cfg); + let connected = + session_setup::connect_mcp_clients(container, mcp, log_dir, cli_env, &session_env).await?; if connected.is_empty() { return Err(OutrigError::Configuration( "outrig mcp with no merged MCP entries has nothing to proxy".to_string(), diff --git a/src/cli/run.rs b/src/cli/run.rs index 65e7241..3020c64 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -186,7 +186,9 @@ async fn run_inner( apply_tool_call_cap_override(&mut resolved, max_tool_calls); apply_tool_result_cap_override(&mut resolved, max_tool_result_bytes); - let connected = session_setup::connect_mcp_clients(container, mcp, log_dir, cli_env).await?; + let session_env = session_setup::mitm_session_env(cfg); + let connected = + session_setup::connect_mcp_clients(container, mcp, log_dir, cli_env, &session_env).await?; mcp_arcs.extend(connected); let mut all_tools: Vec = Vec::new(); diff --git a/src/cli/session_setup.rs b/src/cli/session_setup.rs index 95fbc95..0f3475d 100644 --- a/src/cli/session_setup.rs +++ b/src/cli/session_setup.rs @@ -31,7 +31,9 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use crate::cli::env_arg::CliEnvEntries; -use crate::config::{Config, ContainerConfig, McpServerSpec, MistralrsDeviceSpec, NetworkMode}; +use crate::config::{ + Config, ContainerConfig, McpServerSpec, MistralrsDeviceSpec, NetworkMode, NetworkPolicy, +}; use crate::container::{ Container, ContainerCapabilities, ContainerLaunchSpec, ContainerMount, ContainerWorkspace, embedded, @@ -412,32 +414,24 @@ pub async fn setup(args: SessionSetupArgs<'_>) -> Result { let network = match network_mode { NetworkMode::Default => None, - NetworkMode::Audit => { - let span = ProgressSpan::start("starting network audit interceptor"); - match NetworkInterceptor::start(&container, &log_dir, sid.as_str()).await { - Ok(interceptor) => { - span.done("network audit interceptor ready"); - Some(interceptor) - } - Err(e) => { - let _ = container.stop(STOP_GRACE).await; - let _ = store.finalize(&sid, SystemTime::now(), 1); - return Err(e); - } - } - } - NetworkMode::Filter => { - let span = ProgressSpan::start("starting network filter interceptor"); - match NetworkInterceptor::start_with_policy( + mode => { + let (label, policy) = match mode { + NetworkMode::Audit => ("network audit", NetworkPolicy::allow_all()), + NetworkMode::Filter => ("network filter", cfg.network.policy()), + NetworkMode::Default => unreachable!(), + }; + let span = ProgressSpan::start(format!("starting {label} interceptor")); + match NetworkInterceptor::start_with_options( &container, &log_dir, sid.as_str(), - cfg.network.policy(), + policy, + &cfg.network.mitm, ) .await { Ok(interceptor) => { - span.done("network filter interceptor ready"); + span.done(format!("{label} interceptor ready")); Some(interceptor) } Err(e) => { @@ -515,18 +509,28 @@ pub async fn merged_mcp( /// Spawn one [`McpClient`] per backing MCP declared in `mcp`, in key-sorted /// (`BTreeMap`) iteration order. `cli_env` provides any `--env` overlay -/// entries to merge per server. Adapter construction is the caller's job -/// because only the REPL path consumes adapters. +/// entries to merge per server; `session_env` adds session-wide entries +/// (currently the MITM trust-store env vars) shared by every server. +/// Adapter construction is the caller's job because only the REPL path +/// consumes adapters. pub async fn connect_mcp_clients( container: &Container, mcp: &BTreeMap, log_dir: &Path, cli_env: &CliEnvEntries, + session_env: &BTreeMap, ) -> Result>> { let mut arcs = Vec::with_capacity(mcp.len()); for (mcp_name, spec) in mcp { let span = ProgressSpan::start(format!("MCP {mcp_name}: initializing")); - let extra_env = cli_env.for_server(mcp_name); + let mut extra_env = cli_env.for_server(mcp_name); + // Per-server CLI overlay wins over the session-wide MITM env -- + // explicit user intent shouldn't be overridden by infrastructure. + for (key, value) in session_env { + extra_env + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } let client = McpClient::connect_via_podman_exec(container, spec, mcp_name, log_dir, &extra_env) .await?; @@ -536,6 +540,17 @@ pub async fn connect_mcp_clients( Ok(arcs) } +/// Env vars MCP servers need when MITM is enabled. Empty when off. +pub fn mitm_session_env(cfg: &Config) -> BTreeMap { + if !cfg.network.mitm.enable { + return BTreeMap::new(); + } + crate::network::trust::exec_env_overrides() + .into_iter() + .map(|(k, v)| (k, crate::config::EnvValue::Literal(v))) + .collect() +} + /// Cleanup tail. Order: MCP shutdowns (so their `podman exec` pipes drain /// before the container goes away) -> container stop -> session finalize. /// Each step's failure is logged but never propagated; the caller's outcome diff --git a/src/config/merge.rs b/src/config/merge.rs index 4519844..843ace6 100644 --- a/src/config/merge.rs +++ b/src/config/merge.rs @@ -16,6 +16,10 @@ use super::Config; /// - `[network].mode` follows repo precedence when the repo file declares the /// table. Policy keys (`default`, `allow`, `deny`) are global-only and are /// always taken from the global config. +/// - `[network.mitm].enable` follows repo precedence when the repo file +/// declares it, so a repo can opt out of MITM (or in, if the global hasn't). +/// Other `[network.mitm]` keys (`ports`, `capture-bodies`, `max-body-bytes`) +/// are global-only and always taken from the global config. /// - `[workspace]` primary fields are repo-owned. Since `Workspace` has serde /// defaults, an absent block in the repo file deserializes to those defaults /// -- so taking repo `host-path`/`container-path` unconditionally matches @@ -47,6 +51,12 @@ pub fn merge(global: Config, repo: Config) -> Config { } else { network.set_declared(false); } + if repo.network.is_mitm_declared() { + network.mitm.enable = repo.network.mitm.enable; + network.set_mitm_declared(true); + } else { + network.set_mitm_declared(false); + } Config { default_container: repo.default_container.or(global.default_container), diff --git a/src/config/mod.rs b/src/config/mod.rs index 8d8406d..f1865c1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -107,7 +107,9 @@ impl Config { } Err(e) => return Err(e.into()), }; - cfg.network.declared = declares_top_level_network(s)?; + let (declared, mitm_declared) = declares_network_tables(s)?; + cfg.network.declared = declared; + cfg.network.mitm_declared = mitm_declared; Ok(cfg) } @@ -143,11 +145,19 @@ impl Config { } } -fn declares_top_level_network(text: &str) -> Result { +fn declares_network_tables(text: &str) -> Result<(bool, bool)> { let value = text.parse::().map_err(|source| { crate::error::OutrigError::Configuration(format!("parsing config for [network]: {source}")) })?; - Ok(value.as_table().contains_key("network")) + let top = value.as_table(); + let declared = top.contains_key("network"); + let mitm_declared = top + .get("network") + .and_then(toml_edit::Item::as_table) + .and_then(|t| t.get("mitm")) + .and_then(toml_edit::Item::as_table) + .is_some_and(|t| t.contains_key("enable")); + Ok((declared, mitm_declared)) } fn reject_repo_network_policy(text: &str) -> Result<()> { @@ -164,6 +174,16 @@ fn reject_repo_network_policy(text: &str) -> Result<()> { ))); } } + if let Some(mitm) = network.get("mitm").and_then(toml_edit::Item::as_table) { + for key in ["ports", "capture-bodies", "max-body-bytes"] { + if mitm.contains_key(key) { + return Err(OutrigError::Configuration(format!( + "repo config may set [network.mitm].enable only; \ + [network.mitm].{key} belongs in global config" + ))); + } + } + } Ok(()) } @@ -389,6 +409,11 @@ pub struct NetworkEntry { pub host: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub port: Option, + /// URL-path glob for MITM rules. Only consulted after TLS termination + /// when MITM is enabled. Inline-table form only; the string sugar + /// (`"host:port"`) does not embed a path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, } impl NetworkEntry { @@ -396,6 +421,7 @@ impl NetworkEntry { Self { host: host.into(), port: None, + path: None, } } @@ -403,6 +429,15 @@ impl NetworkEntry { Self { host: host.into(), port: Some(port), + path: None, + } + } + + pub fn with_url(host: impl Into, port: u16, path: impl Into) -> Self { + Self { + host: host.into(), + port: Some(port), + path: Some(path.into()), } } @@ -412,10 +447,27 @@ impl NetworkEntry { } parse_network_host_pattern(&self.host) .map(|_| ()) - .map_err(|e| format!("{path}.host {e}")) + .map_err(|e| format!("{path}.host {e}"))?; + if let Some(url_path) = &self.path { + validate_network_path(url_path).map_err(|e| format!("{path}.path {e}"))?; + } + Ok(()) } } +pub(crate) fn validate_network_path(value: &str) -> std::result::Result<(), String> { + if value.is_empty() { + return Err("must not be empty".to_string()); + } + if !value.starts_with('/') { + return Err("must start with `/`".to_string()); + } + if value.chars().any(char::is_whitespace) { + return Err("must not contain whitespace".to_string()); + } + Ok(()) +} + impl<'de> Deserialize<'de> for NetworkEntry { fn deserialize(deserializer: D) -> std::result::Result where @@ -427,6 +479,8 @@ impl<'de> Deserialize<'de> for NetworkEntry { host: String, #[serde(default)] port: Option, + #[serde(default)] + path: Option, } #[derive(Deserialize)] @@ -441,6 +495,7 @@ impl<'de> Deserialize<'de> for NetworkEntry { Repr::Table(t) => Ok(Self { host: t.host, port: t.port, + path: t.path, }), } } @@ -467,6 +522,7 @@ fn parse_network_entry_string(s: &str) -> std::result::Result std::result::Result std::result::Result, + port: u16, + path: impl Into, + ) -> Self { + self.policy + .allow + .push(NetworkEntry::with_url(host, port, path)); + self + } + pub fn deny_host(mut self, host: impl Into) -> Self { self.policy.deny.push(NetworkEntry::new(host)); self @@ -590,6 +662,15 @@ impl NetworkPolicyBuilder { self } + /// Add a URL-aware deny rule. Only matches once MITM has terminated TLS + /// and parsed the request line. + pub fn deny_url(mut self, host: impl Into, port: u16, path: impl Into) -> Self { + self.policy + .deny + .push(NetworkEntry::with_url(host, port, path)); + self + } + pub fn build(self) -> Result { self.policy .validate(true) @@ -608,9 +689,14 @@ pub struct NetworkConfig { pub allow: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deny: Vec, + #[serde(default, skip_serializing_if = "MitmConfig::is_default")] + pub mitm: MitmConfig, #[serde(skip)] #[schemars(skip)] declared: bool, + #[serde(skip)] + #[schemars(skip)] + mitm_declared: bool, } impl Default for NetworkConfig { @@ -620,7 +706,9 @@ impl Default for NetworkConfig { default: NetworkAction::Deny, allow: Vec::new(), deny: Vec::new(), + mitm: MitmConfig::default(), declared: false, + mitm_declared: false, } } } @@ -631,6 +719,7 @@ impl PartialEq for NetworkConfig { && self.default == other.default && self.allow == other.allow && self.deny == other.deny + && self.mitm == other.mitm } } @@ -645,6 +734,14 @@ impl NetworkConfig { self.declared = declared; } + pub(crate) fn is_mitm_declared(&self) -> bool { + self.mitm_declared + } + + pub(crate) fn set_mitm_declared(&mut self, declared: bool) { + self.mitm_declared = declared; + } + pub fn policy(&self) -> NetworkPolicy { NetworkPolicy { default: self.default, @@ -658,6 +755,88 @@ impl NetworkConfig { } } +/// MITM (man-in-the-middle) HTTPS interception settings. Off by default; +/// when on, the interceptor terminates TLS using a per-session CA so it can +/// apply URL-aware policy and record HTTP method/url/status (and, opt-in, +/// request/response bodies). The CA is global-only configuration; a repo +/// config may flip `enable` (typically to opt out) but cannot widen the +/// capture footprint. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] +pub struct MitmConfig { + /// Master switch for MITM. When false, no CA is generated and HTTPS + /// connections pass through opaquely (the 0060 behavior). + pub enable: bool, + /// TCP ports treated as HTTPS-bearing. Empty serializes to the default + /// `[443]` at use time. Always serialized when non-default to make the + /// config self-documenting. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, + /// When true, request and response bodies are captured into the audit + /// log (base64-encoded, capped by `max_body_bytes`). + pub capture_bodies: bool, + /// Per-body size cap when `capture_bodies` is on. Zero serializes to the + /// default at use time (`DEFAULT_MITM_MAX_BODY_BYTES`). + pub max_body_bytes: usize, +} + +pub const DEFAULT_MITM_PORTS: &[u16] = &[443]; +pub const DEFAULT_MITM_MAX_BODY_BYTES: usize = 64 * 1024; +pub const MIN_MITM_MAX_BODY_BYTES: usize = 1024; +pub const MAX_MITM_MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +// Default values are the natural defaults of each type: `false` for bool, +// empty vec, zero for the body cap (sentinel meaning "use the +// `DEFAULT_MITM_MAX_BODY_BYTES` constant"). The explicit impl wasn't +// adding documentation that derive can't. + +impl MitmConfig { + fn is_default(&self) -> bool { + self == &Self::default() + } + + /// Effective list of HTTPS-bearing ports, substituting the default when + /// no explicit list is configured. + pub fn effective_ports(&self) -> Vec { + if self.ports.is_empty() { + DEFAULT_MITM_PORTS.to_vec() + } else { + self.ports.clone() + } + } + + /// Effective body cap, substituting the default when zero. + pub fn effective_max_body_bytes(&self) -> usize { + if self.max_body_bytes == 0 { + DEFAULT_MITM_MAX_BODY_BYTES + } else { + self.max_body_bytes + } + } + + pub(crate) fn validate(&self) -> std::result::Result<(), String> { + if !self.enable { + return Ok(()); + } + for (idx, port) in self.ports.iter().enumerate() { + if *port == 0 { + return Err(format!( + "network.mitm.ports[{idx}] must be between 1 and 65535" + )); + } + } + if self.max_body_bytes != 0 + && !(MIN_MITM_MAX_BODY_BYTES..=MAX_MITM_MAX_BODY_BYTES).contains(&self.max_body_bytes) + { + return Err(format!( + "network.mitm.max-body-bytes must be 0 or between {MIN_MITM_MAX_BODY_BYTES} \ + and {MAX_MITM_MAX_BODY_BYTES}" + )); + } + Ok(()) + } +} + impl NetworkConfig { fn is_default(&self) -> bool { self == &Self::default() @@ -865,3 +1044,115 @@ where }) }) } + +#[cfg(test)] +mod mitm_tests { + use super::*; + + #[test] + fn network_entry_inline_table_accepts_path() { + let entry: NetworkEntry = + toml::from_str("host = \"api.example.com\"\nport = 443\npath = \"/repos/*\"\n") + .expect("entry"); + assert_eq!(entry.host, "api.example.com"); + assert_eq!(entry.port, Some(443)); + assert_eq!(entry.path.as_deref(), Some("/repos/*")); + entry.validate("test").expect("valid entry"); + } + + #[test] + fn network_entry_string_sugar_has_no_path() { + let entry: NetworkEntry = toml::from_str("v = \"github.com:443\"\n") + .map(|wrapper: toml::Table| wrapper["v"].clone().try_into::().unwrap()) + .expect("string entry"); + assert_eq!(entry.path, None); + } + + #[test] + fn network_entry_validates_path_starts_with_slash() { + let entry = NetworkEntry { + host: "example.com".to_string(), + port: Some(443), + path: Some("repos/*".to_string()), + }; + let err = entry.validate("network.allow[0]").expect_err("invalid"); + assert!(err.contains("must start with `/`"), "got: {err}"); + } + + #[test] + fn mitm_config_defaults_are_disabled() { + let m = MitmConfig::default(); + assert!(!m.enable); + assert!(m.is_default()); + assert!(m.validate().is_ok()); + assert_eq!(m.effective_ports(), vec![443]); + assert_eq!(m.effective_max_body_bytes(), DEFAULT_MITM_MAX_BODY_BYTES); + } + + #[test] + fn mitm_config_validates_body_cap_range() { + let mut m = MitmConfig { + enable: true, + max_body_bytes: 64, + ..MitmConfig::default() + }; + let err = m.validate().expect_err("below min should fail"); + assert!(err.contains("max-body-bytes")); + + m.max_body_bytes = MAX_MITM_MAX_BODY_BYTES + 1; + let err = m.validate().expect_err("above max should fail"); + assert!(err.contains("max-body-bytes")); + + m.max_body_bytes = 0; + m.validate().expect("zero is sentinel for default"); + } + + #[test] + fn mitm_config_rejects_zero_port() { + let m = MitmConfig { + enable: true, + ports: vec![443, 0], + ..MitmConfig::default() + }; + let err = m.validate().expect_err("port 0 should fail"); + assert!(err.contains("ports[1]")); + } + + #[test] + fn repo_config_can_set_mitm_enable_but_nothing_else() { + reject_repo_network_policy("[network.mitm]\nenable = true\n").expect("enable allowed"); + + let err = reject_repo_network_policy("[network.mitm]\nports = [443]\n") + .expect_err("ports rejected"); + assert!(err.to_string().contains("[network.mitm].ports")); + + let err = reject_repo_network_policy("[network.mitm]\ncapture-bodies = true\n") + .expect_err("capture-bodies rejected"); + assert!(err.to_string().contains("capture-bodies")); + + let err = reject_repo_network_policy("[network.mitm]\nmax-body-bytes = 4096\n") + .expect_err("max-body-bytes rejected"); + assert!(err.to_string().contains("max-body-bytes")); + } + + #[test] + fn declares_network_tables_detects_mitm_enable() { + let (declared, mitm_declared) = + declares_network_tables("[network]\nmode = \"audit\"\n").expect("toml ok"); + assert!(declared); + assert!(!mitm_declared); + + let (declared, mitm_declared) = + declares_network_tables("[network]\nmode = \"audit\"\n[network.mitm]\nenable = true\n") + .expect("toml ok"); + assert!(declared); + assert!(mitm_declared); + + let (declared, mitm_declared) = + declares_network_tables("[network.mitm]\n").expect("toml ok"); + // The `[network.mitm]` table exists but doesn't set `enable`, so a + // repo declaring just an empty table doesn't trigger repo-precedence. + assert!(declared); + assert!(!mitm_declared); + } +} diff --git a/src/config/validate.rs b/src/config/validate.rs index 4d44616..b8ab4ab 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -327,7 +327,37 @@ fn validate_network_policy(cfg: &Config) -> Result<(), ConfigValidationError> { cfg.network .policy() .validate(cfg.network.mode == NetworkMode::Filter) - .map_err(|message| ConfigValidationError::NetworkPolicyInvalid { message }) + .map_err(|message| ConfigValidationError::NetworkPolicyInvalid { message })?; + + cfg.network + .mitm + .validate() + .map_err(|message| ConfigValidationError::NetworkPolicyInvalid { message })?; + + if !cfg.network.mitm.enable { + for (idx, entry) in cfg.network.allow.iter().enumerate() { + if entry.path.is_some() { + return Err(ConfigValidationError::NetworkPolicyInvalid { + message: format!( + "network.allow[{idx}] sets `path` but [network.mitm].enable is false; \ + URL-aware rules require MITM" + ), + }); + } + } + for (idx, entry) in cfg.network.deny.iter().enumerate() { + if entry.path.is_some() { + return Err(ConfigValidationError::NetworkPolicyInvalid { + message: format!( + "network.deny[{idx}] sets `path` but [network.mitm].enable is false; \ + URL-aware rules require MITM" + ), + }); + } + } + } + + Ok(()) } fn validate_container_security( diff --git a/src/container/mod.rs b/src/container/mod.rs index 8852dbe..8eeec08 100644 --- a/src/container/mod.rs +++ b/src/container/mod.rs @@ -453,7 +453,7 @@ impl Drop for Container { /// `groupadd` / write to `/home`. Forcing `--user=0:0` explicitly puts us /// at in-container UID 0, which is what we need before any host user /// exists inside the container. -pub(super) fn podman_exec_root(name: &str) -> Cmd { +pub(crate) fn podman_exec_root(name: &str) -> Cmd { Cmd::new("podman").args(["exec", "--user=0:0"]).arg(name) } diff --git a/src/lib.rs b/src/lib.rs index 9d438ce..7ec26de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,8 +22,8 @@ pub mod mcp_proxy; mod outrig_; pub use config::{ - CapabilityProfile, MountAccess, NetworkAction, NetworkEntry, NetworkMode, NetworkPolicy, - NetworkPolicyBuilder, + CapabilityProfile, MitmConfig, MountAccess, NetworkAction, NetworkEntry, NetworkMode, + NetworkPolicy, NetworkPolicyBuilder, }; pub use mcp::{McpTool, McpToolResult}; pub use outrig_::{ diff --git a/src/network.rs b/src/network.rs index 5fd122e..800dc7d 100644 --- a/src/network.rs +++ b/src/network.rs @@ -7,6 +7,11 @@ //! from the host namespace, so OutRig's own traffic is not routed back //! through the interceptor. +pub mod mitm; +pub(crate) mod mitm_io; +pub mod tls; +pub mod trust; + use std::collections::BTreeMap; use std::fs::File as StdFile; use std::io::{self, Write as _}; @@ -28,10 +33,14 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::config::{ - NetworkAction, NetworkEntry, NetworkHostPattern, NetworkPolicy, parse_network_host_pattern, + MitmConfig, NetworkAction, NetworkEntry, NetworkHostPattern, NetworkPolicy, + parse_network_host_pattern, }; use crate::container::Container; use crate::error::{OutrigError, Result}; +use crate::network::mitm::{ + MitmAuditEvent, MitmAuditHook, MitmConnection, MitmContext, MitmPolicyDecision, MitmPolicyHook, +}; use crate::process::{self, Cmd, Transcript}; const NETWORK_LOG: &str = "network.jsonl"; @@ -70,6 +79,10 @@ struct CompiledNetworkPolicy { struct CompiledNetworkEntry { pattern: NetworkHostPattern, port: Option, + /// URL-path glob, present only when the source `NetworkEntry` set + /// `path = "..."`. Entries with `path` are skipped by + /// `decide_pre_mitm` and only matched by `decide_post_mitm`. + path: Option, } impl CompiledNetworkPolicy { @@ -82,8 +95,15 @@ impl CompiledNetworkPolicy { }) } - fn decide(&self, dst: SocketAddr, sniff: &Sniff) -> PolicyDecision { + /// Decision used at TCP-open time, before any TLS termination. Entries + /// with a `path` set are skipped here -- they can't match a TCP socket + /// yet, and letting host+port matching close the connection before TLS + /// would defeat URL-aware filtering. + fn decide_pre_mitm(&self, dst: SocketAddr, sniff: &Sniff) -> PolicyDecision { for (idx, entry) in self.deny.iter().enumerate() { + if entry.path.is_some() { + continue; + } if entry.matches(dst, sniff) { return PolicyDecision { action: NetworkAction::Deny, @@ -92,6 +112,9 @@ impl CompiledNetworkPolicy { } } for (idx, entry) in self.allow.iter().enumerate() { + if entry.path.is_some() { + continue; + } if entry.matches(dst, sniff) { return PolicyDecision { action: NetworkAction::Allow, @@ -104,6 +127,40 @@ impl CompiledNetworkPolicy { rule: "default".to_string(), } } + + /// Decision used after MITM has terminated TLS and parsed the request + /// line. Every entry participates; entries with `path` glob-match + /// against `url_path`. Deny-wins and `default` fallback unchanged. + /// + /// `_method` is reserved for future method-aware rules. + fn decide_post_mitm( + &self, + dst: SocketAddr, + sniff: &Sniff, + _method: &str, + url_path: &str, + ) -> PolicyDecision { + for (idx, entry) in self.deny.iter().enumerate() { + if entry.matches_with_path(dst, sniff, url_path) { + return PolicyDecision { + action: NetworkAction::Deny, + rule: format!("deny[{idx}]"), + }; + } + } + for (idx, entry) in self.allow.iter().enumerate() { + if entry.matches_with_path(dst, sniff, url_path) { + return PolicyDecision { + action: NetworkAction::Allow, + rule: format!("allow[{idx}]"), + }; + } + } + PolicyDecision { + action: self.default, + rule: "default".to_string(), + } + } } impl CompiledNetworkEntry { @@ -128,6 +185,20 @@ impl CompiledNetworkEntry { } } } + + /// Like `matches`, plus a path glob check when this entry has one. + /// Used post-MITM with the parsed request URL path. Entries without a + /// `path` match on host/port alone (so a host-only allow keeps + /// covering MITM traffic). + fn matches_with_path(&self, dst: SocketAddr, sniff: &Sniff, url_path: &str) -> bool { + if !self.matches(dst, sniff) { + return false; + } + match self.path.as_deref() { + Some(pattern) => glob_matches(pattern, url_path), + None => true, + } + } } fn compile_network_entries(entries: Vec) -> Result> { @@ -138,6 +209,7 @@ fn compile_network_entries(entries: Vec) -> Result>, cleanup: Cleanup, + mitm: MitmContext, disposed: bool, } impl NetworkInterceptor { pub async fn start(container: &Container, log_dir: &Path, session_id: &str) -> Result { - Self::start_with_policy(container, log_dir, session_id, NetworkPolicy::allow_all()).await + Self::start_with_options( + container, + log_dir, + session_id, + NetworkPolicy::allow_all(), + &MitmConfig::default(), + ) + .await } pub async fn start_with_policy( @@ -214,6 +294,28 @@ impl NetworkInterceptor { log_dir: &Path, session_id: &str, policy: NetworkPolicy, + ) -> Result { + Self::start_with_options( + container, + log_dir, + session_id, + policy, + &MitmConfig::default(), + ) + .await + } + + /// Start the interceptor with both an egress policy and the MITM + /// configuration. When `mitm.enable` is true, the CA is generated under + /// `log_dir/../tls/` (i.e. alongside `logs/`), installed in the + /// container trust store, and used to terminate TLS on HTTPS-bearing + /// ports listed in `mitm.ports`. + pub async fn start_with_options( + container: &Container, + log_dir: &Path, + session_id: &str, + policy: NetworkPolicy, + mitm: &MitmConfig, ) -> Result { require_tool("nft")?; require_tool("nsenter")?; @@ -239,6 +341,20 @@ impl NetworkInterceptor { transcript: container.transcript(), }; + // MITM materializes one CA per session under /tls/ + // alongside . `log_dir` points at `/logs/`, + // so parent() lands on the session directory. + let mitm_ctx = if mitm.enable { + let session_dir = log_dir.parent().unwrap_or(log_dir); + let ctx = MitmContext::enabled(mitm, session_dir, session_id)?; + if let Some(pem) = ctx.ca_pem() { + crate::network::trust::install_ca_in_container(container, &pem).await?; + } + ctx + } else { + MitmContext::disabled() + }; + install_audit_resolv_conf(container).await?; apply_nft_rules(&cleanup, tcp_port, dns_port).await?; @@ -248,6 +364,7 @@ impl NetworkInterceptor { audit.clone(), dns_cache.clone(), policy.clone(), + mitm_ctx.clone(), cancel.clone(), )), tokio::spawn(dns_loop(sockets.dns, dns_cache, cancel.clone())), @@ -257,6 +374,7 @@ impl NetworkInterceptor { cancel, tasks, cleanup, + mitm: mitm_ctx, disposed: false, }) } @@ -270,6 +388,7 @@ impl NetworkInterceptor { if let Err(e) = self.cleanup.delete_table().await { tracing::warn!(target: "outrig::network", "network cleanup failed: {e}"); } + self.mitm.cleanup(); self.disposed = true; } } @@ -382,6 +501,14 @@ struct AuditRecord { missed_bytes: u64, #[serde(skip_serializing_if = "Option::is_none")] server_name: Option, + // MITM-only fields. All optional: connections that did not go through + // MITM serialize with the exact 0060 shape (none of these appear). + #[serde(skip_serializing_if = "Option::is_none")] + method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, #[serde(rename = "outrig.session_id")] outrig_session_id: String, #[serde(rename = "outrig.container")] @@ -392,6 +519,21 @@ struct AuditRecord { outrig_action: &'static str, #[serde(rename = "outrig.rule")] outrig_rule: String, + #[serde( + rename = "outrig.request_body_b64", + skip_serializing_if = "Option::is_none" + )] + outrig_request_body_b64: Option, + #[serde( + rename = "outrig.response_body_b64", + skip_serializing_if = "Option::is_none" + )] + outrig_response_body_b64: Option, + #[serde( + rename = "outrig.body_truncated", + skip_serializing_if = "Option::is_none" + )] + outrig_body_truncated: Option<&'static str>, } impl AuditRecord { @@ -414,11 +556,56 @@ impl AuditRecord { local_resp: false, missed_bytes: 0, server_name: event.sniff.sni, + method: None, + url: None, + status: None, outrig_session_id: session_id.to_string(), outrig_container: container.to_string(), outrig_host: host, outrig_action: event.decision.action.as_str(), outrig_rule: event.decision.rule, + outrig_request_body_b64: None, + outrig_response_body_b64: None, + outrig_body_truncated: None, + } + } + + /// Build one per-request MITM audit record from a `MitmAuditEvent`. + /// Shares the Zeek `uid` of the parent TCP connection so consumers can + /// group request records with their underlying flow. + fn from_mitm( + session_id: &str, + container: &str, + event: crate::network::mitm::MitmAuditEvent, + ) -> Self { + Self { + ts: zeek_timestamp(event.opened), + uid: event.uid, + id_orig_h: event.orig.ip().to_string(), + id_orig_p: event.orig.port(), + id_resp_h: event.dst.ip().to_string(), + id_resp_p: event.dst.port(), + proto: "tcp", + service: "ssl-mitm", + duration: event.duration.as_secs_f64(), + orig_bytes: 0, + resp_bytes: 0, + conn_state: "SF", + local_orig: true, + local_resp: false, + missed_bytes: 0, + server_name: event.server_name, + method: Some(event.method), + url: Some(event.url), + status: event.status, + outrig_session_id: session_id.to_string(), + outrig_container: container.to_string(), + outrig_host: event.host, + outrig_action: event.action.as_str(), + outrig_rule: event.rule, + outrig_request_body_b64: event.request_body_b64, + outrig_response_body_b64: event.response_body_b64, + outrig_body_truncated: event.body_truncated, } } } @@ -483,6 +670,7 @@ async fn tcp_accept_loop( audit: AuditSink, dns_cache: DnsCache, policy: Arc, + mitm: MitmContext, cancel: CancellationToken, ) { loop { @@ -497,6 +685,7 @@ async fn tcp_accept_loop( audit.clone(), dns_cache.clone(), policy.clone(), + mitm.clone(), )); } Err(e) => { @@ -515,6 +704,7 @@ async fn handle_tcp( audit: AuditSink, dns_cache: DnsCache, policy: Arc, + mitm: MitmContext, ) { let opened = SystemTime::now(); let started = Instant::now(); @@ -547,7 +737,7 @@ async fn handle_tcp( } } - let decision = policy.decide(dst, &sniff); + let decision = policy.decide_pre_mitm(dst, &sniff); if decision.action == NetworkAction::Deny { write_audit( &audit, @@ -567,6 +757,54 @@ async fn handle_tcp( return; } + // MITM branch: take over the TLS connection ourselves so we can apply + // URL-aware policy and record method/url/status. Only fires when MITM + // is enabled, the destination port matches the configured HTTPS port + // list, and the client is actually speaking TLS with an SNI (no SNI + // means we can't honestly mint a leaf cert -- pass through opaque). + if mitm.is_enabled() + && mitm.is_https_port(dst.port()) + && sniff.service == "ssl" + && let Some(sni) = sniff.sni.clone() + { + let uid = zeek_uid(); + let policy_for_hook = policy.clone(); + let sniff_for_hook = sniff.clone(); + let policy_hook: MitmPolicyHook = Arc::new(move |_method, _url, url_path| { + let decision = + policy_for_hook.decide_post_mitm(dst, &sniff_for_hook, _method, url_path); + MitmPolicyDecision { + action: decision.action, + rule: decision.rule, + } + }); + let audit_for_hook = audit.clone(); + let audit_hook: MitmAuditHook = Arc::new(move |event: MitmAuditEvent| { + let sink = audit_for_hook.clone(); + Box::pin(async move { + let record = AuditRecord::from_mitm(&sink.session_id, &sink.container, event); + if let Err(e) = sink.write(&record).await { + tracing::warn!(target: "outrig::network", "mitm audit write failed: {e}"); + } + }) + }); + + let replay = crate::network::mitm_io::ReplayingStream::new(client, initial_client_bytes); + let conn = MitmConnection { + mitm: mitm.clone(), + orig, + dst, + sni, + uid, + policy: policy_hook, + audit: audit_hook, + }; + if let Err(e) = crate::network::mitm::handle_tls_mitm(replay, conn).await { + tracing::warn!(target: "outrig::network", "mitm proxy failed: {e}"); + } + return; + } + let mut upstream = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(dst)).await { Ok(Ok(upstream)) => upstream, Ok(Err(e)) => { @@ -1452,7 +1690,7 @@ mod tests { .expect("policy"), ) .expect("compile policy"); - let decision = policy.decide( + let decision = policy.decide_pre_mitm( "93.184.216.34:443".parse().expect("dst"), &Sniff { service: "ssl", @@ -1478,7 +1716,7 @@ mod tests { ) .expect("compile policy"); - let npm = policy.decide( + let npm = policy.decide_pre_mitm( "104.16.0.1:443".parse().expect("dst"), &Sniff { service: "ssl", @@ -1486,7 +1724,7 @@ mod tests { sni: Some("registry.npmjs.org".to_string()), }, ); - let cidr = policy.decide( + let cidr = policy.decide_pre_mitm( "10.2.3.4:22".parse().expect("dst"), &Sniff { service: "-", @@ -1494,7 +1732,7 @@ mod tests { sni: None, }, ); - let ipv6 = policy.decide( + let ipv6 = policy.decide_pre_mitm( "[2001:db8::1]:443".parse().expect("dst"), &Sniff { service: "ssl", @@ -1502,7 +1740,7 @@ mod tests { sni: None, }, ); - let ipv6_wrong_port = policy.decide( + let ipv6_wrong_port = policy.decide_pre_mitm( "[2001:db8::1]:80".parse().expect("dst"), &Sniff { service: "http", @@ -1535,4 +1773,157 @@ mod tests { vec![IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))] ); } + + fn build_mitm_policy( + allow: Vec, + deny: Vec, + ) -> CompiledNetworkPolicy { + CompiledNetworkPolicy::new(NetworkPolicy { + default: NetworkAction::Deny, + allow, + deny, + }) + .expect("compile policy") + } + + #[test] + fn pre_mitm_skips_path_bearing_entries() { + // A path-only deny must NOT close the TCP socket before MITM gets a + // chance to parse the URL. The host+port still needs an explicit + // allow to reach the MITM layer. + let policy = build_mitm_policy( + vec![NetworkEntry::with_port("api.github.com", 443)], + vec![NetworkEntry::with_url("api.github.com", 443, "/admin/*")], + ); + let decision = policy.decide_pre_mitm( + "140.82.121.5:443".parse().expect("dst"), + &Sniff { + service: "ssl", + host: Some("api.github.com".to_string()), + sni: Some("api.github.com".to_string()), + }, + ); + assert_eq!(decision.action, NetworkAction::Allow); + assert_eq!(decision.rule, "allow[0]"); + } + + #[test] + fn post_mitm_matches_path_glob() { + let policy = build_mitm_policy( + vec![NetworkEntry::with_url("api.github.com", 443, "/repos/*")], + vec![NetworkEntry::with_url("api.github.com", 443, "/admin/*")], + ); + let dst = "140.82.121.5:443".parse().expect("dst"); + let sniff = Sniff { + service: "ssl-mitm", + host: Some("api.github.com".to_string()), + sni: Some("api.github.com".to_string()), + }; + + let allow = policy.decide_post_mitm(dst, &sniff, "get", "/repos/foo/bar"); + assert_eq!(allow.action, NetworkAction::Allow); + assert_eq!(allow.rule, "allow[0]"); + + let deny = policy.decide_post_mitm(dst, &sniff, "get", "/admin/users"); + assert_eq!(deny.action, NetworkAction::Deny); + assert_eq!(deny.rule, "deny[0]"); + + let fallthrough = policy.decide_post_mitm(dst, &sniff, "get", "/other"); + assert_eq!(fallthrough.action, NetworkAction::Deny); + assert_eq!(fallthrough.rule, "default"); + } + + #[test] + fn post_mitm_root_path_matches_star() { + let policy = build_mitm_policy( + vec![NetworkEntry::with_url("api.github.com", 443, "/*")], + vec![], + ); + let decision = policy.decide_post_mitm( + "140.82.121.5:443".parse().expect("dst"), + &Sniff { + service: "ssl-mitm", + host: Some("api.github.com".to_string()), + sni: Some("api.github.com".to_string()), + }, + "get", + "/", + ); + assert_eq!(decision.action, NetworkAction::Allow); + } + + #[test] + fn audit_record_from_mitm_includes_url_method_status() { + use crate::network::mitm::MitmAuditEvent; + let event = MitmAuditEvent { + opened: UNIX_EPOCH + Duration::from_secs(1_700_000_000), + duration: Duration::from_millis(82), + orig: "10.0.2.100:50123".parse().expect("orig"), + dst: "140.82.121.5:443".parse().expect("dst"), + server_name: Some("api.github.com".to_string()), + host: "api.github.com".to_string(), + action: NetworkAction::Allow, + rule: "allow[1]".to_string(), + method: "GET".to_string(), + url: "https://api.github.com/repos/foo/bar".to_string(), + status: Some(200), + request_body_b64: None, + response_body_b64: None, + body_truncated: None, + uid: "Cabcdef1234567890".to_string(), + }; + let record = AuditRecord::from_mitm("20260513T000000-abcd", "outrig-test", event); + let json = serde_json::to_value(record).expect("record json"); + + assert_eq!(json["service"], "ssl-mitm"); + assert_eq!(json["method"], "GET"); + assert_eq!(json["url"], "https://api.github.com/repos/foo/bar"); + assert_eq!(json["status"], 200); + assert_eq!(json["server_name"], "api.github.com"); + assert_eq!(json["outrig.action"], "allow"); + assert_eq!(json["outrig.rule"], "allow[1]"); + assert_eq!(json["uid"], "Cabcdef1234567890"); + assert!(json.get("outrig.request_body_b64").is_none()); + assert!(json.get("outrig.response_body_b64").is_none()); + assert!(json.get("outrig.body_truncated").is_none()); + } + + #[test] + fn audit_record_non_mitm_omits_method_url_status() { + // Regression guard: 0060 audit records must stay byte-identical to + // the pre-MITM shape, so adding MITM fields cannot leak `method` / + // `url` / `status` (or the body fields) into non-MITM records. + let record = AuditRecord::new( + "20260513T000000-abcd", + "outrig-test", + AuditEvent { + opened: UNIX_EPOCH + Duration::from_secs(1_700_000_000), + duration: Duration::from_millis(125), + orig: "10.0.2.100:50123".parse().expect("orig"), + dst: "93.184.216.34:443".parse().expect("dst"), + sniff: Sniff { + service: "ssl", + host: Some("example.com".to_string()), + sni: Some("example.com".to_string()), + }, + bytes_tx: 1, + bytes_rx: 2, + decision: PolicyDecision::allow_default(), + }, + ); + let json = serde_json::to_value(record).expect("record json"); + for absent in [ + "method", + "url", + "status", + "outrig.request_body_b64", + "outrig.response_body_b64", + "outrig.body_truncated", + ] { + assert!( + json.get(absent).is_none(), + "non-MITM record must not carry {absent}" + ); + } + } } diff --git a/src/network/mitm.rs b/src/network/mitm.rs new file mode 100644 index 0000000..e6776c0 --- /dev/null +++ b/src/network/mitm.rs @@ -0,0 +1,640 @@ +//! HTTPS MITM proxy: terminate TLS in the interceptor, apply URL-aware +//! policy, re-encrypt upstream, and emit one audit record per HTTP request. +//! +//! Wire shape (per TCP connection): +//! +//! ```text +//! client TCP --[client TLS, ALPN h2 or http/1.1]--> outrig +//! outrig opens TLS to dst (real SNI, native roots) for upstream +//! for each request on the client conn: +//! parse method + path + Host +//! decide_post_mitm(...) -> allow / deny +//! deny -> synthesize 403, audit deny, continue +//! allow -> forward; tee bodies if capture is on; audit on completion +//! ``` +//! +//! Both server and client speak HTTP/1.1 and HTTP/2; the negotiated ALPN +//! decides which hyper builder is used. + +use std::convert::Infallible; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; + +use base64::Engine as _; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::header::{HOST, HeaderName}; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode, Uri, Version}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use rustls::RootCertStore; +use rustls::pki_types::ServerName; +use tokio::net::TcpStream; +use tokio_rustls::{TlsAcceptor, TlsConnector}; +use tracing::warn; + +use crate::config::{MitmConfig, NetworkAction}; +use crate::error::{OutrigError, Result}; +use crate::network::tls::{self, SessionCa}; + +/// ALPN protocols advertised by both the server-side (toward the +/// container) and client-side (toward the real upstream) of the MITM +/// proxy. Listed in preference order, h2 first. +const ALPN_PROTOCOLS: &[&[u8]] = &[b"h2", b"http/1.1"]; + +fn alpn_protocols() -> Vec> { + ALPN_PROTOCOLS.iter().map(|p| p.to_vec()).collect() +} + +/// Lifetime-extended view of the MITM configuration plus its derived state +/// (CA, native upstream root store, compiled HTTPS port set). Lives inside +/// the interceptor task and is cloned (cheaply, Arc internally) into each +/// per-connection handler. +#[derive(Clone)] +pub struct MitmContext { + inner: Arc, +} + +impl std::fmt::Debug for MitmContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MitmContext") + .field("enabled", &self.inner.enabled) + .field("ports", &self.inner.ports) + .field("capture_bodies", &self.inner.capture_bodies) + .field("max_body_bytes", &self.inner.max_body_bytes) + .finish() + } +} + +struct MitmContextInner { + enabled: bool, + ports: Vec, + capture_bodies: bool, + max_body_bytes: usize, + ca: Option, + /// Server-side TLS acceptor wired to the session CA. Built once at + /// startup so per-connection cost is one `Arc::clone`, not a fresh + /// `rustls::ServerConfig` build. + acceptor: Option, + /// Client-side TLS connector wired to the host's native trust anchors. + /// Built once at startup for the same reason as `acceptor`. + connector: Option, +} + +impl MitmContext { + /// Build a disabled context. Calls to `is_enabled` return false and no + /// CA is generated. Used when MITM is off so callers can pass the same + /// type through unconditionally. + pub fn disabled() -> Self { + Self { + inner: Arc::new(MitmContextInner { + enabled: false, + ports: Vec::new(), + capture_bodies: false, + max_body_bytes: 0, + ca: None, + acceptor: None, + connector: None, + }), + } + } + + /// Materialize an enabled MITM context. Generates the per-session CA + /// under `session_dir/tls/`, builds the upstream `RootCertStore` from + /// the host's native trust anchors, and locks in the configured ports + /// and body cap. + pub fn enabled( + config: &MitmConfig, + session_dir: &std::path::Path, + session_id: &str, + ) -> Result { + tls::ensure_rustls_provider_installed(); + let ca = SessionCa::generate(session_dir, session_id)?; + + let mut server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(ca.resolver()); + server_cfg.alpn_protocols = alpn_protocols(); + let acceptor = TlsAcceptor::from(Arc::new(server_cfg)); + + let client_cfg = build_upstream_client_config()?; + let connector = TlsConnector::from(Arc::new(client_cfg)); + + Ok(Self { + inner: Arc::new(MitmContextInner { + enabled: true, + ports: config.effective_ports(), + capture_bodies: config.capture_bodies, + max_body_bytes: config.effective_max_body_bytes(), + ca: Some(ca), + acceptor: Some(acceptor), + connector: Some(connector), + }), + }) + } + + pub fn is_enabled(&self) -> bool { + self.inner.enabled + } + + pub fn ca_pem(&self) -> Option { + self.inner.ca.as_ref().map(|ca| ca.ca_pem()) + } + + pub fn cleanup(&self) { + if let Some(ca) = &self.inner.ca { + ca.cleanup(); + } + } + + pub fn is_https_port(&self, port: u16) -> bool { + self.inner.ports.contains(&port) + } + + /// Effective per-body capture cap. `0` means capture is off; callers + /// pass this directly to `collect_capped` and `encode_capture`. + fn capture_cap(&self) -> usize { + if self.inner.capture_bodies { + self.inner.max_body_bytes + } else { + 0 + } + } +} + +/// Audit hook the per-request loop calls to record one HTTP request. The +/// callback owns serialization details; this module stays free of the +/// `AuditRecord` shape. +pub type MitmAuditHook = Arc< + dyn Fn(MitmAuditEvent) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + +/// Policy hook the per-request loop calls. Returns the action + a rule +/// label for audit. Letting the caller own decode lets us reuse the +/// existing `CompiledNetworkPolicy::decide_post_mitm` without making it +/// public. +pub type MitmPolicyHook = Arc MitmPolicyDecision + Send + Sync>; + +/// What `MitmPolicyHook` returns. Mirrors `PolicyDecision` in `network.rs` +/// but without re-exporting that internal type. +#[derive(Debug, Clone)] +pub struct MitmPolicyDecision { + pub action: NetworkAction, + pub rule: String, +} + +/// Per-request data handed to the audit hook. Includes both connection- +/// level identifiers (so records group cleanly with the TCP-level audit) +/// and per-request HTTP metadata. +#[derive(Debug, Clone)] +pub struct MitmAuditEvent { + pub opened: SystemTime, + pub duration: Duration, + pub orig: std::net::SocketAddr, + pub dst: std::net::SocketAddr, + pub server_name: Option, + pub host: String, + pub action: NetworkAction, + pub rule: String, + pub method: String, + pub url: String, + pub status: Option, + pub request_body_b64: Option, + pub response_body_b64: Option, + pub body_truncated: Option<&'static str>, + /// Same Zeek `uid` as the TCP-level audit record for this connection, + /// so consumers can group request records back to their underlying + /// TCP flow. + pub uid: String, +} + +/// Per-connection inputs handed in by `network::handle_tcp`. Everything the +/// proxy needs to run, without depending on internal types from +/// `network.rs`. +pub struct MitmConnection { + pub mitm: MitmContext, + pub orig: std::net::SocketAddr, + pub dst: std::net::SocketAddr, + pub sni: String, + pub uid: String, + pub policy: MitmPolicyHook, + pub audit: MitmAuditHook, +} + +/// Run the MITM proxy for one accepted TCP connection. +/// +/// `client_io` carries the original sniffed bytes prepended ahead of the +/// live TCP stream (because we already peeked the ClientHello to identify +/// it as TLS). The handshake completes against those bytes seamlessly. +pub async fn handle_tls_mitm(client_io: S, conn: MitmConnection) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let acceptor = conn + .mitm + .inner + .acceptor + .clone() + .expect("handle_tls_mitm called with disabled MitmContext"); + let connector = conn + .mitm + .inner + .connector + .clone() + .expect("handle_tls_mitm called with disabled MitmContext"); + + let tls_stream = match acceptor.accept(client_io).await { + Ok(s) => s, + Err(e) => { + warn!(target: "outrig::mitm", "client tls handshake failed: {e}"); + return Ok(()); + } + }; + let is_h2 = tls_stream.get_ref().1.alpn_protocol() == Some(b"h2"); + + let upstream_tcp = match TcpStream::connect(conn.dst).await { + Ok(s) => s, + Err(e) => { + warn!(target: "outrig::mitm", "upstream connect {}: {e}", conn.dst); + return Ok(()); + } + }; + let sni_owned: ServerName<'_> = match ServerName::try_from(conn.sni.clone()) { + Ok(s) => s, + Err(e) => { + warn!(target: "outrig::mitm", "invalid SNI {:?}: {e}", conn.sni); + return Ok(()); + } + }; + let upstream_tls = match connector.connect(sni_owned, upstream_tcp).await { + Ok(s) => s, + Err(e) => { + warn!(target: "outrig::mitm", "upstream tls handshake {}: {e}", conn.dst); + return Ok(()); + } + }; + let upstream_h2 = upstream_tls.get_ref().1.alpn_protocol() == Some(b"h2"); + + let svc_state = Arc::new(ServiceState { + mitm: conn.mitm.clone(), + orig: conn.orig, + dst: conn.dst, + sni: conn.sni.clone(), + uid: conn.uid.clone(), + policy: conn.policy.clone(), + audit: conn.audit.clone(), + upstream: Arc::new(UpstreamSender::new(upstream_tls, upstream_h2).await?), + }); + + let svc = service_fn(move |req| { + let state = svc_state.clone(); + async move { handle_request(state, req).await } + }); + + if is_h2 { + let exec = TokioExecutor::new(); + if let Err(e) = hyper::server::conn::http2::Builder::new(exec) + .serve_connection(TokioIo::new(tls_stream), svc) + .await + { + warn!(target: "outrig::mitm", "h2 server conn: {e}"); + } + } else if let Err(e) = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls_stream), svc) + .with_upgrades() + .await + { + warn!(target: "outrig::mitm", "http1 server conn: {e}"); + } + Ok(()) +} + +struct ServiceState { + mitm: MitmContext, + orig: std::net::SocketAddr, + dst: std::net::SocketAddr, + sni: String, + uid: String, + policy: MitmPolicyHook, + audit: MitmAuditHook, + upstream: Arc, +} + +/// Upstream-protocol-aware request sender. HTTP/1.1 connections handle one +/// request at a time, so the sender lives behind a `Mutex` and requests +/// serialize naturally. HTTP/2 multiplexes many requests on one connection; +/// its `SendRequest` is `Clone`, so we hand each request a fresh clone and +/// avoid the per-request lock. +enum UpstreamSender { + H1(tokio::sync::Mutex>>), + H2(hyper::client::conn::http2::SendRequest>), +} + +impl UpstreamSender { + async fn new(io: IO, is_h2: bool) -> Result + where + IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, + { + if is_h2 { + let (sender, conn) = + hyper::client::conn::http2::handshake(TokioExecutor::new(), TokioIo::new(io)) + .await + .map_err(|e| { + OutrigError::Configuration(format!("upstream h2 handshake: {e}")) + })?; + tokio::spawn(async move { + if let Err(e) = conn.await { + warn!(target: "outrig::mitm", "upstream h2 conn closed: {e}"); + } + }); + Ok(Self::H2(sender)) + } else { + let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io)) + .await + .map_err(|e| { + OutrigError::Configuration(format!("upstream http1 handshake: {e}")) + })?; + tokio::spawn(async move { + if let Err(e) = conn.await { + warn!(target: "outrig::mitm", "upstream http1 conn closed: {e}"); + } + }); + Ok(Self::H1(tokio::sync::Mutex::new(sender))) + } + } + + async fn send( + &self, + req: Request>, + ) -> std::result::Result, hyper::Error> { + match self { + Self::H1(mu) => mu.lock().await.send_request(req).await, + Self::H2(sender) => sender.clone().send_request(req).await, + } + } +} + +async fn handle_request( + state: Arc, + req: Request, +) -> std::result::Result>, Infallible> { + let opened = SystemTime::now(); + let started = Instant::now(); + let method = req.method().clone(); + let req_uri = req.uri().clone(); + + // Reconstruct an absolute URL: https:///. The + // authority comes from the request URI when present (HTTP/2 always), + // else from the Host header (HTTP/1.1). + let authority = req_uri + .authority() + .map(|a| a.to_string()) + .or_else(|| { + req.headers() + .get(HOST) + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| state.sni.clone()); + let path_and_query = req_uri + .path_and_query() + .map(|p| p.as_str().to_string()) + .unwrap_or_else(|| req_uri.path().to_string()); + let url = format!("https://{authority}{path_and_query}"); + + let url_path = req_uri.path().to_string(); + let decision = (state.policy)(&method.as_str().to_lowercase(), &url, &url_path); + + if matches!(decision.action, NetworkAction::Deny) { + let resp = Response::builder() + .status(StatusCode::FORBIDDEN) + .header("content-type", "text/plain; charset=utf-8") + .body(Full::new(Bytes::from_static( + b"outrig: blocked by network policy\n", + ))) + .expect("static deny response builds"); + (state.audit)(MitmAuditEvent { + opened, + duration: started.elapsed(), + orig: state.orig, + dst: state.dst, + server_name: Some(state.sni.clone()), + host: authority, + action: NetworkAction::Deny, + rule: decision.rule, + method: method.to_string(), + url, + status: Some(StatusCode::FORBIDDEN.as_u16()), + request_body_b64: None, + response_body_b64: None, + body_truncated: None, + uid: state.uid.clone(), + }) + .await; + return Ok(resp); + } + + let cap = state.mitm.capture_cap(); + + let (parts, body) = req.into_parts(); + let (req_bytes, req_truncated) = match collect_capped(body, cap).await { + Ok(v) => v, + Err(e) => { + warn!(target: "outrig::mitm", "reading request body: {e}"); + return Ok(error_response(StatusCode::BAD_GATEWAY)); + } + }; + + let mut upstream_req_builder = Request::builder() + .method(parts.method.clone()) + .uri(build_upstream_uri(&parts.uri)); + copy_proxy_headers( + &parts.headers, + upstream_req_builder.headers_mut().expect("fresh"), + ); + let upstream_req = match upstream_req_builder.body(Full::new(req_bytes.clone())) { + Ok(r) => r, + Err(e) => { + warn!(target: "outrig::mitm", "building upstream request: {e}"); + return Ok(error_response(StatusCode::BAD_GATEWAY)); + } + }; + + let resp = match state.upstream.send(upstream_req).await { + Ok(r) => r, + Err(e) => { + warn!(target: "outrig::mitm", "upstream send: {e}"); + (state.audit)(MitmAuditEvent { + opened, + duration: started.elapsed(), + orig: state.orig, + dst: state.dst, + server_name: Some(state.sni.clone()), + host: authority, + action: NetworkAction::Allow, + rule: decision.rule, + method: method.to_string(), + url, + status: None, + request_body_b64: encode_capture(&req_bytes, cap), + response_body_b64: None, + body_truncated: truncated_label(req_truncated, false), + uid: state.uid.clone(), + }) + .await; + return Ok(error_response(StatusCode::BAD_GATEWAY)); + } + }; + + let status = resp.status(); + let (resp_parts, resp_body) = resp.into_parts(); + let (resp_bytes, resp_truncated) = match collect_capped(resp_body, cap).await { + Ok(v) => v, + Err(e) => { + warn!(target: "outrig::mitm", "reading response body: {e}"); + return Ok(error_response(StatusCode::BAD_GATEWAY)); + } + }; + + let mut out_builder = Response::builder().status(status).version(Version::HTTP_11); + copy_proxy_headers( + &resp_parts.headers, + out_builder.headers_mut().expect("fresh"), + ); + let out_resp = match out_builder.body(Full::new(resp_bytes.clone())) { + Ok(r) => r, + Err(e) => { + warn!(target: "outrig::mitm", "building downstream response: {e}"); + return Ok(error_response(StatusCode::BAD_GATEWAY)); + } + }; + + (state.audit)(MitmAuditEvent { + opened, + duration: started.elapsed(), + orig: state.orig, + dst: state.dst, + server_name: Some(state.sni.clone()), + host: authority, + action: NetworkAction::Allow, + rule: decision.rule, + method: method.to_string(), + url, + status: Some(status.as_u16()), + request_body_b64: encode_capture(&req_bytes, cap), + response_body_b64: encode_capture(&resp_bytes, cap), + body_truncated: truncated_label(req_truncated, resp_truncated), + uid: state.uid.clone(), + }) + .await; + + Ok(out_resp) +} + +fn copy_proxy_headers(src: &hyper::HeaderMap, dst: &mut hyper::HeaderMap) { + for (k, v) in src.iter() { + if is_hop_by_hop(k) { + continue; + } + dst.insert(k.clone(), v.clone()); + } +} + +fn error_response(status: StatusCode) -> Response> { + Response::builder() + .status(status) + .body(Full::new(Bytes::new())) + .expect("status-only response builds") +} + +fn truncated_label(req_truncated: bool, resp_truncated: bool) -> Option<&'static str> { + match (req_truncated, resp_truncated) { + (false, false) => None, + (true, false) => Some("request"), + (false, true) => Some("response"), + (true, true) => Some("both"), + } +} + +fn encode_capture(bytes: &Bytes, cap: usize) -> Option { + if cap == 0 || bytes.is_empty() { + return None; + } + Some(base64::engine::general_purpose::STANDARD.encode(bytes)) +} + +fn build_upstream_uri(orig_uri: &Uri) -> Uri { + // Hyper accepts origin-form path-and-query on `send_request` regardless of + // the negotiated protocol; it attaches the right pseudo-headers for h2. + orig_uri + .path_and_query() + .and_then(|pq| Uri::try_from(pq.as_str()).ok()) + .unwrap_or_else(|| Uri::try_from("/").expect("static / is valid")) +} + +fn is_hop_by_hop(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "proxy-connection" + | "keep-alive" + | "transfer-encoding" + | "te" + | "trailer" + | "upgrade" + ) +} + +/// Read an `Incoming` body to completion, capturing at most `cap` bytes +/// of payload. Frames past the cap are still drained (we are a transparent +/// proxy) but their data is discarded from the captured buffer. +/// +/// Returns the captured slice as `Bytes` so downstream forwarding and +/// audit encoding both share the same Arc-backed buffer without a copy. +async fn collect_capped( + body: Incoming, + cap: usize, +) -> std::result::Result<(Bytes, bool), hyper::Error> { + let mut buf = Vec::new(); + let mut truncated = false; + let mut body = std::pin::Pin::new(Box::new(body)); + while let Some(frame) = body.frame().await { + let frame = frame?; + if let Ok(data) = frame.into_data() { + if cap == 0 { + continue; + } + if buf.len() >= cap { + truncated = true; + continue; + } + let remaining = cap - buf.len(); + if data.len() <= remaining { + buf.extend_from_slice(&data); + } else { + buf.extend_from_slice(&data[..remaining]); + truncated = true; + } + } + } + Ok((Bytes::from(buf), truncated)) +} + +fn build_upstream_client_config() -> Result { + let mut roots = RootCertStore::empty(); + let certs = rustls_native_certs::load_native_certs(); + for cert in certs.certs { + let _ = roots.add(cert); + } + for err in certs.errors { + warn!(target: "outrig::mitm", "loading native cert: {err}"); + } + let mut cfg = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + cfg.alpn_protocols = alpn_protocols(); + Ok(cfg) +} diff --git a/src/network/mitm_io.rs b/src/network/mitm_io.rs new file mode 100644 index 0000000..521a5d3 --- /dev/null +++ b/src/network/mitm_io.rs @@ -0,0 +1,66 @@ +//! Replay a pre-buffered byte slice before delegating to a live stream. +//! +//! `handle_tcp` peeks the first bytes of the client connection to identify +//! TLS (and capture SNI). When MITM takes over, the rustls server needs to +//! see those bytes as the start of the ClientHello -- they have already +//! been read off the socket and are sitting in a `Vec`. This wrapper +//! yields the buffered bytes first, then delegates further reads to the +//! underlying stream. + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; + +pub struct ReplayingStream { + inner: TcpStream, + buffered: Vec, + cursor: usize, +} + +impl ReplayingStream { + pub fn new(inner: TcpStream, buffered: Vec) -> Self { + Self { + inner, + buffered, + cursor: 0, + } + } +} + +impl AsyncRead for ReplayingStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.cursor < self.buffered.len() { + let remaining = &self.buffered[self.cursor..]; + let take = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..take]); + self.cursor += take; + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for ReplayingStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} diff --git a/src/network/tls.rs b/src/network/tls.rs new file mode 100644 index 0000000..e2b7e5e --- /dev/null +++ b/src/network/tls.rs @@ -0,0 +1,326 @@ +//! Per-session MITM CA and leaf-cert minting. +//! +//! When MITM is enabled, the interceptor mints a fresh ECDSA P-256 CA at +//! startup, writes its public cert plus private key into `/tls/` +//! (key permissions 0600), and issues per-host leaf certificates on demand +//! from a small FIFO-bounded cache. Nothing here touches the host trust +//! anchors; the CA lives only for the session. + +use std::collections::BTreeMap; +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType, + ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, SanType, +}; +use rustls::crypto::ring::sign::any_supported_type; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; +use time::{Duration as TimeDuration, OffsetDateTime}; + +use crate::error::{OutrigError, Result}; + +/// Validity window for the session CA and every leaf cert it signs. Long +/// enough to cover overnight benchmarks; short enough that a leaked +/// `ca.key` doesn't outlive a typical machine reimage. +const CA_VALIDITY_DAYS: i64 = 30; + +/// Cap on the leaf-cert cache. Bounded to guard against SNI flooding from +/// inside the container; eviction is FIFO when the cap is exceeded. +const LEAF_CACHE_CAP: usize = 256; + +/// Filenames used by the on-disk CA layout. The private key sits inside +/// the session directory at mode 0600. +pub const CA_CERT_FILE: &str = "ca.crt"; +pub const CA_KEY_FILE: &str = "ca.key"; +pub const CA_DIR: &str = "tls"; + +/// One session-scoped CA plus the leaf-cert cache backing the MITM TLS +/// handshakes. Cloning is cheap (Arc internally). +#[derive(Clone)] +pub struct SessionCa { + inner: Arc, +} + +struct SessionCaInner { + /// Signed CA certificate (PEM form is also written to disk). + ca_cert: Certificate, + /// CA signing key; never leaves this process. + ca_key: KeyPair, + /// Path to the on-disk private key, used by `cleanup` to wipe it. + ca_key_path: PathBuf, + leaf_cache: Mutex, +} + +struct LeafCache { + map: BTreeMap>, + order: VecDeque, +} + +impl LeafCache { + fn new() -> Self { + Self { + map: BTreeMap::new(), + order: VecDeque::new(), + } + } + + fn get(&self, key: &str) -> Option> { + self.map.get(key).cloned() + } + + fn insert(&mut self, key: String, value: Arc) { + if self.map.contains_key(&key) { + return; + } + while self.map.len() >= LEAF_CACHE_CAP { + if let Some(victim) = self.order.pop_front() { + self.map.remove(&victim); + } else { + break; + } + } + self.order.push_back(key.clone()); + self.map.insert(key, value); + } + + #[cfg(test)] + fn len(&self) -> usize { + self.map.len() + } +} + +impl SessionCa { + /// Generate a fresh CA, write the cert (mode 0644) and the key + /// (mode 0600) to `/tls/`, and return a handle that can + /// mint leaf certs on demand. + pub fn generate(session_dir: &Path, session_id: &str) -> Result { + let ca_dir = session_dir.join(CA_DIR); + std::fs::create_dir_all(&ca_dir).map_err(|e| { + OutrigError::Configuration(format!("creating MITM ca dir {}: {e}", ca_dir.display())) + })?; + + let (ca_cert, ca_key) = mint_ca(session_id)?; + + let ca_cert_path = ca_dir.join(CA_CERT_FILE); + let ca_key_path = ca_dir.join(CA_KEY_FILE); + write_file_mode(&ca_cert_path, ca_cert.pem().as_bytes(), 0o644)?; + write_file_mode(&ca_key_path, ca_key.serialize_pem().as_bytes(), 0o600)?; + + Ok(Self { + inner: Arc::new(SessionCaInner { + ca_cert, + ca_key, + ca_key_path, + leaf_cache: Mutex::new(LeafCache::new()), + }), + }) + } + + /// PEM-encoded CA certificate. Suitable for installing into the + /// container trust store. + pub fn ca_pem(&self) -> String { + self.inner.ca_cert.pem() + } + + /// Mint (or fetch from cache) a leaf certificate keyed on the given + /// server name. The returned `CertifiedKey` is ready to hand to a + /// rustls `ResolvesServerCert` implementation. + pub fn leaf_for(&self, server_name: &str) -> Result> { + let key = server_name.to_ascii_lowercase(); + if let Some(found) = self.inner.leaf_cache.lock().unwrap().get(&key) { + return Ok(found); + } + let certified = mint_leaf(&self.inner.ca_cert, &self.inner.ca_key, &key)?; + let arc = Arc::new(certified); + self.inner + .leaf_cache + .lock() + .unwrap() + .insert(key, arc.clone()); + Ok(arc) + } + + /// Best-effort removal of the on-disk private key. The public cert is + /// left in place so audit-log readers can verify recorded handshakes + /// after the session ends. + pub fn cleanup(&self) { + let _ = std::fs::remove_file(&self.inner.ca_key_path); + } + + /// SNI-driven resolver suitable for plugging into a + /// `rustls::ServerConfig`. + pub fn resolver(&self) -> Arc { + Arc::new(CaResolver { ca: self.clone() }) + } +} + +struct CaResolver { + ca: SessionCa, +} + +impl std::fmt::Debug for CaResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CaResolver").finish() + } +} + +impl ResolvesServerCert for CaResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + let sni = client_hello.server_name()?; + self.ca.leaf_for(sni).ok() + } +} + +fn mint_ca(session_id: &str) -> Result<(Certificate, KeyPair)> { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(rcgen_err)?; + + let mut params = CertificateParams::new(Vec::::new()).map_err(rcgen_err)?; + let mut dn = DistinguishedName::new(); + dn.push(DnType::OrganizationName, "outrig"); + dn.push( + DnType::CommonName, + format!("outrig session {session_id} CA"), + ); + params.distinguished_name = dn; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let (nb, na) = validity_window(); + params.not_before = nb; + params.not_after = na; + + let cert = params.self_signed(&key).map_err(rcgen_err)?; + Ok((cert, key)) +} + +fn mint_leaf(ca_cert: &Certificate, ca_key: &KeyPair, server_name: &str) -> Result { + let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(rcgen_err)?; + + let mut params = CertificateParams::new(vec![server_name.to_string()]).map_err(rcgen_err)?; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, server_name); + params.distinguished_name = dn; + params.subject_alt_names = + vec![SanType::DnsName(server_name.try_into().map_err(|e| { + OutrigError::Configuration(format!("invalid SNI {server_name:?}: {e}")) + })?)]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let (nb, na) = validity_window(); + params.not_before = nb; + params.not_after = na; + + let leaf_cert = params + .signed_by(&leaf_key, ca_cert, ca_key) + .map_err(rcgen_err)?; + + let key_der = PrivatePkcs8KeyDer::from(leaf_key.serialize_der()); + let signing_key = any_supported_type(&PrivateKeyDer::Pkcs8(key_der)) + .map_err(|e| OutrigError::Configuration(format!("loading MITM leaf key: {e:?}")))?; + let cert_chain = vec![ + CertificateDer::from(leaf_cert.der().to_vec()), + CertificateDer::from(ca_cert.der().to_vec()), + ]; + Ok(CertifiedKey::new(cert_chain, signing_key)) +} + +fn rcgen_err(e: rcgen::Error) -> OutrigError { + OutrigError::Configuration(format!("MITM cert generation: {e}")) +} + +fn validity_window() -> (OffsetDateTime, OffsetDateTime) { + let now = OffsetDateTime::now_utc(); + let nb = now - TimeDuration::seconds(60); + let na = now + TimeDuration::days(CA_VALIDITY_DAYS); + (nb, na) +} + +/// rustls 0.23 requires a `CryptoProvider` to be installed before any +/// builder method picks a default. The call is idempotent (the second +/// `install_default` returns `Err`, which we deliberately drop). +pub(crate) fn ensure_rustls_provider_installed() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +fn write_file_mode(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(mode) + .open(path) + .map_err(|e| { + OutrigError::Configuration(format!("writing MITM file {}: {e}", path.display())) + })?; + file.write_all(contents).map_err(|e| { + OutrigError::Configuration(format!("writing MITM file {}: {e}", path.display())) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn ca_files_have_expected_permissions() { + ensure_rustls_provider_installed(); + let dir = tempfile::tempdir().unwrap(); + let ca = SessionCa::generate(dir.path(), "20260101T000000-0001").unwrap(); + + let key_path = dir.path().join(CA_DIR).join(CA_KEY_FILE); + let cert_path = dir.path().join(CA_DIR).join(CA_CERT_FILE); + assert!(cert_path.exists()); + assert!(key_path.exists()); + + let key_mode = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777; + assert_eq!(key_mode, 0o600, "ca.key should be 0600, got {key_mode:o}"); + + let pem = ca.ca_pem(); + assert!(pem.starts_with("-----BEGIN CERTIFICATE-----")); + } + + #[test] + fn leaf_cache_caps_at_capacity() { + ensure_rustls_provider_installed(); + let dir = tempfile::tempdir().unwrap(); + let ca = SessionCa::generate(dir.path(), "20260101T000000-0002").unwrap(); + + for n in 0..(LEAF_CACHE_CAP + 1) { + let host = format!("h{n}.example.com"); + ca.leaf_for(&host).unwrap(); + } + let len = ca.inner.leaf_cache.lock().unwrap().len(); + assert_eq!(len, LEAF_CACHE_CAP); + } + + #[test] + fn leaf_cache_returns_cached_handles() { + ensure_rustls_provider_installed(); + let dir = tempfile::tempdir().unwrap(); + let ca = SessionCa::generate(dir.path(), "20260101T000000-0003").unwrap(); + let a = ca.leaf_for("example.com").unwrap(); + let b = ca.leaf_for("EXAMPLE.com").unwrap(); + assert!(Arc::ptr_eq(&a, &b), "cache should be case-insensitive"); + } + + #[test] + fn cleanup_removes_ca_key() { + ensure_rustls_provider_installed(); + let dir = tempfile::tempdir().unwrap(); + let ca = SessionCa::generate(dir.path(), "20260101T000000-0004").unwrap(); + let key_path = dir.path().join(CA_DIR).join(CA_KEY_FILE); + assert!(key_path.exists()); + ca.cleanup(); + assert!(!key_path.exists(), "cleanup should remove ca.key"); + assert!( + dir.path().join(CA_DIR).join(CA_CERT_FILE).exists(), + "cleanup must NOT remove ca.crt" + ); + } +} diff --git a/src/network/trust.rs b/src/network/trust.rs new file mode 100644 index 0000000..f88c91d --- /dev/null +++ b/src/network/trust.rs @@ -0,0 +1,200 @@ +//! Install a per-session MITM CA into a session container's trust stores. +//! +//! Writes the CA PEM to three canonical locations covering Debian-family, +//! Red Hat-family, and a standalone PEM that language-specific bundle +//! variables can point at. Runs both system updaters with `|| true` so the +//! wrong-distro tool fails silently. Finally drops an `/etc/profile.d` +//! snippet exporting language env vars (`NODE_EXTRA_CA_CERTS`, +//! `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, +//! `GIT_SSL_CAINFO`) so shells inside the container pick the CA up. + +use std::collections::BTreeMap; + +use crate::container::{Container, podman_exec_root}; +use crate::error::Result; +use crate::process; +#[cfg(test)] +use crate::process::Cmd; + +/// Path inside `/etc/profile.d` that exports the language trust-store env +/// vars. Sourced by every interactive shell on POSIX-style images. +pub const CA_PROFILE_PATH: &str = "/etc/profile.d/outrig-ca.sh"; + +/// Path the standalone PEM lives at, regardless of distro. The standalone +/// PEM exists so tools that read a single CA file (notably Node via +/// `NODE_EXTRA_CA_CERTS`) can point at one path. +pub const STANDALONE_CA_PATH: &str = "/etc/ssl/certs/outrig-ca.pem"; + +/// Path that the merged system bundle lives at on both Debian-family and +/// Red Hat-family images. `update-ca-certificates` / `update-ca-trust` +/// rebuild it after our anchor is added. +pub const SYSTEM_BUNDLE_PATH: &str = "/etc/ssl/certs/ca-certificates.crt"; + +/// Write the CA into the container's trust stores. Idempotent (rerunning +/// just rewrites the files with the same content). +pub async fn install_ca_in_container(container: &Container, ca_pem: &str) -> Result<()> { + let script = build_install_script(ca_pem); + let cmd = podman_exec_root(&container.name) + .arg("sh") + .arg("-c") + .arg(script); + let transcript = container.transcript(); + let _ = process::run_capture_logged(cmd, "network", transcript.as_ref()).await?; + Ok(()) +} + +/// Env vars the interceptor must forward into every `podman exec` so MCP +/// servers pick up the CA without depending on `/etc/profile.d` being +/// sourced. The merged-bundle path covers requests/curl/git; the +/// standalone PEM covers Node. +pub fn exec_env_overrides() -> BTreeMap { + let mut env = BTreeMap::new(); + env.insert( + "NODE_EXTRA_CA_CERTS".to_string(), + STANDALONE_CA_PATH.to_string(), + ); + for key in [ + "REQUESTS_CA_BUNDLE", + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] { + env.insert(key.to_string(), SYSTEM_BUNDLE_PATH.to_string()); + } + env +} + +fn build_install_script(ca_pem: &str) -> String { + // Single-quoted heredoc (`<<'EOF'`) keeps shell from interpreting `$`, + // backticks, or backslashes in the PEM body. PEM data is base64 plus + // header dashes plus whitespace -- it cannot contain the EOF marker we + // pick below, and cannot contain `'` either. The trailing newline on + // every literal is deliberate. + let mut script = String::new(); + script.push_str("set -e\n"); + script.push_str("umask 022\n"); + script.push_str("mkdir -p /usr/local/share/ca-certificates "); + script.push_str("/etc/pki/ca-trust/source/anchors /etc/ssl/certs "); + script.push_str("/etc/profile.d\n"); + + let pem_locations = [ + "/usr/local/share/ca-certificates/outrig-ca.crt", + "/etc/pki/ca-trust/source/anchors/outrig-ca.crt", + STANDALONE_CA_PATH, + ]; + for path in pem_locations { + script.push_str("cat > '"); + script.push_str(path); + script.push_str("' <<'OUTRIG_CA_PEM_EOF'\n"); + script.push_str(ca_pem); + if !ca_pem.ends_with('\n') { + script.push('\n'); + } + script.push_str("OUTRIG_CA_PEM_EOF\n"); + } + + script.push_str("update-ca-certificates >/dev/null 2>&1 || true\n"); + script.push_str("update-ca-trust extract >/dev/null 2>&1 || true\n"); + + script.push_str("cat > '"); + script.push_str(CA_PROFILE_PATH); + script.push_str("' <<'OUTRIG_PROFILE_EOF'\n"); + script.push_str(profile_script_body()); + script.push_str("OUTRIG_PROFILE_EOF\n"); + script.push_str("chmod 0644 "); + script.push_str(CA_PROFILE_PATH); + script.push('\n'); + + script +} + +fn profile_script_body() -> &'static str { + "# outrig MITM trust anchors\n\ + export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/outrig-ca.pem\n\ + export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\n\ + export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\n\ + export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt\n\ + export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt\n" +} + +/// Strip the wrapper around `podman exec`, returning just the trailing +/// shell argv -- useful for unit tests that want to inspect what would be +/// executed without spawning podman. Kept here (rather than in the test +/// module) so call sites that need to assert wire format have one knob. +#[cfg(test)] +pub(crate) fn install_argv(container_name: &str, ca_pem: &str) -> Cmd { + podman_exec_root(container_name) + .arg("sh") + .arg("-c") + .arg(build_install_script(ca_pem)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_script_writes_all_three_pem_paths() { + let script = + build_install_script("-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"); + assert!(script.contains("/usr/local/share/ca-certificates/outrig-ca.crt")); + assert!(script.contains("/etc/pki/ca-trust/source/anchors/outrig-ca.crt")); + assert!(script.contains(STANDALONE_CA_PATH)); + } + + #[test] + fn install_script_runs_both_updaters_with_or_true() { + let script = build_install_script("PEM\n"); + assert!(script.contains("update-ca-certificates >/dev/null 2>&1 || true")); + assert!(script.contains("update-ca-trust extract >/dev/null 2>&1 || true")); + } + + #[test] + fn install_script_drops_profile_with_node_var() { + let script = build_install_script("PEM\n"); + assert!(script.contains(CA_PROFILE_PATH)); + assert!(script.contains("NODE_EXTRA_CA_CERTS=/etc/ssl/certs/outrig-ca.pem")); + assert!(script.contains("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt")); + assert!(script.contains("GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt")); + } + + #[test] + fn install_script_appends_newline_to_pem() { + let script = build_install_script("not-newline-terminated"); + // The heredoc body must end with a newline before the EOF marker, + // otherwise the heredoc parser eats the marker. Look for at least + // one occurrence of "not-newline-terminated\nOUTRIG_CA_PEM_EOF". + assert!(script.contains("not-newline-terminated\nOUTRIG_CA_PEM_EOF\n")); + } + + #[test] + fn install_argv_includes_user_root_and_target_container() { + let cmd = install_argv("outrig-test", "PEM\n"); + let rendered = cmd.render(); + assert!(rendered.contains("podman")); + assert!(rendered.contains("--user=0:0")); + assert!(rendered.contains("outrig-test")); + assert!(rendered.contains("sh -c")); + } + + #[test] + fn exec_env_overrides_covers_node_and_bundle_vars() { + let env = exec_env_overrides(); + assert_eq!( + env.get("NODE_EXTRA_CA_CERTS").map(String::as_str), + Some(STANDALONE_CA_PATH) + ); + for key in [ + "REQUESTS_CA_BUNDLE", + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] { + assert_eq!( + env.get(key).map(String::as_str), + Some(SYSTEM_BUNDLE_PATH), + "missing or wrong: {key}" + ); + } + } +} diff --git a/src/outrig_.rs b/src/outrig_.rs index fbfbb26..8c41c58 100644 --- a/src/outrig_.rs +++ b/src/outrig_.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::config::{ CapabilityProfile, ContainerConfig, ContainerSecurity, ContainerSourceRef, EnvValue, - McpServerSpec, MountAccess, NetworkMode, NetworkPolicy, Workspace, + McpServerSpec, MitmConfig, MountAccess, NetworkMode, NetworkPolicy, Workspace, }; use crate::container::{ Container, ContainerCapabilities, ContainerLaunchSpec, ContainerMount, ContainerWorkspace, @@ -74,6 +74,10 @@ pub struct CapabilitySpec { pub struct NetworkSpec { pub mode: NetworkMode, pub policy: Option, + /// HTTPS MITM settings. Off by default; when enabled, the interceptor + /// terminates TLS using a per-session CA and records URL / method / + /// status (and optional bodies) on each request. + pub mitm: MitmConfig, } impl From<&ContainerSecurity> for SecuritySpec { @@ -257,6 +261,14 @@ impl LaunchSpec { self.network.policy = Some(policy); self } + + /// Enable HTTPS MITM with the given settings. Compose with + /// `with_network_mode` or `with_network_filter` to select the egress + /// policy applied alongside. + pub fn with_mitm(mut self, mitm: MitmConfig) -> Self { + self.network.mitm = mitm; + self + } } fn resolve_workspace_host(repo_root: &Path, path: &Path) -> PathBuf {