From 2f09ed18a2a9e98be1e0bfe14d9f246dc905b597 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:21:46 -0700 Subject: [PATCH 01/40] Add ts dev proxy design spec and implementation plan --- .../plans/2026-06-22-ts-dev-proxy.md | 1825 +++++++++++++++++ .../specs/2026-06-22-ts-dev-proxy-design.md | 653 ++++++ 2 files changed, 2478 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-ts-dev-proxy.md create mode 100644 docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md new file mode 100644 index 000000000..de60157bf --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -0,0 +1,1825 @@ +# `ts dev proxy` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local TLS-terminating (MITM) dev proxy, shipped as `ts dev proxy`, that serves a production publisher hostname from a dev/staging upstream by swapping the TLS SNI, using a per-machine local CA so Chrome/Firefox/Safari all trust it. + +**Architecture:** A native host binary (`crates/trusted-server-cli`, **excluded** from the wasm workspace) built on tokio + hyper + rustls + rcgen. The accept loop handles `CONNECT`: it matches the authority against a rule table *before* replying, blind-tunnels unmatched hosts, and MITM-terminates matched hosts with a leaf minted from a local CA, rewriting SNI→`TO` while preserving `Host: FROM`. Pure logic (rule matching, header outcomes, PAC generation, config resolution) is isolated from I/O so it is unit-testable without sockets. + +**Tech Stack:** Rust 2024 edition; `tokio` (`net`, `rt-multi-thread`, `macros`, `io-util`), `hyper` 1 + `hyper-util`, `rustls` 0.23 + `tokio-rustls` 0.26, `rcgen` 0.13 (`Issuer`/leaf minting), `rustls-pemfile` 2, `clap` 4 (derive), `error-stack` 0.6, `derive_more` 2, `log`, `base64` 0.22, `directories` (platform data dir). The spec is the source of truth: [docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md](../specs/2026-06-22-ts-dev-proxy-design.md). + +## Global Constraints + +- Rust **2024 edition**; the crate is **excluded** from `[workspace]` (the repo pins `build.target = "wasm32-wasip1"` in `.cargo/config.toml`; this binary is native). Build/run with an explicit native target: `cargo … --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')"`. +- Excluded crates inherit no `[workspace.dependencies]`/`[workspace.lints]`: pin deps directly and declare an own `[lints.clippy]` mirroring the workspace (deny `unwrap_used`, deny `panic`). +- No `unwrap()`/`panic!`/`println!`/`eprintln!` in non-test code: use `expect("should …")` only where truly infallible, `error-stack` `Report` for fallible paths, `log::*` for instrumentation, and a single binary-scoped output helper (`#![allow(clippy::print_stdout)]` only in that helper module) for user-facing stdout. +- Errors: concrete enums with `derive_more::Display` + `impl core::error::Error`; `ensure!`/`bail!`; `change_context`/`attach`. Import `Error` from `core::error`. +- Example/fictional data only in tests/docs (e.g. `www.example-publisher.com`, `*.edgecompute.app`, `example.com`). No real domains/credentials. +- Default `Host` upstream is **`FROM`** (preserve production host); `--rewrite-host` sends `Host = TO`. SNI is always `TO` **host only** (port stripped). +- Proxy binds **loopback only** unless `--allow-non-loopback`; off loopback, unmatched `CONNECT` is refused `403` (never blind-tunneled). +- CA: CN `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION`; key file `0600`, dir `0700`; never committed; leaf SAN = host, validity ≤ 90 days; ALPN `http/1.1`. +- Commit after every green step. Commit subjects: sentence case, imperative, no semantic prefixes, no AI bylines. + +--- + +## File Structure + +``` +crates/trusted-server-cli/ + Cargo.toml # [[bin]] name = "ts"; native deps; own [lints.clippy] + src/ + lib.rs # library root (Cli, run); tests import this, not the bin + main.rs # thin bin: parse Cli, call run(), exit + output.rs # user-facing stdout/stderr helper (#![allow(clippy::print_stdout)]) + commands/ + mod.rs + dev/ + mod.rs # Dev subcommand group + proxy/ + mod.rs # ProxyArgs (clap), CaArgs; orchestration entrypoint + rewrite.rs # Rule, RuleTable, Match, RewriteOutcome — pure logic + config.rs # ResolvedConfig: args + env + project-config inference + ca.rs # CertAuthority: load-or-generate, mint+cache leaves + server.rs # accept loop, CONNECT dispatch, blind tunnel, MITM, local routes + browser.rs # PAC generation; Chrome/Firefox/Safari launch+configure; ca install/uninstall +Cargo.toml (workspace root) # add crates/trusted-server-cli to [workspace].exclude +``` + +One responsibility per file. `rewrite.rs`, `config.rs`, `browser.rs` (PAC gen) are pure and fully unit-tested; `ca.rs` is testable against a temp dir; `server.rs` is covered by the native integration test (Task 4). + +--- + +## Task 1: Crate skeleton + workspace wiring + CLI surface + +**Files:** +- Modify: `Cargo.toml` (workspace root) — add to `[workspace].exclude` +- Create: `crates/trusted-server-cli/Cargo.toml` +- Create: `crates/trusted-server-cli/src/lib.rs` +- Create: `crates/trusted-server-cli/src/main.rs` +- Create: `crates/trusted-server-cli/src/output.rs` +- Create: `crates/trusted-server-cli/src/commands/mod.rs` +- Create: `crates/trusted-server-cli/src/commands/dev/mod.rs` +- Create: `crates/trusted-server-cli/src/commands/dev/proxy/mod.rs` + +**Interfaces:** +- Produces: `ProxyArgs` (clap-derived struct, fields below), `CaCommand` enum (`Path`/`Install`/`Uninstall`/`Regenerate`), `run(args: ProxyArgs) -> error_stack::Result<(), ProxyError>` (stub), `output::info(&str)` / `output::warn(&str)`. + +- [ ] **Step 1: Add the crate to the workspace exclude list** + +In root `Cargo.toml`, add the new crate beside `integration-tests`: + +```toml +exclude = [ + "crates/integration-tests", + "crates/openrtb-codegen", + "crates/trusted-server-cli", +] +``` + +- [ ] **Step 2: Write `crates/trusted-server-cli/Cargo.toml`** + +```toml +[package] +name = "trusted-server-cli" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "trusted_server_cli" +path = "src/lib.rs" + +[[bin]] +name = "ts" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } +hyper = { version = "1", features = ["http1", "server", "client"] } +hyper-util = { version = "0.1", features = ["tokio"] } +rustls = "0.23" +tokio-rustls = "0.26" +rcgen = "0.13" +time = "0.3" +rustls-pemfile = "2" +clap = { version = "4", features = ["derive", "env"] } +error-stack = "0.6" +derive_more = { version = "2.0", features = ["display", "error"] } +log = "0.4" +env_logger = "0.11" +base64 = "0.22" +directories = "5" + +[dev-dependencies] +tempfile = "3" +reqwest = { version = "0.12", features = ["blocking"] } + +[lints.clippy] +unwrap_used = "deny" +panic = "deny" +print_stdout = "warn" +print_stderr = "warn" +``` + +- [ ] **Step 3: Write the output helper `src/output.rs`** + +```rust +//! User-facing console output for the `ts` binary. +//! +//! This is the only module permitted to write to stdout/stderr directly; +//! everything else uses `log`. +#![allow(clippy::print_stdout, clippy::print_stderr)] + +/// Prints an informational line to stdout. +pub fn info(message: &str) { + println!("{message}"); +} + +/// Prints a warning line to stderr. +pub fn warn(message: &str) { + eprintln!("warning: {message}"); +} +``` + +- [ ] **Step 4: Write the library root `src/lib.rs`** + +The crate is a **library + thin bin** so that integration tests (Task 5) can import `config`/`ca`/`server` — Rust integration tests can only reach a library target, not a binary's private modules. + +```rust +//! Trusted Server developer CLI library. The `ts` binary is a thin wrapper; +//! all logic lives here so integration tests can exercise it. +pub mod commands; +pub mod output; + +use clap::Parser; +use commands::dev::DevCommand; + +/// The `ts` command-line interface. +#[derive(Debug, Parser)] +#[command(name = "ts", version, about = "Trusted Server developer CLI")] +pub struct Cli { + #[command(subcommand)] + command: TopCommand, +} + +#[derive(Debug, clap::Subcommand)] +enum TopCommand { + /// Local development tools. + #[command(subcommand)] + Dev(DevCommand), +} + +impl Cli { + /// Runs the parsed CLI, returning a process exit code. + #[must_use] + pub fn run(self) -> i32 { + let result = match self.command { + TopCommand::Dev(dev) => commands::dev::run(dev), + }; + if let Err(report) = result { + output::warn(&format!("{report:?}")); + return 1; + } + 0 + } +} +``` + +- [ ] **Step 5: Write the thin binary `src/main.rs`** + +```rust +use clap::Parser as _; +use trusted_server_cli::Cli; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + std::process::exit(Cli::parse().run()); +} +``` + +- [ ] **Step 5b: Write `src/commands/mod.rs` and `src/commands/dev/mod.rs`** + +`src/commands/mod.rs`: + +```rust +pub mod dev; +``` + +`src/commands/dev/mod.rs`: + +```rust +pub mod proxy; + +use proxy::{ProxyArgs, ProxyError}; + +/// The `ts dev …` command group. +#[derive(Debug, clap::Subcommand)] +pub enum DevCommand { + /// Run the local production-hostname dev proxy. + Proxy(ProxyArgs), +} + +/// Dispatches a `dev` subcommand. +/// +/// # Errors +/// Propagates failures from the chosen subcommand. +pub fn run(command: DevCommand) -> error_stack::Result<(), ProxyError> { + match command { + DevCommand::Proxy(args) => proxy::run(args), + } +} +``` + +- [ ] **Step 6: Write `src/commands/dev/proxy/mod.rs` with the full arg surface and a stub `run`** + +```rust +pub mod ca; +pub mod config; +pub mod rewrite; + +use crate::output; + +/// Errors surfaced by `ts dev proxy`. +#[derive(Debug, derive_more::Display)] +pub enum ProxyError { + /// A rewrite rule could not be parsed or resolved. + #[display("invalid rule configuration")] + Config, + /// The local certificate authority could not be loaded or generated. + #[display("certificate authority error")] + CertAuthority, + /// The proxy server failed to start or run. + #[display("proxy server error")] + Server, + /// A browser could not be launched or configured. + #[display("browser orchestration error")] + Browser, +} + +impl core::error::Error for ProxyError {} + +/// `ts dev proxy [OPTIONS]` — see the design spec §4. +#[derive(Debug, clap::Args)] +pub struct ProxyArgs { + /// Rewrite rule `FROM=TO` (repeatable). + #[arg(long = "map", value_name = "FROM=TO")] + pub map: Vec, + + /// Shorthand single-rule FROM (optional when inferable from config). + #[arg(short = 'f', long = "from", value_name = "HOST")] + pub from: Option, + + /// Shorthand single-rule TO (`HOST[:PORT]`). + #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] + pub to: Option, + + /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080", env = "TS_DEV_PROXY_LISTEN")] + pub listen: String, + + /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). + #[arg(long)] + pub allow_non_loopback: bool, + + /// Browsers to launch + configure (comma list or `all`). + #[arg(long, value_name = "LIST", env = "TS_DEV_PROXY_LAUNCH")] + pub launch: Option, + + /// Send `Host: ` upstream instead of the default ``. + #[arg(long, env = "TS_DEV_PROXY_REWRITE_HOST")] + pub rewrite_host: bool, + + /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). + #[arg(long, value_name = "USER:PASS", env = "TS_DEV_PROXY_BASIC_AUTH")] + pub basic_auth: Option, + + /// Read `USER:PASS` from a file (preferred over `--basic-auth`). + #[arg(long, value_name = "PATH")] + pub basic_auth_file: Option, + + /// Skip upstream certificate verification. + #[arg(long, env = "TS_DEV_PROXY_INSECURE")] + pub insecure: bool, + + /// Connect to upstream over plaintext HTTP. + #[arg(long)] + pub upstream_plaintext: bool, + + /// Directory holding the per-machine CA cert/key. + #[arg(long, value_name = "PATH")] + pub ca_dir: Option, + + /// Optional nested subcommand (`ts dev proxy ca …`). When absent, the proxy + /// runs with the options above. + #[command(subcommand)] + pub command: Option, +} + +/// Nested `ts dev proxy ` commands. A single `ca` wrapper gives the +/// **two-level** path `ts dev proxy ca ` required by spec §4.2 — a bare +/// `#[command(subcommand)] CaCommand` would have produced `ts dev proxy install`. +#[derive(Debug, clap::Subcommand)] +pub enum ProxySub { + /// Manage the per-machine dev CA. + Ca { + #[command(subcommand)] + action: CaCommand, + }, +} + +/// `ts dev proxy ca …` companion actions (spec §4.2). +#[derive(Debug, clap::Subcommand)] +pub enum CaCommand { + /// Print the per-machine CA certificate path. + Path, + /// Add the CA to the OS trust store (macOS login keychain). + Install, + /// Remove the CA from the OS trust store. + Uninstall, + /// Regenerate the per-machine CA (invalidates prior trust). + Regenerate, +} + +/// Runs `ts dev proxy`. +/// +/// # Errors +/// Returns [`ProxyError`] if configuration, the CA, the server, or browser +/// orchestration fails. +pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { + output::info(&format!("ts dev proxy: listen={}", args.listen)); + Ok(()) +} +``` + +Add empty `ca.rs`, `config.rs`, `rewrite.rs` with a `//!` doc line so the modules compile (later tasks fill them). + +- [ ] **Step 7: Verify it builds and runs on the native target** + +Run: +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy --help +``` +Expected: clap prints the `ts dev proxy` help including `--map`, `--rewrite-host`, `--allow-non-loopback`, and the `ca` subcommand. No build errors. + +- [ ] **Step 8: Verify the workspace gates still pass (crate stays out of wasm build)** + +Run: `cargo check --workspace` +Expected: PASS — the excluded crate is not compiled for `wasm32-wasip1`. + +- [ ] **Step 9: Commit** + +```bash +git add Cargo.toml crates/trusted-server-cli +git commit -m "Add trusted-server-cli crate skeleton with ts dev proxy CLI surface" +``` + +--- + +## Task 2: Rewrite core (rule table, matching, header outcomes) + +Pure logic, no I/O. Implements spec §8.1–§8.4. This is the most heavily unit-tested module. + +**Files:** +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs` +- Test: same file, `#[cfg(test)] mod tests` + +**Interfaces:** +- Produces: + - `struct Authority { host: String, port: u16, default_port: u16 }` with `fn host(&self) -> &str`, `fn is_default_port(&self) -> bool` (port equals the scheme default it was parsed with), `fn host_with_port(&self) -> String` (host, plus `:port` only when non-default), `fn parse(raw: &str, plaintext: bool) -> Result`. + - `struct Rule { from: String, to: Authority, preserve_host: bool, plaintext: bool }`. + - `struct RuleTable(Vec)` with `fn first_match(&self, host: &str) -> Option<&Rule>`. + - `struct RewriteOutcome { sni: String, host_header: String, orig_host: String, scheme_is_tls: bool }`. + - `fn rewrite_for(rule: &Rule) -> RewriteOutcome`. + - `enum RuleError` (`derive_more::Display` + `Error`). + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Rule { + Rule { + from: from.to_string(), + to: Authority::parse(to, plaintext).expect("should parse authority"), + preserve_host, + plaintext, + } + } + + #[test] + fn authority_defaults_port_443_for_tls() { + let a = Authority::parse("staging.example.net", false).expect("should parse"); + assert_eq!(a.host(), "staging.example.net", "should keep host"); + assert_eq!(a.port, 443, "should default to 443 for TLS"); + assert!(a.is_default_port(), "443 is default for TLS"); + assert_eq!(a.host_with_port(), "staging.example.net", "default port omitted"); + } + + #[test] + fn authority_defaults_port_80_for_plaintext() { + let a = Authority::parse("localhost", true).expect("should parse"); + assert_eq!(a.port, 80, "should default to 80 for plaintext"); + assert_eq!(a.host_with_port(), "localhost", "default port omitted"); + } + + #[test] + fn authority_keeps_non_default_port_in_host_header_only() { + let a = Authority::parse("localhost:3000", true).expect("should parse"); + assert_eq!(a.port, 3000, "should parse explicit port"); + assert!(!a.is_default_port(), "3000 is not default"); + assert_eq!(a.host(), "localhost", "SNI host must exclude port"); + assert_eq!(a.host_with_port(), "localhost:3000", "Host header includes non-default port"); + } + + #[test] + fn is_default_port_is_scheme_relative() { + // TLS authority on :80 is NOT default — :80 must appear in Host. + let tls_80 = Authority::parse("host.example.com:80", false).expect("parse"); + assert!(!tls_80.is_default_port(), "80 is not the TLS default"); + assert_eq!(tls_80.host_with_port(), "host.example.com:80", "Host keeps :80 for TLS"); + // Plaintext authority on :443 is NOT default — :443 must appear in Host. + let plain_443 = Authority::parse("host.example.com:443", true).expect("parse"); + assert!(!plain_443.is_default_port(), "443 is not the plaintext default"); + assert_eq!(plain_443.host_with_port(), "host.example.com:443", "Host keeps :443 for plaintext"); + } + + #[test] + fn matching_is_case_insensitive_and_port_stripped() { + let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", true, false)]); + let m = table.first_match("WWW.Example-Publisher.COM:443").expect("should match"); + assert_eq!(m.from, "www.example-publisher.com", "match ignores case and port"); + assert!(table.first_match("other.example.com").is_none(), "unmatched host returns None"); + } + + #[test] + fn first_match_wins() { + let table = RuleTable(vec![ + rule("a.example.com", "first.edgecompute.app", true, false), + rule("a.example.com", "second.edgecompute.app", true, false), + ]); + assert_eq!(table.first_match("a.example.com").expect("should match").to.host(), "first.edgecompute.app"); + } + + #[test] + fn rewrite_default_preserves_from_host_and_sets_sni_to_to() { + let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", true, false); + let out = rewrite_for(&r); + assert_eq!(out.sni, "to.edgecompute.app", "SNI is TO host only, no port"); + assert_eq!(out.host_header, "www.example-publisher.com", "default Host is FROM"); + assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host is FROM"); + } + + #[test] + fn rewrite_host_uses_to_authority_with_port() { + let r = rule("www.example-publisher.com", "localhost:3000", false, true); + let out = rewrite_for(&r); + assert_eq!(out.sni, "localhost", "SNI never carries a port"); + assert_eq!(out.host_header, "localhost:3000", "rewrite-host sends TO host:port"); + assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" rewrite::` +Expected: FAIL to compile (`Authority`, `RuleTable`, `rewrite_for` undefined). + +- [ ] **Step 3: Implement `rewrite.rs`** + +```rust +//! Pure request-rewriting logic: rule matching and header outcomes (spec §8). + +/// A rewrite-target authority: host plus a resolved port and its scheme default. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Authority { + /// Hostname only — never used with a port for SNI. + host: String, + /// Resolved port (explicit, or the scheme default). + pub port: u16, + /// Scheme default for this authority (443 for TLS, 80 for plaintext). + default_port: u16, +} + +/// Errors from parsing/validating rules. +#[derive(Debug, derive_more::Display)] +pub enum RuleError { + /// The `--map FROM=TO` value was not `FROM=TO`. + #[display("expected FROM=TO, got `{value}`")] + Map { value: String }, + /// The authority port was not a valid `u16`. + #[display("invalid port in `{value}`")] + Port { value: String }, + /// The authority host was empty. + #[display("empty host in `{value}`")] + EmptyHost { value: String }, +} + +impl core::error::Error for RuleError {} + +impl Authority { + /// Parses `HOST[:PORT]`, defaulting the port from `plaintext` (80) or TLS (443). + /// + /// # Errors + /// Returns [`RuleError`] on an empty host or an unparseable port. + pub fn parse(raw: &str, plaintext: bool) -> Result { + let default_port = if plaintext { 80 } else { 443 }; + let (host, port) = match raw.rsplit_once(':') { + Some((h, p)) => { + let port = p + .parse::() + .map_err(|_| RuleError::Port { value: raw.to_string() })?; + (h, port) + } + None => (raw, default_port), + }; + if host.is_empty() { + return Err(RuleError::EmptyHost { value: raw.to_string() }); + } + Ok(Self { host: host.to_ascii_lowercase(), port, default_port }) + } + + /// The bare hostname (for SNI and connection target). + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// Whether the port equals this authority's scheme default (443 TLS / 80 + /// plaintext) — so `:port` is omitted from the `Host` header. + #[must_use] + pub fn is_default_port(&self) -> bool { + self.port == self.default_port + } + + /// `host`, plus `:port` only when the port is non-default — for the `Host` header. + #[must_use] + pub fn host_with_port(&self) -> String { + if self.is_default_port() { + self.host.clone() + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +/// A single rewrite rule. +#[derive(Debug, Clone)] +pub struct Rule { + /// Production hostname to match (stored lowercase, port-stripped). + pub from: String, + /// Upstream target. + pub to: Authority, + /// When true (default), send `Host: FROM`; when false, send `Host: TO`. + pub preserve_host: bool, + /// Connect to the upstream over plaintext HTTP. + pub plaintext: bool, +} + +/// An ordered set of rules; first match wins. +#[derive(Debug, Clone, Default)] +pub struct RuleTable(pub Vec); + +impl RuleTable { + /// Returns the first rule matching `host`, comparing case-insensitively and + /// ignoring any `:port`. + #[must_use] + pub fn first_match(&self, host: &str) -> Option<&Rule> { + let needle = host + .rsplit_once(':') + .map_or(host, |(h, _)| h) + .to_ascii_lowercase(); + self.0.iter().find(|r| r.from == needle) + } +} + +/// The header/SNI decisions for a matched rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RewriteOutcome { + /// SNI to present upstream (TO host only, no port). + pub sni: String, + /// Value for the upstream `Host` header. + pub host_header: String, + /// Value for the `X-Orig-Host` header (always FROM). + pub orig_host: String, + /// Whether the upstream leg is TLS (`!plaintext`). + pub scheme_is_tls: bool, +} + +/// Computes the rewrite outcome for a matched rule (spec §8.3). +#[must_use] +pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { + let host_header = if rule.preserve_host { + rule.from.clone() + } else { + rule.to.host_with_port() + }; + RewriteOutcome { + sni: rule.to.host().to_string(), + host_header, + orig_host: rule.from.clone(), + scheme_is_tls: !rule.plaintext, + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" rewrite::` +Expected: PASS (8 tests). + +- [ ] **Step 5: Lint the crate** + +Run: `cargo clippy --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +git commit -m "Add rewrite core with rule matching and header outcomes" +``` + +--- + +## Task 3: Config resolution (args + env + rule construction) + +Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective settings. Pure logic except project-config inference, which is deferred to Task 7 (here, missing rules produce a clear error). Implements spec §10.1 precedence (flags > env > inference > defaults). Scalar env vars (`TS_DEV_PROXY_LISTEN`/`LAUNCH`/`BASIC_AUTH`/`REWRITE_HOST`/`INSECURE`) arrive via clap's `env`; `TS_DEV_PROXY_MAP` is read **explicitly** in `build_rules` (clap `env` on a `Vec` can't express the "only when no `--map`/`-f`/`-t`" rule). + +**Files:** +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/config.rs` +- Test: same file + +**Interfaces:** +- Consumes: `ProxyArgs` (Task 1), `Rule`/`RuleTable`/`Authority`/`RuleError` (Task 2). +- Produces: + - `struct ResolvedConfig { rules: RuleTable, listen: SocketAddr, allow_non_loopback: bool, launch: Vec, insecure: bool, basic_auth: Option, ca_dir: PathBuf }`. + - `struct BasicAuth { user: String, pass: String }` with `fn header_value(&self) -> String` (returns `Basic base64(user:pass)`). + - `enum Browser { Chrome, Firefox, Safari }` with `fn parse_list(raw: &str) -> Result, ConfigError>`. + - `fn resolve(args: &ProxyArgs) -> error_stack::Result`. + - `fn ca_dir(args: &ProxyArgs) -> PathBuf` — CA-dir resolution **independent of rules**, so `ca` subcommands run without a rewrite rule. + - `enum ConfigError` (`Display` + `Error`). + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn base_args() -> crate::commands::dev::proxy::ProxyArgs { + // Construct via clap so defaults match the real surface. + use clap::Parser; + #[derive(clap::Parser)] + struct W { #[command(flatten)] a: crate::commands::dev::proxy::ProxyArgs } + W::parse_from(["ts"]).a + } + + #[test] + fn single_rule_from_to_defaults_to_preserve_host() { + let mut args = base_args(); + args.from = Some("www.example-publisher.com".into()); + args.to = Some("to.edgecompute.app".into()); + let cfg = resolve(&args).expect("should resolve"); + let rule = cfg.rules.first_match("www.example-publisher.com").expect("rule present"); + assert!(rule.preserve_host, "default preserves FROM host"); + assert_eq!(rule.to.host(), "to.edgecompute.app"); + } + + #[test] + fn rewrite_host_flag_clears_preserve_host() { + let mut args = base_args(); + args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; + args.rewrite_host = true; + let cfg = resolve(&args).expect("should resolve"); + assert!(!cfg.rules.first_match("www.example-publisher.com").expect("rule").preserve_host); + } + + #[test] + fn map_value_must_be_from_equals_to() { + let mut args = base_args(); + args.map = vec!["not-a-map".into()]; + assert!(resolve(&args).is_err(), "malformed --map errors"); + } + + #[test] + fn env_map_used_only_when_no_map_or_from_to() { + // SAFETY: single-threaded test; set then remove the env var. + // Used when no --map/-f/-t: env rule applies. + unsafe { std::env::set_var("TS_DEV_PROXY_MAP", "a.example.com=b.edgecompute.app,c.example.com=d.edgecompute.app") }; + let cfg = resolve(&base_args()).expect("env map resolves"); + assert!(cfg.rules.first_match("a.example.com").is_some(), "first env rule applied"); + assert!(cfg.rules.first_match("c.example.com").is_some(), "second env rule applied"); + // Ignored when a flag rule is present (flags > env). + let mut args = base_args(); + args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; + let cfg = resolve(&args).expect("flag rule resolves"); + assert!(cfg.rules.first_match("a.example.com").is_none(), "env ignored when --map present"); + unsafe { std::env::remove_var("TS_DEV_PROXY_MAP") }; + } + + #[test] + fn non_loopback_listen_requires_flag() { + let mut args = base_args(); + args.map = vec!["a.example.com=b.edgecompute.app".into()]; + args.listen = "0.0.0.0:8080".into(); + assert!(resolve(&args).is_err(), "non-loopback without flag is rejected"); + args.allow_non_loopback = true; + assert!(resolve(&args).is_ok(), "non-loopback allowed with flag"); + } + + #[test] + fn basic_auth_header_is_base64() { + let auth = BasicAuth { user: "dev".into(), pass: "secret".into() }; + assert_eq!(auth.header_value(), "Basic ZGV2OnNlY3JldA==", "Basic base64(user:pass)"); + } + + #[test] + fn browser_list_parses_all() { + assert_eq!(Browser::parse_list("all").expect("parses"), vec![Browser::Chrome, Browser::Firefox, Browser::Safari]); + assert_eq!(Browser::parse_list("firefox,chrome").expect("parses"), vec![Browser::Firefox, Browser::Chrome]); + assert!(Browser::parse_list("netscape").is_err(), "unknown browser errors"); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" config::` +Expected: FAIL to compile. + +- [ ] **Step 3: Implement `config.rs`** + +```rust +//! Resolves `ProxyArgs` (+ env, defaults) into a concrete [`ResolvedConfig`]. + +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; + +use base64::Engine as _; +use error_stack::{Report, ResultExt as _}; + +use super::ProxyArgs; +use super::rewrite::{Authority, Rule, RuleTable}; + +/// Errors from configuration resolution. +#[derive(Debug, derive_more::Display)] +pub enum ConfigError { + /// No usable rule could be formed and none was inferable. + #[display("no rewrite rule: pass --map FROM=TO (or --to with an inferable FROM)")] + NoRule, + /// A `--map`/authority value was malformed. + #[display("invalid rule value")] + Rule, + /// `--listen` was not a valid socket address. + #[display("invalid --listen address `{value}`")] + Listen { value: String }, + /// A non-loopback listen address was given without `--allow-non-loopback`. + #[display("--listen {value} is non-loopback; pass --allow-non-loopback to allow it")] + NonLoopback { value: String }, + /// `--basic-auth`/file value was not `USER:PASS`. + #[display("--basic-auth must be USER:PASS")] + BasicAuth, + /// An unknown browser name was passed to `--launch`. + #[display("unknown browser `{value}` (expected chrome|firefox|safari|all)")] + Browser { value: String }, +} + +impl core::error::Error for ConfigError {} + +/// Basic-auth credentials to inject upstream. +#[derive(Debug, Clone)] +pub struct BasicAuth { + pub user: String, + pub pass: String, +} + +impl BasicAuth { + /// The `Authorization` header value (`Basic base64(user:pass)`). + #[must_use] + pub fn header_value(&self) -> String { + let token = base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", self.user, self.pass)); + format!("Basic {token}") + } + + fn parse(raw: &str) -> Result { + let (user, pass) = raw.split_once(':').ok_or(ConfigError::BasicAuth)?; + Ok(Self { user: user.to_string(), pass: pass.to_string() }) + } +} + +/// A browser the proxy can launch and configure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Browser { + Chrome, + Firefox, + Safari, +} + +impl Browser { + /// Parses a comma list (or `all`) of browser names. + /// + /// # Errors + /// Returns [`ConfigError::Browser`] on an unknown name. + pub fn parse_list(raw: &str) -> Result, ConfigError> { + if raw.trim() == "all" { + return Ok(vec![Self::Chrome, Self::Firefox, Self::Safari]); + } + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|name| match name { + "chrome" => Ok(Self::Chrome), + "firefox" => Ok(Self::Firefox), + "safari" => Ok(Self::Safari), + other => Err(ConfigError::Browser { value: other.to_string() }), + }) + .collect() + } +} + +/// Fully-resolved proxy configuration. +#[derive(Debug)] +pub struct ResolvedConfig { + pub rules: RuleTable, + pub listen: SocketAddr, + pub allow_non_loopback: bool, + pub launch: Vec, + pub insecure: bool, + pub basic_auth: Option, + pub ca_dir: PathBuf, +} + +/// Default CA directory (spec §7.1/§12): `$XDG_DATA_HOME/trusted-server/dev-proxy`, +/// or the platform data dir on macOS (`~/Library/Application Support/...`). +/// +/// `ProjectDirs::from(...)` is **not** used — it yields a reverse-DNS leaf +/// (`com.trusted-server.dev-proxy`), not the spec's `trusted-server/dev-proxy`. +fn default_ca_dir() -> PathBuf { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| directories::BaseDirs::new().map(|d| d.data_dir().to_path_buf())); + match base { + Some(dir) => dir.join("trusted-server").join("dev-proxy"), + None => PathBuf::from(".trusted-server-dev-proxy"), + } +} + +/// Resolves the CA directory **independently of rule resolution**, so the `ca` +/// subcommands work without a `--map`/`--to` (spec §4.2). +#[must_use] +pub fn ca_dir(args: &ProxyArgs) -> PathBuf { + args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) +} + +/// Warns about unrecognized `TS_DEV_PROXY_*` environment variables (spec §10.3). +/// `TS_DEV_PROXY_CA_DIR` is intentionally absent here — `--ca-dir` is not +/// env-driven, so setting it warns (and is ignored). +fn warn_unknown_env() { + const KNOWN: &[&str] = &[ + "TS_DEV_PROXY_LISTEN", + "TS_DEV_PROXY_MAP", + "TS_DEV_PROXY_LAUNCH", + "TS_DEV_PROXY_BASIC_AUTH", + "TS_DEV_PROXY_REWRITE_HOST", + "TS_DEV_PROXY_INSECURE", + ]; + for (name, _) in std::env::vars() { + if name.starts_with("TS_DEV_PROXY_") && !KNOWN.contains(&name.as_str()) { + crate::output::warn(&format!("ignoring unknown environment variable {name}")); + } + } +} + +fn build_rules(args: &ProxyArgs) -> Result { + let mut rules = Vec::new(); + let preserve_host = !args.rewrite_host; + for entry in &args.map { + let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; + rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + } + if let (Some(from), Some(to)) = (&args.from, &args.to) { + rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + } + // TS_DEV_PROXY_MAP is consulted only when NO --map/-f/-t was given (flags > env, + // spec §10.1/§10.3). clap's `env` on a Vec can't express that, so read it here. + if args.map.is_empty() && args.from.is_none() && args.to.is_none() { + if let Ok(env_map) = std::env::var("TS_DEV_PROXY_MAP") { + for entry in env_map.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; + rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + } + } + } + // NOTE: lone --to / lone --from + project-config inference is added in Task 7. + Ok(RuleTable(rules)) +} + +fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Result { + let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; + Ok(Rule { from: from.to_ascii_lowercase(), to, preserve_host, plaintext }) +} + +/// Resolves arguments into a [`ResolvedConfig`]. +/// +/// # Errors +/// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen +/// address, malformed credentials, or an unknown browser. +pub fn resolve(args: &ProxyArgs) -> error_stack::Result { + warn_unknown_env(); + let rules = build_rules(args).map_err(Report::from)?; + if rules.0.is_empty() { + return Err(Report::new(ConfigError::NoRule)); + } + + let listen: SocketAddr = args + .listen + .parse() + .change_context_lazy(|| ConfigError::Listen { value: args.listen.clone() })?; + let is_loopback = match listen.ip() { + IpAddr::V4(v4) => v4.is_loopback(), + IpAddr::V6(v6) => v6.is_loopback(), + }; + if !is_loopback && !args.allow_non_loopback { + return Err(Report::new(ConfigError::NonLoopback { value: args.listen.clone() })); + } + + let launch = match &args.launch { + Some(raw) => Browser::parse_list(raw).map_err(Report::from)?, + None => Vec::new(), + }; + + let basic_auth = resolve_basic_auth(args).map_err(Report::from)?; + let ca_dir = ca_dir(args); + + Ok(ResolvedConfig { + rules, + listen, + allow_non_loopback: args.allow_non_loopback, + launch, + insecure: args.insecure, + basic_auth, + ca_dir, + }) +} + +/// Credential precedence: `--basic-auth-file` > `--basic-auth` > env (the env +/// value already arrives via clap's `env` on `--basic-auth`). +fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { + if let Some(path) = &args.basic_auth_file { + let raw = std::fs::read_to_string(path).map_err(|_| ConfigError::BasicAuth)?; + return Ok(Some(BasicAuth::parse(raw.trim())?)); + } + match &args.basic_auth { + Some(raw) => Ok(Some(BasicAuth::parse(raw)?)), + None => Ok(None), + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" config::` +Expected: PASS (7 tests). + +- [ ] **Step 5: Wire `resolve` into `run` and lint** + +In `proxy/mod.rs::run`, replace the stub body with config resolution and a log line: + +```rust +pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { + let cfg = config::resolve(&args).change_context(ProxyError::Config)?; + output::info(&format!( + "ts dev proxy: listen={} rules={} launch={:?}", + cfg.listen, + cfg.rules.0.len(), + cfg.launch, + )); + Ok(()) +} +``` + +Add `use error_stack::ResultExt as _;` at the top of `proxy/mod.rs`. Then run: +`cargo clippy --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-cli/src/commands/dev/proxy +git commit -m "Resolve proxy args and env into a concrete rule table and settings" +``` + +--- + +## Task 4: Local Certificate Authority (load-or-generate, mint+cache leaves) + +Implements spec §7. Testable against a temp `--ca-dir`. + +**Files:** +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/ca.rs` +- Test: same file + +**Interfaces:** +- Produces: + - `struct CertAuthority` with: + - `fn load_or_generate(ca_dir: &Path) -> error_stack::Result` — reads `ca-cert.pem`/`ca-key.pem` or generates and persists them (dir `0700`, key `0600`); logs a one-time trust hint on generation. + - `fn server_config(&self, host: &str) -> error_stack::Result, CaError>` — returns a cached-or-minted leaf `ServerConfig` (ALPN `http/1.1`), keyed by host. + - `fn cert_path(ca_dir: &Path) -> PathBuf`. + - `const CA_COMMON_NAME: &str = "Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION";` + - `enum CaError` (`Display` + `Error`). + +> **Crate-API note:** rcgen 0.13 exposes `KeyPair`, `CertificateParams`, `Certificate`, and `Issuer`. Method names (`self_signed`, `signed_by`, `serialize_pem`) have shifted across 0.13.x — verify against `cargo doc -p rcgen` for the pinned version and adjust the calls below if needed; the *shape* (generate CA → persist PEM → load issuer → mint leaf with SAN) is stable. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt as _; + + #[test] + fn generates_then_reloads_with_0600_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let ca1 = CertAuthority::load_or_generate(dir.path()).expect("should generate"); + let key_path = dir.path().join("ca-key.pem"); + assert!(key_path.exists(), "key persisted"); + let mode = std::fs::metadata(&key_path).expect("meta").permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "key file is 0600"); + + // Second run reloads the same CA cert bytes (no regeneration). + let cert_before = std::fs::read(dir.path().join("ca-cert.pem")).expect("read"); + let _ca2 = CertAuthority::load_or_generate(dir.path()).expect("should reload"); + let cert_after = std::fs::read(dir.path().join("ca-cert.pem")).expect("read"); + assert_eq!(cert_before, cert_after, "reload does not rewrite the CA"); + drop(ca1); + } + + #[test] + fn leaf_cache_returns_same_arc_for_same_host() { + let dir = tempfile::tempdir().expect("tempdir"); + let ca = CertAuthority::load_or_generate(dir.path()).expect("generate"); + let a = ca.server_config("www.example-publisher.com").expect("mint"); + let b = ca.server_config("www.example-publisher.com").expect("cached"); + assert!(Arc::ptr_eq(&a, &b), "same host returns the cached Arc"); + let c = ca.server_config("other.example.com").expect("mint other"); + assert!(!Arc::ptr_eq(&a, &c), "different host mints a new config"); + } + + #[test] + fn mints_leaf_for_ip_literal_host() { + // An IP-literal host must mint successfully (IP-type SAN, not DNS) — spec §8.3. + let dir = tempfile::tempdir().expect("tempdir"); + let ca = CertAuthority::load_or_generate(dir.path()).expect("generate"); + assert!(ca.server_config("127.0.0.1").is_ok(), "IP-literal host mints a leaf"); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" ca::` +Expected: FAIL to compile. + +- [ ] **Step 3: Implement `ca.rs`** + +```rust +//! Per-machine local CA: load-or-generate, mint and cache per-host leaves (spec §7). + +use std::collections::HashMap; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use error_stack::{Report, ResultExt as _}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, +}; +use rustls::ServerConfig; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; + +/// Distinguished CA common name (spec §12). +pub const CA_COMMON_NAME: &str = "Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION"; + +const CA_CERT_FILE: &str = "ca-cert.pem"; +const CA_KEY_FILE: &str = "ca-key.pem"; +const LEAF_VALIDITY_DAYS: i64 = 90; + +/// Errors from the certificate authority. +#[derive(Debug, derive_more::Display)] +pub enum CaError { + /// The CA directory could not be created or secured. + #[display("cannot prepare CA directory")] + Dir, + /// Reading/writing a CA PEM file failed. + #[display("CA file I/O failed")] + Io, + /// Certificate generation/signing failed. + #[display("certificate generation failed")] + Generate, + /// Building the rustls server config failed. + #[display("rustls server config failed")] + Rustls, +} + +impl core::error::Error for CaError {} + +/// Loaded CA material plus a per-host leaf cache. +pub struct CertAuthority { + issuer: Issuer<'static, KeyPair>, + ca_cert_der: CertificateDer<'static>, + leaves: Mutex>>, +} + +impl CertAuthority { + /// Path to the CA certificate under `ca_dir`. + #[must_use] + pub fn cert_path(ca_dir: &Path) -> PathBuf { + ca_dir.join(CA_CERT_FILE) + } + + /// Loads the CA from `ca_dir`, generating and persisting it on first run. + /// + /// # Errors + /// Returns [`CaError`] on directory, I/O, or generation failures. + pub fn load_or_generate(ca_dir: &Path) -> error_stack::Result { + let cert_path = ca_dir.join(CA_CERT_FILE); + let key_path = ca_dir.join(CA_KEY_FILE); + + let (cert_pem, key_pem) = if cert_path.exists() && key_path.exists() { + ( + fs::read_to_string(&cert_path).change_context(CaError::Io)?, + fs::read_to_string(&key_path).change_context(CaError::Io)?, + ) + } else { + let (cert_pem, key_pem) = Self::generate_pems()?; + Self::persist(ca_dir, &cert_path, &key_path, &cert_pem, &key_pem)?; + log::info!( + "generated dev CA at {} — run `ts dev proxy ca install` to trust it", + cert_path.display() + ); + (cert_pem, key_pem) + }; + + let key = KeyPair::from_pem(&key_pem).change_context(CaError::Generate)?; + let params = + CertificateParams::from_ca_cert_pem(&cert_pem).change_context(CaError::Generate)?; + let ca_cert_der = pem_to_cert_der(&cert_pem)?; + let issuer = Issuer::new(params, key); + + Ok(Self { issuer, ca_cert_der, leaves: Mutex::new(HashMap::new()) }) + } + + /// Returns a cached or freshly minted leaf [`ServerConfig`] for `host`. + /// + /// # Errors + /// Returns [`CaError`] if minting or rustls config construction fails. + pub fn server_config(&self, host: &str) -> error_stack::Result, CaError> { + // Fast path: return a cached config without holding the lock during minting. + { + let cache = self.leaves.lock().expect("leaf cache lock should not be poisoned"); + if let Some(existing) = cache.get(host) { + return Ok(Arc::clone(existing)); + } + } + let config = Arc::new(self.mint(host)?); + let mut cache = self.leaves.lock().expect("leaf cache lock should not be poisoned"); + // Double-check: another task may have minted concurrently. + let entry = cache.entry(host.to_string()).or_insert(config); + Ok(Arc::clone(entry)) + } + + fn mint(&self, host: &str) -> error_stack::Result { + let leaf_key = KeyPair::generate().change_context(CaError::Generate)?; + // Build the SAN explicitly so an IP-literal host gets an IP-type SAN, + // not a DNS SAN (spec §8.3). DNS names use an Ia5String. + let san = match host.parse::() { + Ok(ip) => SanType::IpAddress(ip), + Err(_) => SanType::DnsName(host.try_into().change_context(CaError::Generate)?), + }; + let mut params = + CertificateParams::new(Vec::::new()).change_context(CaError::Generate)?; + params.subject_alt_names = vec![san]; + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(LEAF_VALIDITY_DAYS); + let leaf = params.signed_by(&leaf_key, &self.issuer).change_context(CaError::Generate)?; + + let chain = vec![leaf.der().clone(), self.ca_cert_der.clone()]; + let key_der = PrivateKeyDer::try_from(leaf_key.serialize_der()) + .map_err(|_| Report::new(CaError::Rustls))?; + + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(chain, key_der) + .change_context(CaError::Rustls)?; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(config) + } + + fn generate_pems() -> error_stack::Result<(String, String), CaError> { + let key = KeyPair::generate().change_context(CaError::Generate)?; + let mut params = CertificateParams::new(Vec::new()).change_context(CaError::Generate)?; + params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + // ~10 years from generation (spec §7.1); rotate via `ca regenerate`. + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(3650); + let cert = params.self_signed(&key).change_context(CaError::Generate)?; + Ok((cert.pem(), key.serialize_pem())) + } + + fn persist( + ca_dir: &Path, + cert_path: &Path, + key_path: &Path, + cert_pem: &str, + key_pem: &str, + ) -> error_stack::Result<(), CaError> { + fs::create_dir_all(ca_dir).change_context(CaError::Dir)?; + fs::set_permissions(ca_dir, fs::Permissions::from_mode(0o700)).change_context(CaError::Dir)?; + fs::write(cert_path, cert_pem).change_context(CaError::Io)?; + fs::write(key_path, key_pem).change_context(CaError::Io)?; + fs::set_permissions(key_path, fs::Permissions::from_mode(0o600)).change_context(CaError::Io)?; + Ok(()) + } +} + +fn pem_to_cert_der(cert_pem: &str) -> error_stack::Result, CaError> { + let mut reader = std::io::BufReader::new(cert_pem.as_bytes()); + let der = rustls_pemfile::certs(&mut reader) + .next() + .ok_or_else(|| Report::new(CaError::Io))? + .change_context(CaError::Io)?; + Ok(der) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" ca::` +Expected: PASS (3 tests). If rcgen method names differ for the pinned 0.13.x, adjust per the crate-API note, then re-run. + +- [ ] **Step 5: Lint and commit** + +Run clippy (as in Task 3 step 5), then: +```bash +git add crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +git commit -m "Add per-machine local CA with leaf minting and caching" +``` + +--- + +## Task 5: CONNECT/MITM proxy server (with blind tunnel + local routes) + +Implements spec §5. This is the I/O core; it is exercised by a native integration test rather than pure unit tests. + +**Files:** +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/server.rs` +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/mod.rs` (call `server::bind`/`serve_on`) +- Test: `crates/trusted-server-cli/tests/proxy_e2e.rs` (+ `tests/support/mod.rs`) + +**Interfaces:** +- Consumes: `ResolvedConfig` (Task 3), `CertAuthority` (Task 4), `RuleTable`/`rewrite_for` (Task 2). +- Produces (both `pub`, reachable from integration tests via the `trusted_server_cli` lib target): + - `async fn bind(addr: SocketAddr) -> std::io::Result` — binds the listen socket so the port is open (connections queue) **before** browsers are launched (Task 6). + - `async fn serve_on(listener: TcpListener, cfg: Arc, ca: Arc, pac: Arc) -> error_stack::Result<(), ProxyError>` — accept loop; serves until the task is dropped. Splitting bind from serve is what makes the launch ordering in Task 6 safe. + +**Behavior contract (from spec §5, §8, §11):** +1. Read the first request line. If it is `CONNECT host:port`: match `host` (authority) against `cfg.rules`. + - **Match:** reply `200`, select `ca.server_config(host)`, TLS-accept, then loop reading HTTP/1.1 requests; for each apply `rewrite_for`, open the upstream (TLS unless `plaintext`; skip cert verify if `insecure`), forward, stream the response back. Close on `Upgrade:`. + - **No match, loopback bind:** connect upstream **first**, reply `200` only on success (else `502`), then copy bytes both directions. + - **No match, non-loopback bind:** reply `403`, close. +2. If it is an origin-form `GET /proxy.pac` (a local route): serve `pac` with `Content-Type: application/x-ns-proxy-autoconfig`. +3. Any other absolute-form plain-HTTP request: blind-forward (loopback) or `403` (non-loopback). + +- [ ] **Step 1: Write the failing integration test** + +Create `crates/trusted-server-cli/tests/proxy_e2e.rs`. It starts a local TLS "upstream" with a self-signed cert, runs the proxy with `--insecure` in a background task, and drives it with a proxy-aware client. + +```rust +//! End-to-end proxy test: a matched host is MITM'd, rewritten, and forwarded. +//! Run with: cargo test --manifest-path crates/trusted-server-cli/Cargo.toml \ +//! --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e + +use std::sync::Arc; + +// Helper: spin a local HTTPS server that echoes the Host and X-Orig-Host it saw. +// (Implementation uses tokio + tokio-rustls + a rcgen self-signed cert for +// "upstream.localhost"; see fixtures below.) + +#[tokio::test] +async fn matched_host_is_rewritten_and_forwarded() { + // Arrange: start echo upstream; capture its addr. + let upstream = start_echo_upstream().await; + + // Build a ResolvedConfig mapping FROM=www.example-publisher.com to the + // upstream addr, preserve_host = true (default), insecure = true. + let cfg = test_config(&upstream.addr); + // CA + helpers come from the lib target (Task 1 added `src/lib.rs`): + // use trusted_server_cli::commands::dev::proxy::{ca, config, server}; + let ca = Arc::new(support::dev_ca()); + // Act: serve in the background, then issue a request through the proxy. + // The client CONNECTs to www.example-publisher.com:443 via the proxy and + // trusts the dev CA; SNI/Host are set by the proxy. + let response = drive_request_through_proxy(cfg, ca).await; + + // Assert: upstream saw Host = FROM and X-Orig-Host = FROM. + assert_eq!(response.seen_host, "www.example-publisher.com", "Host preserved as FROM"); + assert_eq!(response.seen_orig_host, "www.example-publisher.com", "X-Orig-Host is FROM"); + assert_eq!(response.status, 200, "response streamed back"); +} + +#[tokio::test] +async fn unmatched_host_is_blind_tunneled_on_loopback() { + // Arrange: upstream with self-signed CN "upstream.localhost"; NO rule for it; loopback. + let upstream = start_echo_upstream().await; + let cfg = test_config_without_rules(); + let ca = Arc::new(support::dev_ca()); + // Act: CONNECT to upstream.localhost through the proxy and capture the leaf + // certificate the client received during the TLS handshake. + let observed = support::connect_through_proxy_capturing_cert( + cfg, ca, &upstream.addr, "upstream.localhost", + ).await; + // Assert: the handshake terminated at the UPSTREAM cert, not the dev CA — + // i.e. the proxy blind-tunneled and did not MITM. + assert_eq!(observed.issuer_common_name, "upstream.localhost", "blind tunnel presents the upstream cert"); + assert_ne!(observed.issuer_common_name, ca::CA_COMMON_NAME, "proxy did not MITM an unmatched host"); +} + +#[tokio::test] +async fn basic_auth_injected_when_absent_clears_401() { + // Arrange: upstream returns 401 unless Authorization is present; cfg injects basic auth. + let upstream = start_gated_upstream().await; + let mut cfg = test_config(&upstream.addr); + cfg.basic_auth = Some(config::BasicAuth { user: "dev".into(), pass: "secret".into() }); + let ca = Arc::new(support::dev_ca()); + // Act + Assert: the injected Authorization clears the gate. + let response = drive_request_through_proxy(cfg, ca).await; + assert_eq!(response.status, 200, "injected Basic auth clears the 401"); +} + +#[tokio::test] +async fn keep_alive_serves_multiple_sequential_requests() { + // Spec §5/§14: one tunnel carries many sequential keep-alive requests. + let upstream = start_echo_upstream().await; + let cfg = test_config(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + // Act: two requests over ONE MITM tunnel (Connection: keep-alive). + let responses = support::drive_sequential_requests(cfg, ca, &["/one", "/two"]).await; + // Assert: both answered in order on the same tunnel. + assert_eq!(responses.len(), 2, "both requests answered"); + assert!(responses.iter().all(|r| r.status == 200), "each request gets 200"); + assert_eq!(responses[0].path, "/one", "first request"); + assert_eq!(responses[1].path, "/two", "second request reused the tunnel"); +} +``` + +> The test-support module `tests/support/mod.rs` provides: `dev_ca()` (a `CertAuthority` in a `tempfile::tempdir()`); `start_echo_upstream()` (HTTPS server, self-signed CN `upstream.localhost`, echoes the `Host`/`X-Orig-Host`/path it saw); `start_gated_upstream()` (returns `401` unless `Authorization` present); `test_config(addr)` / `test_config_without_rules()`; `drive_request_through_proxy(cfg, ca)`; `connect_through_proxy_capturing_cert(cfg, ca, addr, sni)` (returns the leaf the client saw); and `drive_sequential_requests(cfg, ca, paths)` (multiple requests over one keep-alive tunnel). Build them on `tokio` + `tokio-rustls` + rcgen + a `hyper` client that CONNECTs through `cfg.listen`. They import the crate under test as `use trusted_server_cli::commands::dev::proxy::{ca, config, server};` — possible only because Task 1 made the crate a lib + bin. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e` +Expected: FAIL to compile (`bind`/`serve_on` and helpers undefined). + +- [ ] **Step 3: Implement `server.rs`** + +Implement `bind` + `serve_on` per the behavior contract. Core skeleton (fill in the forwarding bodies): + +```rust +//! Accept loop, CONNECT dispatch, blind tunnel, MITM, and local routes (spec §5). + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use error_stack::{Report, ResultExt as _}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; + +use super::ca::CertAuthority; +use super::config::ResolvedConfig; +use super::rewrite::rewrite_for; +use super::ProxyError; + +/// Binds the listen socket. Separate from [`serve_on`] so the caller can open +/// the port (queueing connections) before launching browsers (spec §9, Task 6). +/// +/// # Errors +/// Returns the bind I/O error if the address is unavailable. +pub async fn bind(addr: SocketAddr) -> std::io::Result { + TcpListener::bind(addr).await +} + +/// Accepts and serves connections on `listener` until the task is dropped. +/// +/// # Errors +/// Returns [`ProxyError::Server`] only on an unrecoverable accept-loop failure. +pub async fn serve_on( + listener: TcpListener, + cfg: Arc, + ca: Arc, + pac: Arc, +) -> error_stack::Result<(), ProxyError> { + let is_loopback = matches!(cfg.listen.ip(), IpAddr::V4(v) if v.is_loopback()) + || matches!(cfg.listen.ip(), IpAddr::V6(v) if v.is_loopback()); + log::info!("listening on {}", cfg.listen); + loop { + let (client, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(err) => { + log::warn!("accept failed: {err}"); + continue; + } + }; + let cfg = Arc::clone(&cfg); + let ca = Arc::clone(&ca); + let pac = Arc::clone(&pac); + tokio::spawn(async move { + if let Err(err) = handle_connection(client, is_loopback, &cfg, &ca, &pac).await { + log::debug!("connection from {peer} ended: {err:?}"); + } + }); + } +} + +async fn handle_connection( + mut client: TcpStream, + is_loopback: bool, + cfg: &ResolvedConfig, + ca: &CertAuthority, + pac: &str, +) -> error_stack::Result<(), ProxyError> { + let head = read_request_head(&mut client).await?; + if let Some(authority) = head.connect_authority() { + return handle_connect(client, authority, is_loopback, cfg, ca).await; + } + if head.is_local_pac_route() { + return serve_pac(&mut client, pac).await; + } + // Stray absolute-form plain HTTP. + if is_loopback { + blind_forward_http(client, &head).await + } else { + respond_status(&mut client, 403, "Forbidden").await + } +} + +// read_request_head: parse method/target/Host without consuming the body. +// connect_authority(): Some(host) if method == CONNECT. +// handle_connect(): match rules; on match reply 200 + MITM via ca.server_config; +// on no-match loopback connect-first-then-200 blind tunnel; non-loopback -> 403. +// For each MITM request: let out = rewrite_for(rule); set Host/X-Orig-Host/SNI; +// inject cfg.basic_auth if Authorization absent; open upstream (TLS unless +// plaintext; skip verify if cfg.insecure); stream response; close on Upgrade. +// Redact Authorization/Cookie in any logging. +``` + +Implement the helper bodies (`read_request_head`, `handle_connect`, `mitm_loop`, `blind_tunnel`, `serve_pac`, `respond_status`, upstream connect with optional `insecure` `ClientConfig`). Use `tokio::io::copy_bidirectional` for the blind tunnel. For the non-loopback path, `handle_connect` must return `403` for unmatched authorities *before* any upstream connect. + +- [ ] **Step 4: Wire `bind` + `serve_on` into `run` (interim — no browsers yet)** + +Replace `run` in `proxy/mod.rs` with a running proxy. Task 6 finalizes `run` (adds `ca` subcommand dispatch and browser launch); this interim version just binds and serves: + +```rust +pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { + let cfg = Arc::new(config::resolve(&args).change_context(ProxyError::Config)?); + let ca = Arc::new(ca::CertAuthority::load_or_generate(&cfg.ca_dir).change_context(ProxyError::CertAuthority)?); + // PAC generator arrives in Task 6; stub it locally for now. + let pac: Arc = Arc::from("function FindProxyForURL(u,h){return \"DIRECT\";}"); + let runtime = tokio::runtime::Runtime::new().change_context(ProxyError::Server)?; + runtime.block_on(async move { + let listener = server::bind(cfg.listen).await.change_context(ProxyError::Server)?; + output::info(&format!("ts dev proxy listening on {}", cfg.listen)); + server::serve_on(listener, cfg, ca, pac).await + }) +} +``` + +(`use std::sync::Arc;` at the top of `proxy/mod.rs`.) + +- [ ] **Step 5: Run the integration test to verify it passes** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e` +Expected: PASS (4 tests) — matched host rewritten with `Host=FROM` + `X-Orig-Host`; unmatched host blind-tunneled (upstream cert, not dev CA); basic-auth clears `401`; keep-alive serves two sequential requests over one tunnel. + +- [ ] **Step 6: Lint and commit** + +```bash +git add crates/trusted-server-cli/src/commands/dev/proxy/server.rs crates/trusted-server-cli/src/commands/dev/proxy/mod.rs crates/trusted-server-cli/tests +git commit -m "Add CONNECT MITM proxy server with blind tunnel and local PAC route" +``` + +--- + +## Task 6: Browser orchestration, PAC generation, and `ca` subcommands + +Implements spec §9 and §4.2/§7.3. + +**Files:** +- Create: `crates/trusted-server-cli/src/commands/dev/proxy/browser.rs` +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/mod.rs` (finalize `run`: `ca` dispatch before resolve + browser launch after bind) +- Test: `browser.rs` (`#[cfg(test)]`) for PAC generation (pure) + +**Interfaces:** +- Consumes: `RuleTable` (Task 2), `Browser` (Task 3), `CertAuthority::cert_path` (Task 4). +- Produces: + - `fn generate_pac(rules: &RuleTable, listen: SocketAddr) -> String`. + - `fn launch(browsers: &[Browser], cfg: &ResolvedConfig) -> error_stack::Result<(), ProxyError>`. + - `fn ca_install(cert_path: &Path)`, `fn ca_uninstall()`, `fn ca_path(cert_path: &Path)`, `fn ca_regenerate(ca_dir: &Path)` — invoked from `run` for the `ca` subcommand. + +- [ ] **Step 1: Write the failing PAC test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; + + #[test] + fn pac_proxies_only_https_for_from_hosts() { + let rules = RuleTable(vec![Rule { + from: "www.example-publisher.com".into(), + to: Authority::parse("to.edgecompute.app", false).expect("auth"), + preserve_host: true, + plaintext: false, + }]); + let pac = generate_pac(&rules, "127.0.0.1:8080".parse().expect("addr")); + assert!(pac.contains("https:"), "PAC guards on https scheme"); + assert!(pac.contains("www.example-publisher.com"), "PAC lists the FROM host"); + assert!(pac.contains("PROXY 127.0.0.1:8080"), "PAC points at the listen addr"); + assert!(pac.contains("return \"DIRECT\""), "everything else is direct"); + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" browser::` +Expected: FAIL to compile. + +- [ ] **Step 3: Implement `browser.rs`** + +```rust +//! Browser launch/config, PAC generation, and CA trust commands (spec §9, §7.3). + +use std::net::SocketAddr; +use std::path::Path; +use std::process::Command; + +use error_stack::ResultExt as _; + +use super::config::{Browser, ResolvedConfig}; +use super::rewrite::RuleTable; +use super::ProxyError; +use crate::output; + +/// Generates a PAC that proxies only `https://` requests for matched FROM hosts. +#[must_use] +pub fn generate_pac(rules: &RuleTable, listen: SocketAddr) -> String { + let mut checks = String::new(); + for rule in &rules.0 { + checks.push_str(&format!( + " if (url.substring(0,6) == \"https:\" && host == \"{}\") return \"PROXY {}\";\n", + rule.from, listen + )); + } + format!("function FindProxyForURL(url, host) {{\n{checks} return \"DIRECT\";\n}}\n") +} + +/// Adds the CA to the macOS login keychain (spec §7.3). +pub fn ca_install(cert_path: &Path) { + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").unwrap_or_default(); + let keychain = format!("{home}/Library/Keychains/login.keychain-db"); + let status = Command::new("security") + .args(["add-trusted-cert", "-r", "trustRoot", "-k", &keychain]) + .arg(cert_path) + .status(); + match status { + Ok(s) if s.success() => output::info("CA added to login keychain"), + _ => output::warn(&format!( + "could not auto-install; run: security add-trusted-cert -r trustRoot -k {keychain} {}", + cert_path.display() + )), + } + } + #[cfg(not(target_os = "macos"))] + output::info(&format!("trust this CA manually: {}", cert_path.display())); +} + +/// Removes the CA from the macOS keychain (spec §7.3). +pub fn ca_uninstall() { + #[cfg(target_os = "macos")] + { + let _ = Command::new("security") + .args(["delete-certificate", "-c", super::ca::CA_COMMON_NAME]) + .status(); + output::info("CA removed from keychain (if present)"); + } + #[cfg(not(target_os = "macos"))] + output::info("remove the CA from your OS trust store manually"); +} + +/// Launches and configures each requested browser against the proxy (spec §9). +/// +/// # Errors +/// Returns [`ProxyError::Browser`] only on unrecoverable setup failures; a +/// single browser that cannot be configured logs manual steps and is skipped. +pub fn launch(browsers: &[Browser], cfg: &ResolvedConfig) -> error_stack::Result<(), ProxyError> { + for browser in browsers { + match browser { + Browser::Chrome => launch_chrome(cfg), + Browser::Firefox => launch_firefox(cfg), + Browser::Safari => launch_safari(cfg), + } + } + Ok(()) +} +``` + +Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping → `networksetup -setautoproxyurl http://127.0.0.1:/proxy.pac`; persist prior state to a file and restore on exit + on next run). Each helper logs manual steps on failure and continues. + +- [ ] **Step 4: Run the PAC test to verify it passes** + +Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" browser::` +Expected: PASS. + +- [ ] **Step 5: Finalize `run` — `ca` dispatch before resolution, then bind → spawn → launch → await** + +Replace the interim `run` (Task 5 step 4) with the final version. Two ordering fixes are essential: +- **`ca` subcommands must be handled *before* `config::resolve`** — `resolve` errors when no rewrite rule exists, but `ca path/install/uninstall/regenerate` are standalone (spec §4.2). Use `config::ca_dir(&args)`, which needs no rule. +- **Bind the listener *before* launching browsers**, and keep the runtime alive by awaiting the server. Launching before the socket is bound would point browsers at a dead port; blocking on `serve_on` before launching would never reach the launch. + +```rust +pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { + // CA subcommands need only the CA directory — handle them before rule resolution. + if let Some(ProxySub::Ca { action }) = &args.command { + let ca_dir = config::ca_dir(&args); + let cert_path = ca::CertAuthority::cert_path(&ca_dir); + match action { + CaCommand::Path => { + // Ensure the CA exists so the printed path points at a real file. + ca::CertAuthority::load_or_generate(&ca_dir).change_context(ProxyError::CertAuthority)?; + output::info(&cert_path.display().to_string()); + } + CaCommand::Install => { + // A fresh machine has no CA yet — generate before trusting it. + ca::CertAuthority::load_or_generate(&ca_dir).change_context(ProxyError::CertAuthority)?; + browser::ca_install(&cert_path); + } + CaCommand::Uninstall => browser::ca_uninstall(), + CaCommand::Regenerate => { + std::fs::remove_file(&cert_path).ok(); + std::fs::remove_file(ca_dir.join("ca-key.pem")).ok(); + ca::CertAuthority::load_or_generate(&ca_dir).change_context(ProxyError::CertAuthority)?; + output::info("regenerated CA — re-run `ca install` to trust it"); + } + } + return Ok(()); + } + + let cfg = Arc::new(config::resolve(&args).change_context(ProxyError::Config)?); + let ca = Arc::new(ca::CertAuthority::load_or_generate(&cfg.ca_dir).change_context(ProxyError::CertAuthority)?); + let pac: Arc = Arc::from(browser::generate_pac(&cfg.rules, cfg.listen).as_str()); + + let runtime = tokio::runtime::Runtime::new().change_context(ProxyError::Server)?; + runtime.block_on(async move { + // Bind first: the port is open and connections queue before we launch browsers. + let listener = server::bind(cfg.listen).await.change_context(ProxyError::Server)?; + output::info(&format!("ts dev proxy listening on {}", cfg.listen)); + let server = tokio::spawn(server::serve_on(listener, Arc::clone(&cfg), Arc::clone(&ca), Arc::clone(&pac))); + + if !cfg.launch.is_empty() { + // Browser launch spawns processes (blocking) — keep it off the reactor thread. + let launch_cfg = Arc::clone(&cfg); + tokio::task::spawn_blocking(move || browser::launch(&launch_cfg.launch, &launch_cfg)) + .await + .change_context(ProxyError::Browser)??; + } + // Keep the runtime alive: serve until the accept loop ends (Ctrl-C / drop). + server.await.change_context(ProxyError::Server)? + }) +} +``` + +- [ ] **Step 6: Lint and commit** + +```bash +git add crates/trusted-server-cli/src/commands/dev/proxy +git commit -m "Add browser orchestration, PAC generation, and ca trust subcommands" +``` + +--- + +## Task 7: Project-config inference (zero-arg ergonomics) + +Implements spec §10.2. Lets `ts dev proxy` (and lone `--to`/`--from`) resolve a rule from `trusted-server.toml`. + +**Files:** +- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/config.rs` +- Test: same file + +**Interfaces:** +- Produces: `fn infer_from_host() -> Option` (reads `publisher.domain` from `trusted-server.toml` in the CWD), `fn infer_to_host() -> Option` (reads `[dev_proxy].upstream`). `build_rules` is extended so a lone `--to` pairs with the inferred FROM and zero-arg pairs both. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn lone_to_pairs_with_inferred_from(/* uses a temp CWD with trusted-server.toml */) { + // Arrange: write trusted-server.toml with [publisher] domain = "www.example-publisher.com" + // and run resolve() with only --to set. + // Assert: a single rule with from = the inferred publisher domain. +} + +#[test] +fn zero_arg_requires_dev_proxy_upstream() { + // Arrange: trusted-server.toml with publisher.domain but no [dev_proxy].upstream. + // Assert: resolve() errors with NoRule and the message names --to/--map. +} +``` + +> Implement these with a helper that writes a `trusted-server.toml` into a `tempfile::tempdir()` and parses from an explicit path (add `fn resolve_in(args, project_dir)` so tests don't depend on the process CWD). Use a minimal hand-rolled TOML read (the two keys) or add a `toml` dev-dependency; keep the parser scoped to `publisher.domain` and `dev_proxy.upstream`. + +- [ ] **Step 2: Run to verify they fail.** Run the `config::` tests; expected FAIL. + +- [ ] **Step 3: Implement inference** — add `infer_from_host`/`infer_to_host` and extend `build_rules`: if `--map`/`-f`/`-t` produced no rule, try `(--from or inferred FROM, --to or inferred TO)`; if either side is missing, return `ConfigError::NoRule`. List candidates when multiple publishers exist. + +- [ ] **Step 4: Run to verify they pass.** Expected PASS. + +- [ ] **Step 5: Lint and commit** + +```bash +git add crates/trusted-server-cli/src/commands/dev/proxy/config.rs +git commit -m "Infer dev-proxy rule from trusted-server.toml for zero-arg use" +``` + +--- + +## Task 8: User-facing documentation + +Implements spec §15 step 8. + +**Files:** +- Create: `docs/guide/ts-dev-proxy.md` + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Write the guide** — cover: what the proxy does and why (SNI swap, MITM); install/build (`cargo … --manifest-path … --target …`); first-run CA generation + `ca install` per browser (Chrome/Firefox NSS/Safari keychain); the default `Host = FROM` behavior and when to use `--rewrite-host`; `--allow-non-loopback` safety; the §13 troubleshooting table (unknown domain, `401`, `503`, untrusted CA, addr in use); and the per-machine CA security note + `ca uninstall`. Use only example domains. + +- [ ] **Step 2: Format docs** + +Run: `cd docs && npm run format` +Expected: PASS, no diff on re-run. + +- [ ] **Step 3: Commit** + +```bash +git add docs/guide/ts-dev-proxy.md +git commit -m "Document ts dev proxy setup, trust, and troubleshooting" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §3 decisions → Tasks 1–6 (crate exclude, default Host=FROM, blind tunnel, non-loopback). ✓ +- §4 CLI surface + §4.2 ca subcommands → Task 1 (args), Task 6 (`ca`). ✓ +- §5 architecture/flow (200-ordering, blind tunnel, keep-alive loop, Upgrade close, local routes) → Task 5. ✓ +- §6 module structure / workspace exclude / native target → Task 1. ✓ +- §7 CA (load-or-generate, 0600/0700, mint+cache, install/uninstall) → Tasks 4, 6. ✓ +- §8 rewrite (Authority/RuleTable/matching/header outcomes/port-vs-SNI) → Task 2. ✓ +- §9 browser orchestration (HTTPS-only Chrome/Firefox, Safari PAC + active-service) → Task 6. ✓ +- §10 config (precedence, env, inference) → Tasks 3, 7. ✓ +- §11 security (non-loopback guard, redaction, credential input, blind-tunnel privacy) → Tasks 3, 5. ✓ +- §12 constants → encoded in Tasks 2/4 (ports, ALPN, validity, CN). ✓ +- §13 error handling → Task 5 status mapping + Task 8 troubleshooting table. ✓ +- §14 testing (rewrite unit, ca unit, native integration incl. blind-tunnel, basic-auth, and keep-alive/sequential-request coverage) → Tasks 2, 4, 5. ✓ +- §16 out-of-scope (HTTP/2, WebSocket, plain-HTTP rewriting) → respected (Upgrade closed; stray HTTP blind-forwarded only). ✓ + +**Placeholder scan:** I/O-bound helper bodies in Tasks 5–7 (forwarding loops, browser launch, inference TOML read) are described by an explicit behavior contract with signatures rather than full literal bodies, because their exact code depends on the pinned tokio/hyper/rcgen APIs; the pure-logic tasks (2, 3, 6-PAC) carry complete code and tests. Flagged the rcgen API drift explicitly in Task 4. No `TODO`/`TBD` left in committed code. + +**Type consistency:** `Authority::{host,host_with_port,is_default_port}` (now scheme-relative via the stored `default_port`), `RuleTable::first_match`, `rewrite_for → RewriteOutcome{sni,host_header,orig_host,scheme_is_tls}`, `ResolvedConfig`, `config::ca_dir`, `CertAuthority::{load_or_generate,server_config,cert_path}`, `server::{bind,serve_on}`, `Browser::parse_list`, `generate_pac` are used consistently across tasks. + +**Review-round fixes (2026-06-22):** (1) crate is a **lib + bin** so integration tests reach internal modules; (2) `ca` subcommands resolve via `config::ca_dir` *before* rule resolution; (3) `run` binds the listener, spawns `serve_on`, launches browsers via `spawn_blocking`, then awaits the server — correct ordering and the runtime stays alive; (4) `Authority` stores its scheme `default_port` so `:80`/`:443` are kept/omitted per scheme; (5) `TS_DEV_PROXY_MAP` is parsed explicitly in `build_rules` with flags-over-env precedence. + +**Second review round (2026-06-22):** (6) `ca` is a **nested** subcommand (`ProxySub::Ca { action }`) so the path is `ts dev proxy ca `, not `ts dev proxy `; (7) `ca path`/`ca install` call `load_or_generate` first so a fresh machine works before any proxy run; (8) `default_ca_dir` builds `…/trusted-server/dev-proxy` from `XDG_DATA_HOME`/`BaseDirs` (not `ProjectDirs`, which yields a reverse-DNS leaf); (9) CA validity is ~10 years and the leaf ≤ 90 days, both `now`-relative via `time`; (10) the blind-tunnel and basic-auth E2E tests have real assertions, plus a new keep-alive/sequential-request test. + +**Third review round (2026-06-22):** (11) `mint` builds the SAN explicitly — `SanType::IpAddress` for an IP-literal host, `SanType::DnsName` otherwise (spec §8.3), with a `127.0.0.1` test; (12) `resolve` calls `warn_unknown_env`, warning on unrecognized `TS_DEV_PROXY_*` vars (spec §10.3). + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-22-ts-dev-proxy.md`. diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md new file mode 100644 index 000000000..b3e947b1a --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -0,0 +1,653 @@ +# Technical Specification: `ts dev proxy` — local production-hostname dev proxy + +**Status:** Draft +**Author:** Engineering +**Crate:** `crates/trusted-server-cli` (binary `ts`) +**Command:** `ts dev proxy` +**Last updated:** 2026-06-22 + +> A standalone prototype of the core proxy (tokio + hyper + rustls + rcgen) has +> been validated end-to-end against a live Fastly service: it rewrites a +> production hostname → an alternate upstream with TLS SNI swap, reaches the +> correct Fastly POP, and injects Basic auth. This spec generalizes that +> prototype into a `ts dev proxy` subcommand and adds a per-machine dev +> Certificate Authority so Chrome, Firefox, and Safari all work. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Background and Constraints](#2-background-and-constraints) +3. [Design Decisions](#3-design-decisions) +4. [CLI Surface](#4-cli-surface) +5. [Architecture](#5-architecture) +6. [Module Structure](#6-module-structure) +7. [Local Certificate Authority](#7-local-certificate-authority) +8. [Request Rewriting](#8-request-rewriting) +9. [Browser Orchestration](#9-browser-orchestration) +10. [Configuration](#10-configuration) +11. [Security Considerations](#11-security-considerations) +12. [Constants and Defaults](#12-constants-and-defaults) +13. [Error Handling](#13-error-handling) +14. [Testing Strategy](#14-testing-strategy) +15. [Implementation Order](#15-implementation-order) +16. [Out of Scope / Future Work](#16-out-of-scope--future-work) + +--- + +## 1. Overview + +`ts dev proxy` is a local developer tool that lets you open a **production +publisher hostname** (e.g. `https://www.example-publisher.com`) in a real +browser and have it served by a **dev or staging upstream** — a Trusted Server +Compute service (`*.edgecompute.app`), a staging Fastly service, or +`localhost` — **without changing any production DNS, VCL, certificates, or +affecting any other user.** + +It is a TLS-terminating forward (MITM) proxy that runs entirely on the +developer's machine. The browser is pointed at it; for the configured +hostname(s) it rewrites the request to the chosen upstream (including the TLS +SNI) while the address bar continues to show the production hostname. + +**Primary use case:** validate the routing and behavior of a new or changed +Trusted Server deployment at the publisher's real domain — cookies, +`Host`-sensitive logic, CMP/consent flows, first-party context — before any DNS +cutover. + +**Non-goals:** not a production proxy, not a load-test tool; it does not modify +the upstream Fastly service. Local-only, developer-facing. + +--- + +## 2. Background and Constraints + +The naive approaches do not work, and the reasons drive the design: + +1. **The browser binds TLS SNI to the URL host.** A request to + `https://www.example-publisher.com` always sends SNI + `www.example-publisher.com`. +2. **Fastly routes by SNI to the service that owns the domain.** That domain is + active on the production service, so the SNI is delivered to **production** — + regardless of any `/etc/hosts` or `--host-resolver-rules` IP override (all + Fastly anycast IPs route by SNI). +3. **A Fastly domain can be active on only one service at a time.** The new + service cannot claim the production hostname while prod still serves it. + +Therefore the only way to reach an alternate upstream while the browser shows +the production hostname is to **rewrite the SNI between the browser and the +upstream**, which requires terminating the browser's TLS locally — a MITM proxy. +No browser flag, extension, or hosts entry can do it, because none of them +decouple SNI from the URL. + +**TLS trust.** Terminating the browser's TLS means presenting a certificate for +the production hostname. Chrome can ignore cert errors +(`--ignore-certificate-errors`), but **Safari and Firefox have no such flag**. +To support all three uniformly, the proxy presents certificates from a local +**Certificate Authority** the developer trusts once. A trusted chain also +satisfies **HSTS**, which an "ignored" cert does not. + +--- + +## 3. Design Decisions + +Resolved during brainstorming and design review (2026-06-22): + +| Decision | Choice | +|---|---| +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), *not* a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO` for upstreams that route/validate on their own host. `X-Orig-Host` is informational (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | + +--- + +## 4. CLI Surface + +``` +ts dev proxy [OPTIONS] +``` + +### 4.1 Options + +| Flag | Value | Default | Description | +|---|---|---|---| +| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | +| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Optional when `FROM` is inferable from config (§10.2). | +| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Combines with `--from`, or with the inferred publisher domain when `--from` is omitted. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `--listen` | `ADDR` | `127.0.0.1:8080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | +| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | +| `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file` or `TS_DEV_PROXY_BASIC_AUTH`. | +| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | +| `--insecure` | flag | false | Skip **upstream** certificate verification. | +| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | +| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | + +### 4.2 Companion subcommands + +``` +ts dev proxy ca path # print the per-machine CA certificate path +ts dev proxy ca install # add the CA to the OS trust store (macOS login keychain; prompts) +ts dev proxy ca uninstall # remove the CA from the OS trust store (revoke trust when done) +ts dev proxy ca regenerate # regenerate the per-machine CA (invalidates prior trust) +``` + +### 4.3 Examples + +```bash +# Default: infer rule from project config, run proxy only (no browser): +ts dev proxy + +# Explicit map to a Compute service, launch+configure all three browsers: +ts dev proxy --map www.example-publisher.com=trusted-server-example.edgecompute.app \ + --launch chrome,firefox,safari + +# Gated staging upstream, Firefox only: +ts dev proxy -f www.example-publisher.com -t staging.example.net \ + --basic-auth dev:secret --launch firefox + +# Local instance, just run the proxy (no browser): +ts dev proxy -f www.example-publisher.com -t localhost:3000 \ + --upstream-plaintext +``` + +--- + +## 5. Architecture + +```mermaid +sequenceDiagram + participant B as Browser
(proxy = 127.0.0.1:8080) + participant P as ts dev proxy + participant U as Upstream
(Compute / staging) + + Note over B,P: address bar stays https://www.pub.com + B->>P: CONNECT www.pub.com:443 + P-->>B: 200 (tunnel established) + P->>P: TLS-accept with leaf cert for www.pub.com
(signed by local CA) + B->>P: GET / — Host: www.pub.com (over MITM TLS) + P->>P: match rule www.pub.com → TO
SNI→TO; keep Host: FROM (default); add X-Orig-Host; inject auth + P->>U: GET / — Host: www.pub.com (FROM), SNI=TO (over TLS) + Note over P,U: SNI=TO → valid cert + Fastly routing; Host=FROM by default (--rewrite-host sends Host=TO) + U-->>P: response + P-->>B: response (over MITM TLS) +``` + +**Per-connection flow:** + +1. Browser issues `CONNECT host:443`. The proxy parses the authority and matches + it against the rule table **before replying** — the `200` is deferred until it + knows it can serve the tunnel: + - **No match → blind tunnel.** Connect to `host:port` first; reply `200` only + after the upstream TCP connect succeeds (a connect failure returns a proper + `502` to the browser), then pipe bytes verbatim — no leaf minted, nothing + decrypted, so unrelated browsing is never MITM'd. (Refused with `403` on a + non-loopback bind — §11.) + - **Match → MITM.** Mint/select the leaf for `host`, reply `200`, then + TLS-accept the tunnel with that leaf (from the local CA, cached per host). +2. On the MITM path, read decrypted HTTP/1.1 requests **in a loop** — one + keep-alive tunnel carries many sequential requests. +3. For each request: rewrite upstream target + SNI to `TO`, set `Host` (§8.3), + add `X-Orig-Host: `, inject auth if configured. An `Upgrade:` + (WebSocket) request is out of scope in v1 (§16): log a clear note and close + rather than corrupting the stream. +4. Proxy opens a TLS (or plaintext) connection to `TO`, forwards the request, + streams the response back through the MITM TLS, and keeps the tunnel open for + the next request. +5. The pass-through case is handled entirely at step 1 (blind tunnel); a matched + tunnel never falls through to an unrewritten upstream. + +--- + +## 6. Module Structure + +The CLI is a **native host binary**, distinct from the wasm32 workspace default. + +``` +crates/trusted-server-cli/ + Cargo.toml # [[bin]] name = "ts"; native deps (tokio net, hyper, rustls, rcgen) + src/ + main.rs # clap root; dispatches `dev` + commands/ + dev/ + mod.rs # `Dev` subcommand group + proxy/ + mod.rs # `ProxyArgs`; orchestration (run / ca / launch) + server.rs # accept loop, CONNECT upgrade, request handler + ca.rs # CertAuthority: load-or-generate per-machine CA, mint+cache per-host leaves + rewrite.rs # RuleTable, Rule, RewriteOutcome + browser.rs # launch+configure Chrome/Firefox/Safari; PAC generation + config.rs # arg + project-config resolution into RuleTable +``` + +**Workspace integration.** Add `crates/trusted-server-cli` to the `[workspace] +exclude` list (alongside `crates/integration-tests`) — **not** `members`. The +repo pins `build.target = "wasm32-wasip1"` in `.cargo/config.toml`, so every +workspace member is built for wasm by the CI gates (`cargo test --workspace`, +`cargo clippy --workspace`); a native binary (tokio/hyper/rustls/rcgen) cannot +compile for wasm and would break those gates for everyone. It must therefore be +excluded, exactly like `integration-tests`. An excluded crate is **not** +addressable by `-p` from the workspace root, and the root config still forces +wasm, so build/run it with an explicit native target: + +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy … +``` + +(mirrors how `scripts/integration-tests.sh` runs the excluded `integration-tests` +crate via `--manifest-path` + a detected host `--target`.) Note the binary name +`ts` collides with the common `ts` timestamp tool (moreutils); consider also +installing a less ambiguous alias (e.g. `tsrv`). + +**Dependencies & lints.** Excluded crates inherit neither +`[workspace.dependencies]` nor `[workspace.lints]`, so this crate pins its own +deps — including its **own** `tokio` features (`net`, `rt-multi-thread`, +`macros`, `io-util`), since the workspace's wasm-oriented set lacks `net` — and +declares its **own** `[lints.clippy]`. Mirror the workspace posture (deny +`unwrap_used`/`panic`), use `error-stack` for fallible paths, and route +user-facing output through a thin helper (with a local +`#![allow(clippy::print_stdout)]` if that restriction lint is enabled). + +--- + +## 7. Local Certificate Authority + +### 7.1 Provenance + +The CA cert and key are **generated on the developer's machine on first run** and +stored under `--ca-dir` (default `$XDG_DATA_HOME/trusted-server/dev-proxy`, or +`~/Library/Application Support/trusted-server/dev-proxy` on macOS, where +`$XDG_DATA_HOME` is normally unset — resolve via a platform data-dir helper). The +directory is created `0700` and the key file written mode `0600`. The CA is +**never committed** and never leaves +the machine; each developer trusts their own CA once. + +- CN: `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION`. +- Validity: ~10 years (rotation = re-run `ca regenerate` + re-trust). +- The default `--ca-dir` lives outside the repo; the key must never be committed. + +### 7.2 `CertAuthority` + +```rust +struct CertAuthority { + issuer: rcgen::Issuer<'static>, // loaded-or-generated CA cert + key + leaves: Mutex>>, // per-host leaf cache +} +``` + +- **Load-or-generate** at startup: read `ca-cert.pem`/`ca-key.pem` from + `--ca-dir`; if absent, generate the CA, persist it (key `0600`), and print a + one-time "trust this CA" hint. +- **Mint** a leaf per **matched** CONNECT host (unmatched hosts are blind-tunneled + and never get a leaf — §5): `subject_alt_name = [host]`, short validity + (≤ 90 days), signed by `issuer`; wrap in a `rustls::ServerConfig` (ALPN + `http/1.1`); cache keyed by host. Sign *outside* the cache lock and + double-check before insert so concurrent first-time hosts don't serialize on + the signing work. (An IP-literal host needs an IP-type SAN, not DNS.) +- **Acceptor selection:** the CONNECT handler knows the host and selects the + cached `ServerConfig` directly — no SNI `ResolvesServerCert` in v1. + +### 7.3 Trust installation + +- `ts dev proxy ca path` prints the CA cert path under `--ca-dir`. +- `ts dev proxy ca install` (macOS) adds the CA to the **login** keychain (no + sudo): + `security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ca-cert.pem` + (prompts). Do **not** pass `-d` — that targets the admin/System keychain and + requires root. Prints instructions on failure / other OSes. +- `ts dev proxy ca uninstall` removes the CA again + (`security delete-certificate -c ""`), so trust can be fully revoked + when you're done (§11). +- Firefox does not consult the macOS login keychain reliably; it is trusted + per-profile at launch by importing the CA into the profile's NSS DB (§9). + +--- + +## 8. Request Rewriting + +### 8.1 Rule table + +```rust +struct Rule { + from: String, // matched case-insensitively, port-stripped + to: Authority, // host + optional port (default 443; 80 with --upstream-plaintext) + preserve_host: bool, + plaintext: bool, +} +struct RuleTable(Vec); // first match wins; unmatched => pass-through +``` + +In v1, `preserve_host` (default **true**) and `plaintext` are set on every rule +from the global flags — `--rewrite-host` clears `preserve_host`, and +`--upstream-plaintext` sets `plaintext`; the per-rule fields exist so a future +per-`--map` override can be added without a struct change. `-f/--from` + +`-t/--to` is sugar for a single `--map FROM=TO`. + +### 8.2 Matching + +The MITM-vs-tunnel decision is made first, from the **CONNECT authority** (§5 +step 2). On the MITM path each request is then matched by host (from `Host`, +else the CONNECT authority) to select the rule — case-insensitive, ignoring +`:port`, first match wins. On a loopback bind, a CONNECT authority with no +matching rule is blind-tunneled unchanged, so the proxy stays usable for normal +browsing; on a non-loopback bind (`--allow-non-loopback`) unmatched authorities +are instead refused with `403` (§11), never blind-tunneled. + +### 8.3 Header rewriting on match + +| Header | Action | Rationale | +|---|---|---| +| upstream connection + **SNI** | `rule.to` **host only** (port stripped) | SNI is a bare hostname; a `:port` in SNI is invalid and breaks the handshake | +| `Host` | `rule.from` (default) or `rule.to` if `--rewrite-host` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | +| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | +| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | +| `Proxy-Connection` | removed | hop-by-hop hygiene | + +**Why `Host = FROM` is the default (resolved).** The §1 goal — validate cookies, +`Host`-sensitive logic, CMP/consent, and first-party context at the *real* +domain — requires the upstream to see `Host = FROM`. Trusted Server core derives +`request_host` from the inbound `Host` (`RequestInfo::from_request` in +`http_util.rs`) and anchors all HTML/RSC URL rewriting to it +(`request_url = "{scheme}://{request_host}"` and `rewrite_bare_host_at_boundaries` +in `publisher.rs` / `rsc_flight.rs`). With `Host = TO` the app would rewrite every +first-party URL onto the Compute/staging host — wrong for the primary use case — +so the default preserves `FROM`. + +This works against a TS **Compute** upstream because Fastly routes by SNI +(`= TO`, a domain provisioned on that service) and passes `Host` through to the +program unchecked. A Fastly **Deliver** / host-validating upstream may reject an +unconfigured `Host` ("unknown domain"); and because a domain can be active on +only one service (§2 ¶3), you cannot add the live production domain to a separate +dev service. For those upstreams, pass `--rewrite-host` (sends `Host = TO`) or add +the domain to the service. + +`X-Orig-Host: FROM` is still sent for upstreams that opt to honor it, but it is +**informational only**: TS core does not read it today and in fact *strips* +spoofable forwarded host headers (`X-Forwarded-Host`, etc.) as an anti-spoofing +measure. Reconcile any future trusted-`X-Orig-Host` contract with the existing +`publisher.origin_host_header_override` knob. **Validation:** an integration test +must assert that, by default, rewritten HTML/RSC output stays on `FROM` (not `TO`). + +**Port handling.** When the `Host` value is the upstream (`--rewrite-host`) and +`TO` carries a non-default port (e.g. `localhost:3000`, +`staging.example.com:8443`), the port **is** included in the `Host` header but +**never** in the SNI (a bare hostname). This mirrors the existing split in +`publisher.rs` (`origin_host_without_port` vs `origin_host_header`). + +### 8.4 URI normalization + +Ensure the upstream request URI is origin-form (`path?query`); routing is driven +by the `Host` header. Requests read off a CONNECT tunnel are already origin-form, +so this is a no-op for the MITM-HTTPS path. + +**Plain HTTP.** v1 proxies **HTTPS only** — launched browsers are configured to +send only `https://` URLs to the proxy (§9), so plain `http://` goes `DIRECT`. +The proxy still handles a stray absolute-form HTTP request defensively: it +blind-forwards it to the URL host unchanged (never undefined behavior) and +applies no rewrite rules. Full absolute-form plain-HTTP rewriting is future work +(§16). + +**Local routes.** Origin-form requests addressed to the proxy's **own** listen +address — notably `GET /proxy.pac` for Safari (§9) — are served locally and never +forwarded. The listener dispatches these *before* proxy handling: a request is +proxy traffic only if it is `CONNECT` or absolute-form; an origin-form request to +a local route (`/proxy.pac`, a health check) is answered directly. On a +non-loopback bind (§11), blind-forwarding is disabled, so only `CONNECT` to +matched rules and these local routes are answered. + +--- + +## 9. Browser Orchestration + +`--launch` is a comma list with **no default** — when omitted, the proxy runs +without launching any browser. When set, each listed browser is launched in a +throwaway/temporary profile configured against the proxy, opening the first +rule's `FROM` URL. + +| Browser | Launch + configure | Trust | +|---|---|---| +| **chrome** | temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"` (per-scheme — **HTTPS only**, plain HTTP stays direct), `--no-first-run`, open URL | local CA via OS keychain (`ca install`) | +| **firefox** | temp profile `user.js` (not `prefs.js`, which Firefox owns and rewrites): `network.proxy.type=1` + **`network.proxy.ssl` host+port only** (leave `network.proxy.http` unset, so plain HTTP stays direct); `firefox -profile ` | Import the CA into the profile's NSS DB with `certutil -A` (robust, no sudo). `security.enterprise_roots.enabled=true` is unreliable on macOS — it reads the **admin/System** keychain, not the login keychain — so NSS import is the primary path. | +| **safari** | no per-app proxy: best-effort, **system-wide** `networksetup -setautoproxyurl ` on the active service, scoped to `FROM` via the PAC; open URL; **restore prior setting on exit** | local CA via macOS keychain (`ca install`) | + +PAC (Safari/system scoping) sends only `FROM` hosts to the proxy, everything +else `DIRECT`: + +```javascript +function FindProxyForURL(url, host) { + if (url.substring(0, 6) == "https:" && host == "www.example-publisher.com") + return "PROXY 127.0.0.1:8080"; + return "DIRECT"; +} +``` + +**Safari/macOS implementation notes:** macOS frequently ignores `file://` PAC +URLs, so the proxy **serves the PAC as a first-class local route** — an +origin-form `GET /proxy.pac` on the proxy's own listen address, dispatched before +proxy forwarding (§8.4) — and points `networksetup -setautoproxyurl` at +`http://127.0.0.1:/proxy.pac`. (It may instead bind a separate loopback +port for the PAC; either way the PAC is served locally, never proxied.) The +target must be the **active** network service, which `-listallnetworkservices` +does **not** identify (it only lists every service). Detect the default-route +interface with `route -n get default` (`interface: enX`), then map that device to +its service name via `networksetup -listnetworkserviceorder` (entries carry +`(Hardware Port: …, Device: enX)`); set, and later restore, the PAC on **that** +service. Chrome/`--ignore-certificate-errors` is not used (we rely on the trusted +CA); a developer who prefers not to trust the CA can launch Chrome manually with +that flag. + +Because `networksetup` changes are **system-wide** (every app, not just Safari), +the proxy persists the prior auto-proxy state to a file and restores it via an +exit hook plus signal handlers. A hard kill (`SIGKILL`) skips cleanup, so on the +next run `ts dev proxy` re-reads that file and restores it (or prints the manual +`networksetup` command). On multi-service machines it must target the correct +service, and managed networks may require admin rights. + +If any browser can't be auto-configured, print its manual steps and continue +with the others. + +--- + +## 10. Configuration + +### 10.1 Precedence + +CLI flags > env vars (§10.3) > project-config inference (§10.2) > built-in +defaults. `--map`/`-f`/`-t` rules are unioned (first-match-wins by declared +order). `--from` and `--to` may be supplied independently: a lone `--to` pairs +with the inferred `FROM`, and a lone `--from` pairs with the inferred `TO` +(§10.2). A rule is complete only when both sides resolve; otherwise the tool +errors with what it could and couldn't infer. + +### 10.2 Project-config inference (zero-arg ergonomics) + +With no `--map`/`-f`/`-t`, infer a single rule from the Trusted Server project +config so the common case is argument-free: + +- `FROM` ← the publisher first-party domain (`publisher.domain` in + `trusted-server.toml` — the public hostname, **not** `publisher.origin_url`'s + host, which is the upstream origin). +- `TO` ← a dev-proxy upstream that **must be added to config**: no existing field + carries the Compute/staging hostname (`fastly.toml` has only `service_id`, and + `edgecompute.app` appears only in comments). Add an explicit field, honored + only when no `--map`/`-f`/`-t`/`--to` is given: + + ```toml + [dev_proxy] + upstream = "trusted-server-example.edgecompute.app" + ``` + +Until `[dev_proxy].upstream` (or `TS_DEV_PROXY_MAP`) exists, zero-arg `ts dev +proxy` cannot infer `TO`: exit with a clear error showing the inferred `FROM` and +asking for `--to`/`--map`. If `FROM` is ambiguous (multiple publishers), list +candidates. + +### 10.3 Environment variables + +Each variable is honored **only when its corresponding flag is absent** (flags > +env > inference > defaults, §10.1) and is read once at startup. + +| Variable | Maps to | Syntax / behavior | +|---|---|---| +| `TS_DEV_PROXY_LISTEN` | `--listen` | `ADDR` (e.g. `127.0.0.1:8080`). | +| `TS_DEV_PROXY_MAP` | `--map` | `FROM=TO[,FROM=TO…]` (comma-separated, first-match-wins). Used only when **no** `--map`/`-f`/`-t` is given — the two sources are not merged. | +| `TS_DEV_PROXY_LAUNCH` | `--launch` | `chrome,firefox,safari` \| `all`. | +| `TS_DEV_PROXY_BASIC_AUTH` | `--basic-auth` | `USER:PASS`. If more than one auth source is set, precedence is `--basic-auth-file` > `--basic-auth` > env. | +| `TS_DEV_PROXY_REWRITE_HOST` | `--rewrite-host` | `1`/`true` sends `Host = TO`. | +| `TS_DEV_PROXY_INSECURE` | `--insecure` | `1`/`true` skips upstream verification. | + +`--ca-dir` is intentionally **not** env-driven (it changes which CA is trusted — +keep it explicit). Unknown `TS_DEV_PROXY_*` names are ignored with a warning. + +--- + +## 11. Security Considerations + +- **Per-machine CA key (never committed).** `ca-key.pem` is generated on the + developer's machine and stored under `--ca-dir` with mode `0600`. It is never + committed and never leaves the machine, so a repo leak cannot MITM anyone. + - CN explicitly marks it **DEV-ONLY / DO NOT TRUST IN PRODUCTION**. + - Proxy binds **loopback only** by default; a non-loopback `--listen` is + **rejected** unless `--allow-non-loopback` is passed. Even then, blind + tunnel/forward of unmatched hosts is **disabled** off loopback (unmatched + `CONNECT` gets `403`, only configured rules are served), so the tool can + never become a generic open CONNECT/HTTP proxy on the LAN. + - Leaves are short-lived; the CA is never used by any deployed artifact. + - `ca regenerate` rotates the CA (forces re-trust). + - `ca uninstall` removes it from the trust store. Trust is **not** auto-revoked + on exit, so an OS-trusted 10-year dev CA whose key sits on disk is a standing + MITM risk if that key is ever exfiltrated by user-level malware: run + `ca uninstall` when finished and treat `ca-key.pem` like a credential. + - Docs state plainly: trust this CA only on a dev machine you control. +- **`--insecure` is loud.** Disables upstream verification; print a banner while + active. Independent of the browser-side MITM trust. +- **No secret logging.** Redact `Authorization` and `Cookie`; log method, host, + path, and chosen upstream only. +- **Credential input.** `--basic-auth USER:PASS` is **convenience only** — argv + is visible via `ps` and shell history. Prefer `--basic-auth-file` or the + `TS_DEV_PROXY_BASIC_AUTH` env var; the file is read once at startup and never + logged. +- **Only matched hosts are decrypted.** Launched browsers proxy **HTTPS only** + (§9) and unmatched CONNECT authorities are blind-tunneled (§5), so unrelated + browsing is never MITM'd. +- **Production credentials reach `TO`.** With the default `Host = FROM`, the + browser attaches the production hostname's cookies and any existing + `Authorization` for `FROM`, and the proxy forwards them to `TO` — and injected + `--basic-auth` is *skipped* when an `Authorization` is already present (§8.3). + Point `TO` only at a dev/staging upstream you control. Launched **temp + profiles** start with no real cookies, so prefer `--launch` over running the + proxy against your everyday browser profile; for manual use, treat `TO` as + receiving real first-party session data. (A future `--scrub-request-headers` + could drop `Cookie`/`Authorization` toward `TO`, at the cost of session + fidelity.) +- **Scope reminder.** Mutates traffic only on the developer's machine; performs + no changes to Fastly services, DNS, or certificates. The Safari/system + auto-proxy change is system-wide while active and is reverted on exit (and + recovered on the next run after a hard kill — §9). + +--- + +## 12. Constants and Defaults + +| Name | Value | +|---|---| +| Default listen | `127.0.0.1:8080` | +| Default `--launch` | _unset_ (proxy only) | +| CA storage dir | `$XDG_DATA_HOME/trusted-server/dev-proxy/{ca-cert.pem,ca-key.pem}` (macOS: `~/Library/Application Support/…`; dir `0700`, key `0600`) | +| CA CN | `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION` | +| Leaf validity | ≤ 90 days | +| ALPN (both legs) | `http/1.1` | +| Injected real-host header | `X-Orig-Host` | +| Upstream port (default) | `443` (`80` with `--upstream-plaintext`) | + +--- + +## 13. Error Handling + +`error-stack` with actionable messages mapped to the failures we actually hit: + +| Condition | Detection | Message guidance | +|---|---|---| +| Upstream TLS `unrecognized_name` | rustls alert on connect | "`TO` has no TLS cert for its SNI — verify the domain is provisioned on the upstream Fastly service." | +| Upstream `401` | response status | "Upstream is gated; pass `--basic-auth user:pass`." | +| Upstream `503` / connect refused | response/IO | "Upstream unreachable or backend unhealthy; check the service and its origin healthcheck." | +| CA not trusted (browser warning) | n/a (browser-side) | Surface `ca install` / Firefox-profile note in the run banner. | +| Listen addr in use | bind error | Suggest `--listen` with another port. | +| Upstream "unknown domain" | `404` / Fastly error body | "The default `Host = FROM` isn't a domain the `TO` service accepts. Use a TS Compute upstream (routes by SNI), pass `--rewrite-host` to send `Host = TO`, or add the domain to the upstream service." | +| `Upgrade:` / WebSocket on a matched host | request `Upgrade` header | "Upgrades aren't proxied in v1 (§16); the connection is closed with a logged note." | + +Per-request errors become a `502` with a short diagnostic body plus a logged +line; the accept loop continues. The process never panics on a single bad +request. + +--- + +## 14. Testing Strategy + +**Unit (`rewrite.rs`):** host matching (case-insensitivity, port stripping, +first-match-wins, no-match pass-through); header outcomes (default `Host=FROM` + +`X-Orig-Host`; `--rewrite-host` sends `Host=TO`; non-default `TO` port in `Host` +but not SNI; auth injected only when absent); URI normalization. + +**Unit (`ca.rs`):** CA is generated on first run and reloaded from `--ca-dir` on +the next run (key file mode `0600`); minted leaf carries the requested SAN, +chains to the CA, and is cached (second call returns the same `Arc`). + +**Integration (`crates/integration-tests`, native):** local HTTPS upstream with +a known self-signed cert; run the proxy with `--insecure`; client configured to +use the proxy and trust the dev CA; assert address-host preserved, request +reaches upstream with rewritten `Host`/SNI + `X-Orig-Host`, response streamed +back. Cover `--basic-auth` clearing a `401`; unmatched-host **blind tunnel** +(bytes piped, no leaf minted, dev CA never presented); and **multiple sequential +requests over one keep-alive tunnel**. + +**Manual matrix (documented):** Chrome / Firefox / Safari each load the `FROM` +URL through the proxy with the CA trusted and reach the upstream with a valid +padlock and the production hostname in the address bar. + +--- + +## 15. Implementation Order + +1. **Crate skeleton.** `trusted-server-cli` with `ts` binary, clap root, `dev` + group, `proxy` stub. Workspace wiring (**excluded** crate, native target). +2. **Rewrite core.** `RuleTable`/`Rule`/matching/outcome, fully unit-tested. + Pure logic, no I/O. +3. **Local CA + minting.** Generate the CA on first run into `--ca-dir` (key + `0600`, outside the repo); `CertAuthority` loads-or-generates and mints+caches + per-host leaves. Unit-tested. +4. **Proxy server.** CONNECT upgrade, MITM TLS via minted leaf, upstream forward + (TLS + `--insecure` + `--upstream-plaintext`). End-to-end against a real + upstream. +5. **Header/auth polish.** `--basic-auth`/`--basic-auth-file`, `X-Orig-Host`, + `--rewrite-host` (default preserves `Host = FROM`), secret redaction in logs. +6. **Browser orchestration.** `--launch` list (no default — unset runs proxy + only): Chrome + Firefox profiles, Safari PAC via `networksetup` with + restore-on-exit; PAC generation; `ts dev proxy ca {path,install,uninstall,regenerate}`. +7. **Project-config inference.** Zero-arg resolution from `trusted-server.toml` + / `.env.ts.*`. +8. **Docs.** A `docs/guide/` page: setup, per-browser trust, the §13 + troubleshooting table, and the per-machine CA security note. + +Steps 1–4 already deliver a usable tool; each step is independently shippable. + +--- + +## 16. Out of Scope / Future Work + +- **HTTP/2 upstream** (v1 forces HTTP/1.1 on both legs). +- **Absolute-form plain-HTTP proxying / rewriting** (v1 proxies HTTPS only; a + stray `http://` request is blind-forwarded, not rewritten — §8.4). +- **WebSocket / non-HTTP upgrades** through the MITM tunnel. +- **Response rewriting / fixture injection** (mock upstreams, latency). +- **Multiple simultaneous upstreams per host** (A/B / weighted). +- **Windows/Linux trust + Safari automation** beyond printing instructions. +- **Recording/replay** of proxied traffic. From cb0b209afc48c586b7f10ac188f5966c55c4145d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:37:33 -0700 Subject: [PATCH 02/40] Add trusted-server-cli crate skeleton with ts dev proxy CLI surface --- Cargo.toml | 1 + crates/trusted-server-cli/Cargo.toml | 42 +++++++ .../src/commands/dev/mod.rs | 20 +++ .../src/commands/dev/proxy/ca.rs | 1 + .../src/commands/dev/proxy/config.rs | 1 + .../src/commands/dev/proxy/mod.rs | 116 ++++++++++++++++++ .../src/commands/dev/proxy/rewrite.rs | 1 + crates/trusted-server-cli/src/commands/mod.rs | 1 + crates/trusted-server-cli/src/lib.rs | 37 ++++++ crates/trusted-server-cli/src/main.rs | 7 ++ crates/trusted-server-cli/src/output.rs | 15 +++ 11 files changed, 242 insertions(+) create mode 100644 crates/trusted-server-cli/Cargo.toml create mode 100644 crates/trusted-server-cli/src/commands/dev/mod.rs create mode 100644 crates/trusted-server-cli/src/commands/dev/proxy/ca.rs create mode 100644 crates/trusted-server-cli/src/commands/dev/proxy/config.rs create mode 100644 crates/trusted-server-cli/src/commands/dev/proxy/mod.rs create mode 100644 crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs create mode 100644 crates/trusted-server-cli/src/commands/mod.rs create mode 100644 crates/trusted-server-cli/src/lib.rs create mode 100644 crates/trusted-server-cli/src/main.rs create mode 100644 crates/trusted-server-cli/src/output.rs diff --git a/Cargo.toml b/Cargo.toml index 9f2f4c673..d89a9f2d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ exclude = [ "crates/integration-tests", "crates/openrtb-codegen", + "crates/trusted-server-cli", ] default-members = [ diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml new file mode 100644 index 000000000..4bb56954f --- /dev/null +++ b/crates/trusted-server-cli/Cargo.toml @@ -0,0 +1,42 @@ +[workspace] + +[package] +name = "trusted-server-cli" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "trusted_server_cli" +path = "src/lib.rs" + +[[bin]] +name = "ts" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } +hyper = { version = "1", features = ["http1", "server", "client"] } +hyper-util = { version = "0.1", features = ["tokio"] } +rustls = "0.23" +tokio-rustls = "0.26" +rcgen = "0.13" +time = "0.3" +rustls-pemfile = "2" +clap = { version = "4", features = ["derive", "env"] } +error-stack = "0.6" +derive_more = { version = "2.0", features = ["display", "error"] } +log = "0.4" +env_logger = "0.11" +base64 = "0.22" +directories = "5" + +[dev-dependencies] +tempfile = "3" +reqwest = { version = "0.12", features = ["blocking"] } + +[lints.clippy] +unwrap_used = "deny" +panic = "deny" +print_stdout = "warn" +print_stderr = "warn" diff --git a/crates/trusted-server-cli/src/commands/dev/mod.rs b/crates/trusted-server-cli/src/commands/dev/mod.rs new file mode 100644 index 000000000..04a6f83e4 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/mod.rs @@ -0,0 +1,20 @@ +pub mod proxy; + +use proxy::{ProxyArgs, ProxyError}; + +/// The `ts dev …` command group. +#[derive(Debug, clap::Subcommand)] +pub enum DevCommand { + /// Run the local production-hostname dev proxy. + Proxy(ProxyArgs), +} + +/// Dispatches a `dev` subcommand. +/// +/// # Errors +/// Propagates failures from the chosen subcommand. +pub fn run(command: DevCommand) -> Result<(), error_stack::Report> { + match command { + DevCommand::Proxy(args) => proxy::run(args), + } +} diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs new file mode 100644 index 000000000..2f192f12b --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -0,0 +1 @@ +//! Certificate authority management for the `ts dev proxy ca` subcommand. diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs new file mode 100644 index 000000000..9cd0d3e7a --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -0,0 +1 @@ +//! Configuration loading and validation for `ts dev proxy`. diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs new file mode 100644 index 000000000..44b90bbac --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -0,0 +1,116 @@ +pub mod ca; +pub mod config; +pub mod rewrite; + +use crate::output; + +/// Errors surfaced by `ts dev proxy`. +#[derive(Debug, derive_more::Display)] +pub enum ProxyError { + /// A rewrite rule could not be parsed or resolved. + #[display("invalid rule configuration")] + Config, + /// The local certificate authority could not be loaded or generated. + #[display("certificate authority error")] + CertAuthority, + /// The proxy server failed to start or run. + #[display("proxy server error")] + Server, + /// A browser could not be launched or configured. + #[display("browser orchestration error")] + Browser, +} + +impl core::error::Error for ProxyError {} + +/// `ts dev proxy [OPTIONS]` — see the design spec §4. +#[derive(Debug, clap::Args)] +pub struct ProxyArgs { + /// Rewrite rule `FROM=TO` (repeatable). + #[arg(long = "map", value_name = "FROM=TO")] + pub map: Vec, + + /// Shorthand single-rule FROM (optional when inferable from config). + #[arg(short = 'f', long = "from", value_name = "HOST")] + pub from: Option, + + /// Shorthand single-rule TO (`HOST[:PORT]`). + #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] + pub to: Option, + + /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080", env = "TS_DEV_PROXY_LISTEN")] + pub listen: String, + + /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). + #[arg(long)] + pub allow_non_loopback: bool, + + /// Browsers to launch + configure (comma list or `all`). + #[arg(long, value_name = "LIST", env = "TS_DEV_PROXY_LAUNCH")] + pub launch: Option, + + /// Send `Host: ` upstream instead of the default ``. + #[arg(long, env = "TS_DEV_PROXY_REWRITE_HOST")] + pub rewrite_host: bool, + + /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). + #[arg(long, value_name = "USER:PASS", env = "TS_DEV_PROXY_BASIC_AUTH")] + pub basic_auth: Option, + + /// Read `USER:PASS` from a file (preferred over `--basic-auth`). + #[arg(long, value_name = "PATH")] + pub basic_auth_file: Option, + + /// Skip upstream certificate verification. + #[arg(long, env = "TS_DEV_PROXY_INSECURE")] + pub insecure: bool, + + /// Connect to upstream over plaintext HTTP. + #[arg(long)] + pub upstream_plaintext: bool, + + /// Directory holding the per-machine CA cert/key. + #[arg(long, value_name = "PATH")] + pub ca_dir: Option, + + /// Optional nested subcommand (`ts dev proxy ca …`). When absent, the proxy + /// runs with the options above. + #[command(subcommand)] + pub command: Option, +} + +/// Nested `ts dev proxy ` commands. A single `ca` wrapper gives the +/// **two-level** path `ts dev proxy ca ` required by spec §4.2 — a bare +/// `#[command(subcommand)] CaCommand` would have produced `ts dev proxy install`. +#[derive(Debug, clap::Subcommand)] +pub enum ProxySub { + /// Manage the per-machine dev CA. + Ca { + #[command(subcommand)] + action: CaCommand, + }, +} + +/// `ts dev proxy ca …` companion actions (spec §4.2). +#[derive(Debug, clap::Subcommand)] +pub enum CaCommand { + /// Print the per-machine CA certificate path. + Path, + /// Add the CA to the OS trust store (macOS login keychain). + Install, + /// Remove the CA from the OS trust store. + Uninstall, + /// Regenerate the per-machine CA (invalidates prior trust). + Regenerate, +} + +/// Runs `ts dev proxy`. +/// +/// # Errors +/// Returns [`ProxyError`] if configuration, the CA, the server, or browser +/// orchestration fails. +pub fn run(args: ProxyArgs) -> Result<(), error_stack::Report> { + output::info(&format!("ts dev proxy: listen={}", args.listen)); + Ok(()) +} diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs new file mode 100644 index 000000000..3cde8f529 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -0,0 +1 @@ +//! Host rewrite rule parsing and application for `ts dev proxy`. diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs new file mode 100644 index 000000000..2cdafaac9 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod dev; diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs new file mode 100644 index 000000000..d7126ec4f --- /dev/null +++ b/crates/trusted-server-cli/src/lib.rs @@ -0,0 +1,37 @@ +//! Trusted Server developer CLI library. The `ts` binary is a thin wrapper; +//! all logic lives here so integration tests can exercise it. +pub mod commands; +pub mod output; + +use clap::Parser; +use commands::dev::DevCommand; + +/// The `ts` command-line interface. +#[derive(Debug, Parser)] +#[command(name = "ts", version, about = "Trusted Server developer CLI")] +pub struct Cli { + #[command(subcommand)] + command: TopCommand, +} + +#[derive(Debug, clap::Subcommand)] +enum TopCommand { + /// Local development tools. + #[command(subcommand)] + Dev(DevCommand), +} + +impl Cli { + /// Runs the parsed CLI, returning a process exit code. + #[must_use] + pub fn run(self) -> i32 { + let result = match self.command { + TopCommand::Dev(dev) => commands::dev::run(dev), + }; + if let Err(report) = result { + output::warn(&format!("{report:?}")); + return 1; + } + 0 + } +} diff --git a/crates/trusted-server-cli/src/main.rs b/crates/trusted-server-cli/src/main.rs new file mode 100644 index 000000000..8bebe20b4 --- /dev/null +++ b/crates/trusted-server-cli/src/main.rs @@ -0,0 +1,7 @@ +use clap::Parser as _; +use trusted_server_cli::Cli; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + std::process::exit(Cli::parse().run()); +} diff --git a/crates/trusted-server-cli/src/output.rs b/crates/trusted-server-cli/src/output.rs new file mode 100644 index 000000000..afcfc94ba --- /dev/null +++ b/crates/trusted-server-cli/src/output.rs @@ -0,0 +1,15 @@ +//! User-facing console output for the `ts` binary. +//! +//! This is the only module permitted to write to stdout/stderr directly; +//! everything else uses `log`. +#![allow(clippy::print_stdout, clippy::print_stderr)] + +/// Prints an informational line to stdout. +pub fn info(message: &str) { + println!("{message}"); +} + +/// Prints a warning line to stderr. +pub fn warn(message: &str) { + eprintln!("warning: {message}"); +} From e3b17b68a59fd113de9d81fa9a7807497a090a96 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:56:32 -0700 Subject: [PATCH 03/40] Add rewrite core with rule matching and header outcomes --- .../src/commands/dev/proxy/rewrite.rs | 221 +++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index 3cde8f529..12041a02c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -1 +1,220 @@ -//! Host rewrite rule parsing and application for `ts dev proxy`. +//! Pure request-rewriting logic: rule matching and header outcomes (spec §8). + +/// A rewrite-target authority: host plus a resolved port and its scheme default. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Authority { + /// Hostname only — never used with a port for SNI. + host: String, + /// Resolved port (explicit, or the scheme default). + pub port: u16, + /// Scheme default for this authority (443 for TLS, 80 for plaintext). + default_port: u16, +} + +/// Errors from parsing/validating rules. +#[derive(Debug, derive_more::Display)] +pub enum RuleError { + /// The `--map FROM=TO` value was not `FROM=TO`. + #[display("expected FROM=TO, got `{value}`")] + Map { value: String }, + /// The authority port was not a valid `u16`. + #[display("invalid port in `{value}`")] + Port { value: String }, + /// The authority host was empty. + #[display("empty host in `{value}`")] + EmptyHost { value: String }, +} + +impl core::error::Error for RuleError {} + +impl Authority { + /// Parses `HOST[:PORT]`, defaulting the port from `plaintext` (80) or TLS (443). + /// + /// # Errors + /// + /// Returns [`RuleError`] on an empty host or an unparseable port. + pub fn parse(raw: &str, plaintext: bool) -> Result { + let default_port = if plaintext { 80 } else { 443 }; + let (host, port) = match raw.rsplit_once(':') { + Some((h, p)) => { + let port = p + .parse::() + .map_err(|_| RuleError::Port { value: raw.to_string() })?; + (h, port) + } + None => (raw, default_port), + }; + if host.is_empty() { + return Err(RuleError::EmptyHost { value: raw.to_string() }); + } + Ok(Self { host: host.to_ascii_lowercase(), port, default_port }) + } + + /// The bare hostname (for SNI and connection target). + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// Whether the port equals this authority's scheme default (443 TLS / 80 + /// plaintext) — so `:port` is omitted from the `Host` header. + #[must_use] + pub fn is_default_port(&self) -> bool { + self.port == self.default_port + } + + /// `host`, plus `:port` only when the port is non-default — for the `Host` header. + #[must_use] + pub fn host_with_port(&self) -> String { + if self.is_default_port() { + self.host.clone() + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +/// A single rewrite rule. +#[derive(Debug, Clone)] +pub struct Rule { + /// Production hostname to match (stored lowercase, port-stripped). + pub from: String, + /// Upstream target. + pub to: Authority, + /// When true (default), send `Host: FROM`; when false, send `Host: TO`. + pub preserve_host: bool, + /// Connect to the upstream over plaintext HTTP. + pub plaintext: bool, +} + +/// An ordered set of rules; first match wins. +#[derive(Debug, Clone, Default)] +pub struct RuleTable(pub Vec); + +impl RuleTable { + /// Returns the first rule matching `host`, comparing case-insensitively and + /// ignoring any `:port`. + #[must_use] + pub fn first_match(&self, host: &str) -> Option<&Rule> { + let needle = host + .rsplit_once(':') + .map_or(host, |(h, _)| h) + .to_ascii_lowercase(); + self.0.iter().find(|r| r.from == needle) + } +} + +/// The header/SNI decisions for a matched rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RewriteOutcome { + /// SNI to present upstream (TO host only, no port). + pub sni: String, + /// Value for the upstream `Host` header. + pub host_header: String, + /// Value for the `X-Orig-Host` header (always FROM). + pub orig_host: String, + /// Whether the upstream leg is TLS (`!plaintext`). + pub scheme_is_tls: bool, +} + +/// Computes the rewrite outcome for a matched rule (spec §8.3). +#[must_use] +pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { + let host_header = if rule.preserve_host { + rule.from.clone() + } else { + rule.to.host_with_port() + }; + RewriteOutcome { + sni: rule.to.host().to_string(), + host_header, + orig_host: rule.from.clone(), + scheme_is_tls: !rule.plaintext, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Rule { + Rule { + from: from.to_string(), + to: Authority::parse(to, plaintext).expect("should parse authority"), + preserve_host, + plaintext, + } + } + + #[test] + fn authority_defaults_port_443_for_tls() { + let a = Authority::parse("staging.example.net", false).expect("should parse"); + assert_eq!(a.host(), "staging.example.net", "should keep host"); + assert_eq!(a.port, 443, "should default to 443 for TLS"); + assert!(a.is_default_port(), "443 is default for TLS"); + assert_eq!(a.host_with_port(), "staging.example.net", "default port omitted"); + } + + #[test] + fn authority_defaults_port_80_for_plaintext() { + let a = Authority::parse("localhost", true).expect("should parse"); + assert_eq!(a.port, 80, "should default to 80 for plaintext"); + assert_eq!(a.host_with_port(), "localhost", "default port omitted"); + } + + #[test] + fn authority_keeps_non_default_port_in_host_header_only() { + let a = Authority::parse("localhost:3000", true).expect("should parse"); + assert_eq!(a.port, 3000, "should parse explicit port"); + assert!(!a.is_default_port(), "3000 is not default"); + assert_eq!(a.host(), "localhost", "SNI host must exclude port"); + assert_eq!(a.host_with_port(), "localhost:3000", "Host header includes non-default port"); + } + + #[test] + fn is_default_port_is_scheme_relative() { + // TLS authority on :80 is NOT default — :80 must appear in Host. + let tls_80 = Authority::parse("host.example.com:80", false).expect("parse"); + assert!(!tls_80.is_default_port(), "80 is not the TLS default"); + assert_eq!(tls_80.host_with_port(), "host.example.com:80", "Host keeps :80 for TLS"); + // Plaintext authority on :443 is NOT default — :443 must appear in Host. + let plain_443 = Authority::parse("host.example.com:443", true).expect("parse"); + assert!(!plain_443.is_default_port(), "443 is not the plaintext default"); + assert_eq!(plain_443.host_with_port(), "host.example.com:443", "Host keeps :443 for plaintext"); + } + + #[test] + fn matching_is_case_insensitive_and_port_stripped() { + let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", true, false)]); + let m = table.first_match("WWW.Example-Publisher.COM:443").expect("should match"); + assert_eq!(m.from, "www.example-publisher.com", "match ignores case and port"); + assert!(table.first_match("other.example.com").is_none(), "unmatched host returns None"); + } + + #[test] + fn first_match_wins() { + let table = RuleTable(vec![ + rule("a.example.com", "first.edgecompute.app", true, false), + rule("a.example.com", "second.edgecompute.app", true, false), + ]); + assert_eq!(table.first_match("a.example.com").expect("should match").to.host(), "first.edgecompute.app"); + } + + #[test] + fn rewrite_default_preserves_from_host_and_sets_sni_to_to() { + let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", true, false); + let out = rewrite_for(&r); + assert_eq!(out.sni, "to.edgecompute.app", "SNI is TO host only, no port"); + assert_eq!(out.host_header, "www.example-publisher.com", "default Host is FROM"); + assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host is FROM"); + } + + #[test] + fn rewrite_host_uses_to_authority_with_port() { + let r = rule("www.example-publisher.com", "localhost:3000", false, true); + let out = rewrite_for(&r); + assert_eq!(out.sni, "localhost", "SNI never carries a port"); + assert_eq!(out.host_header, "localhost:3000", "rewrite-host sends TO host:port"); + assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); + } +} From cba4d7e232a16a965b4bd9b342cb6a46c97d7f40 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:03:55 -0700 Subject: [PATCH 04/40] Harden rewrite rule matching, port parsing, and test coverage - Compare r.from case-insensitively in RuleTable::first_match to enforce the lowercase invariant regardless of how Rule.from was built - Reject trailing-colon inputs (empty port string) as RuleError::Port in Authority::parse; add rejects_empty_or_missing_port test - Assert scheme_is_tls in rewrite_default_preserves_from_host_and_sets_sni_to_to and rewrite_host_uses_to_authority_with_port --- .../src/commands/dev/proxy/rewrite.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index 12041a02c..e8fcd0a2f 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -37,6 +37,9 @@ impl Authority { let default_port = if plaintext { 80 } else { 443 }; let (host, port) = match raw.rsplit_once(':') { Some((h, p)) => { + if p.is_empty() { + return Err(RuleError::Port { value: raw.to_string() }); + } let port = p .parse::() .map_err(|_| RuleError::Port { value: raw.to_string() })?; @@ -100,7 +103,7 @@ impl RuleTable { .rsplit_once(':') .map_or(host, |(h, _)| h) .to_ascii_lowercase(); - self.0.iter().find(|r| r.from == needle) + self.0.iter().find(|r| r.from.to_ascii_lowercase() == needle) } } @@ -207,6 +210,7 @@ mod tests { assert_eq!(out.sni, "to.edgecompute.app", "SNI is TO host only, no port"); assert_eq!(out.host_header, "www.example-publisher.com", "default Host is FROM"); assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host is FROM"); + assert!(out.scheme_is_tls, "TLS rule yields a TLS outcome"); } #[test] @@ -216,5 +220,15 @@ mod tests { assert_eq!(out.sni, "localhost", "SNI never carries a port"); assert_eq!(out.host_header, "localhost:3000", "rewrite-host sends TO host:port"); assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); + assert!(!out.scheme_is_tls, "plaintext rule yields a non-TLS outcome"); + } + + #[test] + fn rejects_empty_or_missing_port() { + let err = Authority::parse("host.example.com:", true).expect_err("should reject trailing colon"); + assert!( + matches!(err, RuleError::Port { .. }), + "trailing colon should be a Port error, got: {err}" + ); } } From 03c4999280e24b844fc584492996e4ba2a4c0cb3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:12:01 -0700 Subject: [PATCH 05/40] Resolve proxy args and env into a concrete rule table and settings --- .../src/commands/dev/proxy/config.rs | 344 +++++++++++++++++- .../src/commands/dev/proxy/mod.rs | 11 +- 2 files changed, 353 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 9cd0d3e7a..aa4caf397 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -1 +1,343 @@ -//! Configuration loading and validation for `ts dev proxy`. +//! Resolves `ProxyArgs` (+ env, defaults) into a concrete [`ResolvedConfig`]. + +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; + +use base64::Engine as _; +use error_stack::{Report, ResultExt as _}; + +use super::ProxyArgs; +use super::rewrite::{Authority, Rule, RuleTable}; + +/// Errors from configuration resolution. +#[derive(Debug, derive_more::Display)] +pub enum ConfigError { + /// No usable rule could be formed and none was inferable. + #[display("no rewrite rule: pass --map FROM=TO (or --to with an inferable FROM)")] + NoRule, + /// A `--map`/authority value was malformed. + #[display("invalid rule value")] + Rule, + /// `--listen` was not a valid socket address. + #[display("invalid --listen address `{value}`")] + Listen { value: String }, + /// A non-loopback listen address was given without `--allow-non-loopback`. + #[display("--listen {value} is non-loopback; pass --allow-non-loopback to allow it")] + NonLoopback { value: String }, + /// `--basic-auth`/file value was not `USER:PASS`. + #[display("--basic-auth must be USER:PASS")] + BasicAuth, + /// An unknown browser name was passed to `--launch`. + #[display("unknown browser `{value}` (expected chrome|firefox|safari|all)")] + Browser { value: String }, +} + +impl core::error::Error for ConfigError {} + +/// Basic-auth credentials to inject upstream. +#[derive(Debug, Clone)] +pub struct BasicAuth { + pub user: String, + pub pass: String, +} + +impl BasicAuth { + /// The `Authorization` header value (`Basic base64(user:pass)`). + #[must_use] + pub fn header_value(&self) -> String { + let token = base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", self.user, self.pass)); + format!("Basic {token}") + } + + fn parse(raw: &str) -> Result { + let (user, pass) = raw.split_once(':').ok_or(ConfigError::BasicAuth)?; + Ok(Self { user: user.to_string(), pass: pass.to_string() }) + } +} + +/// A browser the proxy can launch and configure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Browser { + Chrome, + Firefox, + Safari, +} + +impl Browser { + /// Parses a comma list (or `all`) of browser names. + /// + /// # Errors + /// + /// Returns [`ConfigError::Browser`] on an unknown name. + pub fn parse_list(raw: &str) -> Result, ConfigError> { + if raw.trim() == "all" { + return Ok(vec![Self::Chrome, Self::Firefox, Self::Safari]); + } + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|name| match name { + "chrome" => Ok(Self::Chrome), + "firefox" => Ok(Self::Firefox), + "safari" => Ok(Self::Safari), + other => Err(ConfigError::Browser { value: other.to_string() }), + }) + .collect() + } +} + +/// Fully-resolved proxy configuration. +#[derive(Debug)] +pub struct ResolvedConfig { + pub rules: RuleTable, + pub listen: SocketAddr, + pub allow_non_loopback: bool, + pub launch: Vec, + pub insecure: bool, + pub basic_auth: Option, + pub ca_dir: PathBuf, +} + +/// Default CA directory (spec §7.1/§12): `$XDG_DATA_HOME/trusted-server/dev-proxy`, +/// or the platform data dir on macOS (`~/Library/Application Support/...`). +/// +/// `ProjectDirs::from(...)` is **not** used — it yields a reverse-DNS leaf +/// (`com.trusted-server.dev-proxy`), not the spec's `trusted-server/dev-proxy`. +fn default_ca_dir() -> PathBuf { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| directories::BaseDirs::new().map(|d| d.data_dir().to_path_buf())); + match base { + Some(dir) => dir.join("trusted-server").join("dev-proxy"), + None => PathBuf::from(".trusted-server-dev-proxy"), + } +} + +/// Resolves the CA directory **independently of rule resolution**, so the `ca` +/// subcommands work without a `--map`/`--to` (spec §4.2). +#[must_use] +pub fn ca_dir(args: &ProxyArgs) -> PathBuf { + args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) +} + +/// Warns about unrecognized `TS_DEV_PROXY_*` environment variables (spec §10.3). +/// +/// `TS_DEV_PROXY_CA_DIR` is intentionally absent here — `--ca-dir` is not +/// env-driven, so setting it warns (and is ignored). +fn warn_unknown_env() { + const KNOWN: &[&str] = &[ + "TS_DEV_PROXY_LISTEN", + "TS_DEV_PROXY_MAP", + "TS_DEV_PROXY_LAUNCH", + "TS_DEV_PROXY_BASIC_AUTH", + "TS_DEV_PROXY_REWRITE_HOST", + "TS_DEV_PROXY_INSECURE", + ]; + for (name, _) in std::env::vars() { + if name.starts_with("TS_DEV_PROXY_") && !KNOWN.contains(&name.as_str()) { + crate::output::warn(&format!("ignoring unknown environment variable {name}")); + } + } +} + +fn build_rules(args: &ProxyArgs) -> Result { + let mut rules = Vec::new(); + let preserve_host = !args.rewrite_host; + for entry in &args.map { + let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; + rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + } + if let (Some(from), Some(to)) = (&args.from, &args.to) { + rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + } + // TS_DEV_PROXY_MAP is consulted only when NO --map/-f/-t was given (flags > env, + // spec §10.1/§10.3). clap's `env` on a Vec can't express that, so read it here. + if args.map.is_empty() && args.from.is_none() && args.to.is_none() + && let Ok(env_map) = std::env::var("TS_DEV_PROXY_MAP") + { + for entry in env_map.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; + rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + } + } + // NOTE: lone --to / lone --from + project-config inference is added in Task 7. + Ok(RuleTable(rules)) +} + +fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Result { + let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; + Ok(Rule { from: from.to_ascii_lowercase(), to, preserve_host, plaintext }) +} + +/// Resolves arguments into a [`ResolvedConfig`]. +/// +/// # Errors +/// +/// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen +/// address, malformed credentials, or an unknown browser. +pub fn resolve(args: &ProxyArgs) -> Result> { + warn_unknown_env(); + let rules = build_rules(args).map_err(Report::from)?; + if rules.0.is_empty() { + return Err(Report::new(ConfigError::NoRule)); + } + + let listen: SocketAddr = args + .listen + .parse() + .change_context_lazy(|| ConfigError::Listen { value: args.listen.clone() })?; + let is_loopback = match listen.ip() { + IpAddr::V4(v4) => v4.is_loopback(), + IpAddr::V6(v6) => v6.is_loopback(), + }; + if !is_loopback && !args.allow_non_loopback { + return Err(Report::new(ConfigError::NonLoopback { value: args.listen.clone() })); + } + + let launch = match &args.launch { + Some(raw) => Browser::parse_list(raw).map_err(Report::from)?, + None => Vec::new(), + }; + + let basic_auth = resolve_basic_auth(args).map_err(Report::from)?; + let ca_dir = ca_dir(args); + + Ok(ResolvedConfig { + rules, + listen, + allow_non_loopback: args.allow_non_loopback, + launch, + insecure: args.insecure, + basic_auth, + ca_dir, + }) +} + +/// Credential precedence: `--basic-auth-file` > `--basic-auth` > env (the env +/// value already arrives via clap's `env` on `--basic-auth`). +fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { + if let Some(path) = &args.basic_auth_file { + let raw = std::fs::read_to_string(path).map_err(|_| ConfigError::BasicAuth)?; + return Ok(Some(BasicAuth::parse(raw.trim())?)); + } + match &args.basic_auth { + Some(raw) => Ok(Some(BasicAuth::parse(raw)?)), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_args() -> crate::commands::dev::proxy::ProxyArgs { + // Construct via clap so defaults match the real surface. + use clap::Parser; + #[derive(clap::Parser)] + struct W { + #[command(flatten)] + a: crate::commands::dev::proxy::ProxyArgs, + } + W::parse_from(["ts"]).a + } + + #[test] + fn single_rule_from_to_defaults_to_preserve_host() { + let mut args = base_args(); + args.from = Some("www.example-publisher.com".into()); + args.to = Some("to.edgecompute.app".into()); + let cfg = resolve(&args).expect("should resolve"); + let rule = cfg.rules.first_match("www.example-publisher.com").expect("rule present"); + assert!(rule.preserve_host, "default preserves FROM host"); + assert_eq!(rule.to.host(), "to.edgecompute.app"); + } + + #[test] + fn rewrite_host_flag_clears_preserve_host() { + let mut args = base_args(); + args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; + args.rewrite_host = true; + let cfg = resolve(&args).expect("should resolve"); + assert!( + !cfg.rules + .first_match("www.example-publisher.com") + .expect("rule") + .preserve_host + ); + } + + #[test] + fn map_value_must_be_from_equals_to() { + let mut args = base_args(); + args.map = vec!["not-a-map".into()]; + assert!(resolve(&args).is_err(), "malformed --map errors"); + } + + #[test] + fn env_map_used_only_when_no_map_or_from_to() { + // SAFETY: single-threaded test; set then remove the env var. + // Used when no --map/-f/-t: env rule applies. + unsafe { + std::env::set_var( + "TS_DEV_PROXY_MAP", + "a.example.com=b.edgecompute.app,c.example.com=d.edgecompute.app", + ) + }; + let cfg = resolve(&base_args()).expect("env map resolves"); + assert!( + cfg.rules.first_match("a.example.com").is_some(), + "first env rule applied" + ); + assert!( + cfg.rules.first_match("c.example.com").is_some(), + "second env rule applied" + ); + // Ignored when a flag rule is present (flags > env). + let mut args = base_args(); + args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; + let cfg = resolve(&args).expect("flag rule resolves"); + assert!( + cfg.rules.first_match("a.example.com").is_none(), + "env ignored when --map present" + ); + unsafe { std::env::remove_var("TS_DEV_PROXY_MAP") }; + } + + #[test] + fn non_loopback_listen_requires_flag() { + let mut args = base_args(); + args.map = vec!["a.example.com=b.edgecompute.app".into()]; + args.listen = "0.0.0.0:8080".into(); + assert!(resolve(&args).is_err(), "non-loopback without flag is rejected"); + args.allow_non_loopback = true; + assert!(resolve(&args).is_ok(), "non-loopback allowed with flag"); + } + + #[test] + fn basic_auth_header_is_base64() { + let auth = BasicAuth { + user: "dev".into(), + pass: "secret".into(), + }; + assert_eq!( + auth.header_value(), + "Basic ZGV2OnNlY3JldA==", + "Basic base64(user:pass)" + ); + } + + #[test] + fn browser_list_parses_all() { + assert_eq!( + Browser::parse_list("all").expect("parses"), + vec![Browser::Chrome, Browser::Firefox, Browser::Safari] + ); + assert_eq!( + Browser::parse_list("firefox,chrome").expect("parses"), + vec![Browser::Firefox, Browser::Chrome] + ); + assert!(Browser::parse_list("netscape").is_err(), "unknown browser errors"); + } +} diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 44b90bbac..ad9706847 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -2,6 +2,8 @@ pub mod ca; pub mod config; pub mod rewrite; +use error_stack::ResultExt as _; + use crate::output; /// Errors surfaced by `ts dev proxy`. @@ -108,9 +110,16 @@ pub enum CaCommand { /// Runs `ts dev proxy`. /// /// # Errors +/// /// Returns [`ProxyError`] if configuration, the CA, the server, or browser /// orchestration fails. pub fn run(args: ProxyArgs) -> Result<(), error_stack::Report> { - output::info(&format!("ts dev proxy: listen={}", args.listen)); + let cfg = config::resolve(&args).change_context(ProxyError::Config)?; + output::info(&format!( + "ts dev proxy: listen={} rules={} launch={:?}", + cfg.listen, + cfg.rules.0.len(), + cfg.launch, + )); Ok(()) } From 2a2e85e3f8a7718ad2c25d7faca23e771169d2ac Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:22:50 -0700 Subject: [PATCH 06/40] Drop environment-variable support from dev proxy CLI --- crates/trusted-server-cli/Cargo.toml | 2 +- .../src/commands/dev/proxy/config.rs | 66 +------------------ .../src/commands/dev/proxy/mod.rs | 10 +-- 3 files changed, 8 insertions(+), 70 deletions(-) diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 4bb56954f..7c9a2d3d1 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -23,7 +23,7 @@ tokio-rustls = "0.26" rcgen = "0.13" time = "0.3" rustls-pemfile = "2" -clap = { version = "4", features = ["derive", "env"] } +clap = { version = "4", features = ["derive"] } error-stack = "0.6" derive_more = { version = "2.0", features = ["display", "error"] } log = "0.4" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index aa4caf397..2907dc5ff 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -1,4 +1,4 @@ -//! Resolves `ProxyArgs` (+ env, defaults) into a concrete [`ResolvedConfig`]. +//! Resolves `ProxyArgs` (+ defaults) into a concrete [`ResolvedConfig`]. use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -122,26 +122,6 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) } -/// Warns about unrecognized `TS_DEV_PROXY_*` environment variables (spec §10.3). -/// -/// `TS_DEV_PROXY_CA_DIR` is intentionally absent here — `--ca-dir` is not -/// env-driven, so setting it warns (and is ignored). -fn warn_unknown_env() { - const KNOWN: &[&str] = &[ - "TS_DEV_PROXY_LISTEN", - "TS_DEV_PROXY_MAP", - "TS_DEV_PROXY_LAUNCH", - "TS_DEV_PROXY_BASIC_AUTH", - "TS_DEV_PROXY_REWRITE_HOST", - "TS_DEV_PROXY_INSECURE", - ]; - for (name, _) in std::env::vars() { - if name.starts_with("TS_DEV_PROXY_") && !KNOWN.contains(&name.as_str()) { - crate::output::warn(&format!("ignoring unknown environment variable {name}")); - } - } -} - fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); let preserve_host = !args.rewrite_host; @@ -152,16 +132,6 @@ fn build_rules(args: &ProxyArgs) -> Result { if let (Some(from), Some(to)) = (&args.from, &args.to) { rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); } - // TS_DEV_PROXY_MAP is consulted only when NO --map/-f/-t was given (flags > env, - // spec §10.1/§10.3). clap's `env` on a Vec can't express that, so read it here. - if args.map.is_empty() && args.from.is_none() && args.to.is_none() - && let Ok(env_map) = std::env::var("TS_DEV_PROXY_MAP") - { - for entry in env_map.split(',').map(str::trim).filter(|s| !s.is_empty()) { - let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; - rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); - } - } // NOTE: lone --to / lone --from + project-config inference is added in Task 7. Ok(RuleTable(rules)) } @@ -178,7 +148,6 @@ fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Resu /// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen /// address, malformed credentials, or an unknown browser. pub fn resolve(args: &ProxyArgs) -> Result> { - warn_unknown_env(); let rules = build_rules(args).map_err(Report::from)?; if rules.0.is_empty() { return Err(Report::new(ConfigError::NoRule)); @@ -215,8 +184,7 @@ pub fn resolve(args: &ProxyArgs) -> Result> }) } -/// Credential precedence: `--basic-auth-file` > `--basic-auth` > env (the env -/// value already arrives via clap's `env` on `--basic-auth`). +/// Credential precedence: `--basic-auth-file` > `--basic-auth`. fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { if let Some(path) = &args.basic_auth_file { let raw = std::fs::read_to_string(path).map_err(|_| ConfigError::BasicAuth)?; @@ -275,36 +243,6 @@ mod tests { assert!(resolve(&args).is_err(), "malformed --map errors"); } - #[test] - fn env_map_used_only_when_no_map_or_from_to() { - // SAFETY: single-threaded test; set then remove the env var. - // Used when no --map/-f/-t: env rule applies. - unsafe { - std::env::set_var( - "TS_DEV_PROXY_MAP", - "a.example.com=b.edgecompute.app,c.example.com=d.edgecompute.app", - ) - }; - let cfg = resolve(&base_args()).expect("env map resolves"); - assert!( - cfg.rules.first_match("a.example.com").is_some(), - "first env rule applied" - ); - assert!( - cfg.rules.first_match("c.example.com").is_some(), - "second env rule applied" - ); - // Ignored when a flag rule is present (flags > env). - let mut args = base_args(); - args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - let cfg = resolve(&args).expect("flag rule resolves"); - assert!( - cfg.rules.first_match("a.example.com").is_none(), - "env ignored when --map present" - ); - unsafe { std::env::remove_var("TS_DEV_PROXY_MAP") }; - } - #[test] fn non_loopback_listen_requires_flag() { let mut args = base_args(); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index ad9706847..0868c0146 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -41,7 +41,7 @@ pub struct ProxyArgs { pub to: Option, /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. - #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080", env = "TS_DEV_PROXY_LISTEN")] + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080")] pub listen: String, /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). @@ -49,15 +49,15 @@ pub struct ProxyArgs { pub allow_non_loopback: bool, /// Browsers to launch + configure (comma list or `all`). - #[arg(long, value_name = "LIST", env = "TS_DEV_PROXY_LAUNCH")] + #[arg(long, value_name = "LIST")] pub launch: Option, /// Send `Host: ` upstream instead of the default ``. - #[arg(long, env = "TS_DEV_PROXY_REWRITE_HOST")] + #[arg(long)] pub rewrite_host: bool, /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). - #[arg(long, value_name = "USER:PASS", env = "TS_DEV_PROXY_BASIC_AUTH")] + #[arg(long, value_name = "USER:PASS")] pub basic_auth: Option, /// Read `USER:PASS` from a file (preferred over `--basic-auth`). @@ -65,7 +65,7 @@ pub struct ProxyArgs { pub basic_auth_file: Option, /// Skip upstream certificate verification. - #[arg(long, env = "TS_DEV_PROXY_INSECURE")] + #[arg(long)] pub insecure: bool, /// Connect to upstream over plaintext HTTP. From 092611eed062c17415dc71a783b59c25991a59c2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:25:18 -0700 Subject: [PATCH 07/40] Drop env-var support from dev proxy spec and plan --- .../plans/2026-06-22-ts-dev-proxy.md | 75 ++++--------------- .../specs/2026-06-22-ts-dev-proxy-design.md | 35 +++------ 2 files changed, 25 insertions(+), 85 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index de60157bf..c5047fda4 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -102,7 +102,7 @@ tokio-rustls = "0.26" rcgen = "0.13" time = "0.3" rustls-pemfile = "2" -clap = { version = "4", features = ["derive", "env"] } +clap = { version = "4", features = ["derive"] } error-stack = "0.6" derive_more = { version = "2.0", features = ["display", "error"] } log = "0.4" @@ -274,7 +274,7 @@ pub struct ProxyArgs { pub to: Option, /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. - #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080", env = "TS_DEV_PROXY_LISTEN")] + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080")] pub listen: String, /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). @@ -282,15 +282,15 @@ pub struct ProxyArgs { pub allow_non_loopback: bool, /// Browsers to launch + configure (comma list or `all`). - #[arg(long, value_name = "LIST", env = "TS_DEV_PROXY_LAUNCH")] + #[arg(long, value_name = "LIST")] pub launch: Option, /// Send `Host: ` upstream instead of the default ``. - #[arg(long, env = "TS_DEV_PROXY_REWRITE_HOST")] + #[arg(long)] pub rewrite_host: bool, /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). - #[arg(long, value_name = "USER:PASS", env = "TS_DEV_PROXY_BASIC_AUTH")] + #[arg(long, value_name = "USER:PASS")] pub basic_auth: Option, /// Read `USER:PASS` from a file (preferred over `--basic-auth`). @@ -298,7 +298,7 @@ pub struct ProxyArgs { pub basic_auth_file: Option, /// Skip upstream certificate verification. - #[arg(long, env = "TS_DEV_PROXY_INSECURE")] + #[arg(long)] pub insecure: bool, /// Connect to upstream over plaintext HTTP. @@ -645,9 +645,9 @@ git commit -m "Add rewrite core with rule matching and header outcomes" --- -## Task 3: Config resolution (args + env + rule construction) +## Task 3: Config resolution (args + rule construction) -Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective settings. Pure logic except project-config inference, which is deferred to Task 7 (here, missing rules produce a clear error). Implements spec §10.1 precedence (flags > env > inference > defaults). Scalar env vars (`TS_DEV_PROXY_LISTEN`/`LAUNCH`/`BASIC_AUTH`/`REWRITE_HOST`/`INSECURE`) arrive via clap's `env`; `TS_DEV_PROXY_MAP` is read **explicitly** in `build_rules` (clap `env` on a `Vec` can't express the "only when no `--map`/`-f`/`-t`" rule). +Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective settings. Pure logic except project-config inference, which is deferred to Task 7 (here, missing rules produce a clear error). Implements spec §10.1 precedence (flags > inference > defaults). The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable overrides. **Files:** - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/config.rs` @@ -705,22 +705,6 @@ mod tests { assert!(resolve(&args).is_err(), "malformed --map errors"); } - #[test] - fn env_map_used_only_when_no_map_or_from_to() { - // SAFETY: single-threaded test; set then remove the env var. - // Used when no --map/-f/-t: env rule applies. - unsafe { std::env::set_var("TS_DEV_PROXY_MAP", "a.example.com=b.edgecompute.app,c.example.com=d.edgecompute.app") }; - let cfg = resolve(&base_args()).expect("env map resolves"); - assert!(cfg.rules.first_match("a.example.com").is_some(), "first env rule applied"); - assert!(cfg.rules.first_match("c.example.com").is_some(), "second env rule applied"); - // Ignored when a flag rule is present (flags > env). - let mut args = base_args(); - args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - let cfg = resolve(&args).expect("flag rule resolves"); - assert!(cfg.rules.first_match("a.example.com").is_none(), "env ignored when --map present"); - unsafe { std::env::remove_var("TS_DEV_PROXY_MAP") }; - } - #[test] fn non_loopback_listen_requires_flag() { let mut args = base_args(); @@ -877,25 +861,6 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) } -/// Warns about unrecognized `TS_DEV_PROXY_*` environment variables (spec §10.3). -/// `TS_DEV_PROXY_CA_DIR` is intentionally absent here — `--ca-dir` is not -/// env-driven, so setting it warns (and is ignored). -fn warn_unknown_env() { - const KNOWN: &[&str] = &[ - "TS_DEV_PROXY_LISTEN", - "TS_DEV_PROXY_MAP", - "TS_DEV_PROXY_LAUNCH", - "TS_DEV_PROXY_BASIC_AUTH", - "TS_DEV_PROXY_REWRITE_HOST", - "TS_DEV_PROXY_INSECURE", - ]; - for (name, _) in std::env::vars() { - if name.starts_with("TS_DEV_PROXY_") && !KNOWN.contains(&name.as_str()) { - crate::output::warn(&format!("ignoring unknown environment variable {name}")); - } - } -} - fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); let preserve_host = !args.rewrite_host; @@ -906,16 +871,6 @@ fn build_rules(args: &ProxyArgs) -> Result { if let (Some(from), Some(to)) = (&args.from, &args.to) { rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); } - // TS_DEV_PROXY_MAP is consulted only when NO --map/-f/-t was given (flags > env, - // spec §10.1/§10.3). clap's `env` on a Vec can't express that, so read it here. - if args.map.is_empty() && args.from.is_none() && args.to.is_none() { - if let Ok(env_map) = std::env::var("TS_DEV_PROXY_MAP") { - for entry in env_map.split(',').map(str::trim).filter(|s| !s.is_empty()) { - let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; - rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); - } - } - } // NOTE: lone --to / lone --from + project-config inference is added in Task 7. Ok(RuleTable(rules)) } @@ -931,7 +886,6 @@ fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Resu /// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen /// address, malformed credentials, or an unknown browser. pub fn resolve(args: &ProxyArgs) -> error_stack::Result { - warn_unknown_env(); let rules = build_rules(args).map_err(Report::from)?; if rules.0.is_empty() { return Err(Report::new(ConfigError::NoRule)); @@ -968,8 +922,7 @@ pub fn resolve(args: &ProxyArgs) -> error_stack::Result `--basic-auth` > env (the env -/// value already arrives via clap's `env` on `--basic-auth`). +/// Credential precedence: `--basic-auth-file` > `--basic-auth`. fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { if let Some(path) = &args.basic_auth_file { let raw = std::fs::read_to_string(path).map_err(|_| ConfigError::BasicAuth)?; @@ -985,7 +938,7 @@ fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError - [ ] **Step 4: Run the tests to verify they pass** Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" config::` -Expected: PASS (7 tests). +Expected: PASS (6 tests). - [ ] **Step 5: Wire `resolve` into `run` and lint** @@ -1801,7 +1754,7 @@ git commit -m "Document ts dev proxy setup, trust, and troubleshooting" - §7 CA (load-or-generate, 0600/0700, mint+cache, install/uninstall) → Tasks 4, 6. ✓ - §8 rewrite (Authority/RuleTable/matching/header outcomes/port-vs-SNI) → Task 2. ✓ - §9 browser orchestration (HTTPS-only Chrome/Firefox, Safari PAC + active-service) → Task 6. ✓ -- §10 config (precedence, env, inference) → Tasks 3, 7. ✓ +- §10 config (precedence, inference; flags-only — no env vars) → Tasks 3, 7. ✓ - §11 security (non-loopback guard, redaction, credential input, blind-tunnel privacy) → Tasks 3, 5. ✓ - §12 constants → encoded in Tasks 2/4 (ports, ALPN, validity, CN). ✓ - §13 error handling → Task 5 status mapping + Task 8 troubleshooting table. ✓ @@ -1812,11 +1765,13 @@ git commit -m "Document ts dev proxy setup, trust, and troubleshooting" **Type consistency:** `Authority::{host,host_with_port,is_default_port}` (now scheme-relative via the stored `default_port`), `RuleTable::first_match`, `rewrite_for → RewriteOutcome{sni,host_header,orig_host,scheme_is_tls}`, `ResolvedConfig`, `config::ca_dir`, `CertAuthority::{load_or_generate,server_config,cert_path}`, `server::{bind,serve_on}`, `Browser::parse_list`, `generate_pac` are used consistently across tasks. -**Review-round fixes (2026-06-22):** (1) crate is a **lib + bin** so integration tests reach internal modules; (2) `ca` subcommands resolve via `config::ca_dir` *before* rule resolution; (3) `run` binds the listener, spawns `serve_on`, launches browsers via `spawn_blocking`, then awaits the server — correct ordering and the runtime stays alive; (4) `Authority` stores its scheme `default_port` so `:80`/`:443` are kept/omitted per scheme; (5) `TS_DEV_PROXY_MAP` is parsed explicitly in `build_rules` with flags-over-env precedence. +**Review-round fixes (2026-06-22):** (1) crate is a **lib + bin** so integration tests reach internal modules; (2) `ca` subcommands resolve via `config::ca_dir` *before* rule resolution; (3) `run` binds the listener, spawns `serve_on`, launches browsers via `spawn_blocking`, then awaits the server — correct ordering and the runtime stays alive; (4) `Authority` stores its scheme `default_port` so `:80`/`:443` are kept/omitted per scheme. **Second review round (2026-06-22):** (6) `ca` is a **nested** subcommand (`ProxySub::Ca { action }`) so the path is `ts dev proxy ca `, not `ts dev proxy `; (7) `ca path`/`ca install` call `load_or_generate` first so a fresh machine works before any proxy run; (8) `default_ca_dir` builds `…/trusted-server/dev-proxy` from `XDG_DATA_HOME`/`BaseDirs` (not `ProjectDirs`, which yields a reverse-DNS leaf); (9) CA validity is ~10 years and the leaf ≤ 90 days, both `now`-relative via `time`; (10) the blind-tunnel and basic-auth E2E tests have real assertions, plus a new keep-alive/sequential-request test. -**Third review round (2026-06-22):** (11) `mint` builds the SAN explicitly — `SanType::IpAddress` for an IP-literal host, `SanType::DnsName` otherwise (spec §8.3), with a `127.0.0.1` test; (12) `resolve` calls `warn_unknown_env`, warning on unrecognized `TS_DEV_PROXY_*` vars (spec §10.3). +**Third review round (2026-06-22):** (11) `mint` builds the SAN explicitly — `SanType::IpAddress` for an IP-literal host, `SanType::DnsName` otherwise (spec §8.3), with a `127.0.0.1` test. + +**Scope change (2026-06-22):** environment-variable support (`TS_DEV_PROXY_*`, former spec §10.3) was **dropped** — the tool is flags-only. `ProxyArgs` no longer carries clap `env`, `build_rules` has no `TS_DEV_PROXY_MAP` path, and `warn_unknown_env` is gone (config tests: 6). --- diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index b3e947b1a..4552dce94 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -124,7 +124,7 @@ ts dev proxy [OPTIONS] | `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | | `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | | `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | -| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file` or `TS_DEV_PROXY_BASIC_AUTH`. | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | | `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | | `--insecure` | flag | false | Skip **upstream** certificate verification. | | `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | @@ -460,7 +460,7 @@ with the others. ### 10.1 Precedence -CLI flags > env vars (§10.3) > project-config inference (§10.2) > built-in +CLI flags > project-config inference (§10.2) > built-in defaults. `--map`/`-f`/`-t` rules are unioned (first-match-wins by declared order). `--from` and `--to` may be supplied independently: a lone `--to` pairs with the inferred `FROM`, and a lone `--from` pairs with the inferred `TO` @@ -485,27 +485,13 @@ config so the common case is argument-free: upstream = "trusted-server-example.edgecompute.app" ``` -Until `[dev_proxy].upstream` (or `TS_DEV_PROXY_MAP`) exists, zero-arg `ts dev -proxy` cannot infer `TO`: exit with a clear error showing the inferred `FROM` and -asking for `--to`/`--map`. If `FROM` is ambiguous (multiple publishers), list -candidates. +Until `[dev_proxy].upstream` exists, zero-arg `ts dev proxy` cannot infer `TO`: +exit with a clear error showing the inferred `FROM` and asking for `--to`/`--map`. +If `FROM` is ambiguous (multiple publishers), list candidates. -### 10.3 Environment variables - -Each variable is honored **only when its corresponding flag is absent** (flags > -env > inference > defaults, §10.1) and is read once at startup. - -| Variable | Maps to | Syntax / behavior | -|---|---|---| -| `TS_DEV_PROXY_LISTEN` | `--listen` | `ADDR` (e.g. `127.0.0.1:8080`). | -| `TS_DEV_PROXY_MAP` | `--map` | `FROM=TO[,FROM=TO…]` (comma-separated, first-match-wins). Used only when **no** `--map`/`-f`/`-t` is given — the two sources are not merged. | -| `TS_DEV_PROXY_LAUNCH` | `--launch` | `chrome,firefox,safari` \| `all`. | -| `TS_DEV_PROXY_BASIC_AUTH` | `--basic-auth` | `USER:PASS`. If more than one auth source is set, precedence is `--basic-auth-file` > `--basic-auth` > env. | -| `TS_DEV_PROXY_REWRITE_HOST` | `--rewrite-host` | `1`/`true` sends `Host = TO`. | -| `TS_DEV_PROXY_INSECURE` | `--insecure` | `1`/`true` skips upstream verification. | - -`--ca-dir` is intentionally **not** env-driven (it changes which CA is trusted — -keep it explicit). Unknown `TS_DEV_PROXY_*` names are ignored with a warning. +The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable +overrides. Every setting is a CLI flag (§4); the only file inputs are +`trusted-server.toml` (inference, §10.2) and `--basic-auth-file`. --- @@ -532,9 +518,8 @@ keep it explicit). Unknown `TS_DEV_PROXY_*` names are ignored with a warning. - **No secret logging.** Redact `Authorization` and `Cookie`; log method, host, path, and chosen upstream only. - **Credential input.** `--basic-auth USER:PASS` is **convenience only** — argv - is visible via `ps` and shell history. Prefer `--basic-auth-file` or the - `TS_DEV_PROXY_BASIC_AUTH` env var; the file is read once at startup and never - logged. + is visible via `ps` and shell history. Prefer `--basic-auth-file`; the file is + read once at startup and never logged. - **Only matched hosts are decrypted.** Launched browsers proxy **HTTPS only** (§9) and unmatched CONNECT authorities are blind-tunneled (§5), so unrelated browsing is never MITM'd. From ed37bb8edd9033fd1326799976559722684f6aed Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:45:34 -0700 Subject: [PATCH 08/40] Report I/O failure distinctly when --basic-auth-file cannot be read Add ConfigError::BasicAuthFile variant with a path-carrying display message so file-not-found and permission errors are no longer reported as the misleading "--basic-auth must be USER:PASS" format error. The read failure now maps to BasicAuthFile; only parse failures map to BasicAuth. Includes a unit test that asserts the correct variant is returned for a missing file. --- .../src/commands/dev/proxy/config.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 2907dc5ff..40511be50 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -27,6 +27,9 @@ pub enum ConfigError { /// `--basic-auth`/file value was not `USER:PASS`. #[display("--basic-auth must be USER:PASS")] BasicAuth, + /// `--basic-auth-file` could not be read. + #[display("cannot read --basic-auth-file `{path}`")] + BasicAuthFile { path: String }, /// An unknown browser name was passed to `--launch`. #[display("unknown browser `{value}` (expected chrome|firefox|safari|all)")] Browser { value: String }, @@ -187,7 +190,8 @@ pub fn resolve(args: &ProxyArgs) -> Result> /// Credential precedence: `--basic-auth-file` > `--basic-auth`. fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { if let Some(path) = &args.basic_auth_file { - let raw = std::fs::read_to_string(path).map_err(|_| ConfigError::BasicAuth)?; + let raw = std::fs::read_to_string(path) + .map_err(|_| ConfigError::BasicAuthFile { path: path.clone() })?; return Ok(Some(BasicAuth::parse(raw.trim())?)); } match &args.basic_auth { @@ -278,4 +282,20 @@ mod tests { ); assert!(Browser::parse_list("netscape").is_err(), "unknown browser errors"); } + + #[test] + fn basic_auth_file_missing_is_a_file_error() { + let dir = tempfile::tempdir().expect("should create temp dir"); + let missing = dir.path().join("no-such-file.txt"); + + let mut args = base_args(); + args.map = vec!["a.example.com=b.edgecompute.app".into()]; + args.basic_auth_file = Some(missing.to_string_lossy().into_owned()); + + let err = resolve(&args).expect_err("should fail when file is missing"); + assert!( + matches!(err.current_context(), ConfigError::BasicAuthFile { .. }), + "should be a BasicAuthFile error, not BasicAuth" + ); + } } From 8715a7f963cff5a4af6e8d7a5bbe3febf107a8e3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:02:10 -0700 Subject: [PATCH 09/40] Add per-machine local CA with leaf minting and caching --- crates/trusted-server-cli/Cargo.lock | 2325 +++++++++++++++++ crates/trusted-server-cli/Cargo.toml | 2 +- .../src/commands/dev/proxy/ca.rs | 267 +- 3 files changed, 2592 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-cli/Cargo.lock diff --git a/crates/trusted-server-cli/Cargo.lock b/crates/trusted-server-cli/Cargo.lock new file mode 100644 index 000000000..9a8317856 --- /dev/null +++ b/crates/trusted-server-cli/Cargo.lock @@ -0,0 +1,2325 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-stack" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878b3fac9613c3c7f22eb70bc8a3c6ebdc03cc11479ee60fde1692d747fd45f" +dependencies = [ + "anyhow", + "rustc_version", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-server-cli" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "derive_more", + "directories", + "env_logger", + "error-stack", + "hyper", + "hyper-util", + "log", + "rcgen", + "reqwest", + "rustls", + "rustls-pemfile", + "tempfile", + "time", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 7c9a2d3d1..588a76235 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -20,7 +20,7 @@ hyper = { version = "1", features = ["http1", "server", "client"] } hyper-util = { version = "0.1", features = ["tokio"] } rustls = "0.23" tokio-rustls = "0.26" -rcgen = "0.13" +rcgen = { version = "0.13", features = ["x509-parser"] } time = "0.3" rustls-pemfile = "2" clap = { version = "4", features = ["derive"] } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs index 2f192f12b..e41c4d6b0 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -1 +1,266 @@ -//! Certificate authority management for the `ts dev proxy ca` subcommand. +//! Per-machine local CA: load-or-generate, mint and cache per-host leaves (spec §7). + +use std::collections::HashMap; +use std::fs; +use std::net::IpAddr; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use error_stack::{Report, ResultExt as _}; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, + SanType, +}; +use rustls::ServerConfig; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; + +/// Distinguished CA common name (spec §12). +pub const CA_COMMON_NAME: &str = + "Trusted Server DEV-ONLY Proxy CA \u{2014} DO NOT TRUST IN PRODUCTION"; + +const CA_CERT_FILE: &str = "ca-cert.pem"; +const CA_KEY_FILE: &str = "ca-key.pem"; +const LEAF_VALIDITY_DAYS: i64 = 90; + +/// Errors from the certificate authority. +#[derive(Debug, derive_more::Display)] +pub enum CaError { + /// The CA directory could not be created or secured. + #[display("cannot prepare CA directory")] + Dir, + /// Reading/writing a CA PEM file failed. + #[display("CA file I/O failed")] + Io, + /// Certificate generation/signing failed. + #[display("certificate generation failed")] + Generate, + /// Building the rustls server config failed. + #[display("rustls server config failed")] + Rustls, +} + +impl core::error::Error for CaError {} + +/// Loaded CA material plus a per-host leaf cache. +pub struct CertAuthority { + /// CA certificate — kept alive to pass as `issuer` to `signed_by`. + ca_cert: Certificate, + /// CA key pair used when signing leaf certificates. + ca_key: KeyPair, + /// DER-encoded CA cert included in each leaf's certificate chain. + ca_cert_der: CertificateDer<'static>, + /// Per-host cache of minted `ServerConfig` instances. + leaves: Mutex>>, +} + +impl CertAuthority { + /// Path to the CA certificate under `ca_dir`. + #[must_use] + pub fn cert_path(ca_dir: &Path) -> PathBuf { + ca_dir.join(CA_CERT_FILE) + } + + /// Loads the CA from `ca_dir`, generating and persisting it on first run. + /// + /// The directory is created with mode `0700` and the key file is written + /// with mode `0600`. On first run a trust hint is logged. + /// + /// # Errors + /// + /// Returns [`CaError`] on directory, I/O, or certificate generation failures. + pub fn load_or_generate(ca_dir: &Path) -> Result> { + let cert_path = ca_dir.join(CA_CERT_FILE); + let key_path = ca_dir.join(CA_KEY_FILE); + + let (cert_pem, key_pem) = if cert_path.exists() && key_path.exists() { + ( + fs::read_to_string(&cert_path).change_context(CaError::Io)?, + fs::read_to_string(&key_path).change_context(CaError::Io)?, + ) + } else { + let (cert_pem, key_pem) = Self::generate_pems()?; + Self::persist(ca_dir, &cert_path, &key_path, &cert_pem, &key_pem)?; + log::info!( + "generated dev CA at {} — run `ts dev proxy ca install` to trust it", + cert_path.display() + ); + (cert_pem, key_pem) + }; + + let ca_key = KeyPair::from_pem(&key_pem).change_context(CaError::Generate)?; + let ca_params = + CertificateParams::from_ca_cert_pem(&cert_pem).change_context(CaError::Generate)?; + let ca_cert_der = pem_to_cert_der(&cert_pem)?; + // Reconstruct the Certificate struct so we can pass it as issuer to signed_by. + let ca_cert = ca_params.self_signed(&ca_key).change_context(CaError::Generate)?; + + Ok(Self { + ca_cert, + ca_key, + ca_cert_der, + leaves: Mutex::new(HashMap::new()), + }) + } + + /// Returns a cached or freshly minted leaf [`ServerConfig`] for `host`. + /// + /// Minting happens outside the cache lock; a double-check after re-acquiring + /// the lock ensures concurrent callers for the same host return the same [`Arc`]. + /// + /// # Errors + /// + /// Returns [`CaError`] if leaf minting or rustls config construction fails. + pub fn server_config(&self, host: &str) -> Result, Report> { + // Fast path: return a cached config without holding the lock during minting. + { + let cache = self + .leaves + .lock() + .expect("should be able to acquire leaf cache lock"); + if let Some(existing) = cache.get(host) { + return Ok(Arc::clone(existing)); + } + } + + let config = Arc::new(self.mint(host)?); + + let mut cache = self + .leaves + .lock() + .expect("should be able to acquire leaf cache lock"); + // Double-check: another task may have minted concurrently. + let entry = cache.entry(host.to_string()).or_insert(config); + Ok(Arc::clone(entry)) + } + + fn mint(&self, host: &str) -> Result> { + let leaf_key = KeyPair::generate().change_context(CaError::Generate)?; + + // Build the SAN explicitly: an IP-literal host gets an IP-type SAN (not DNS). + let san = match host.parse::() { + Ok(ip) => SanType::IpAddress(ip), + Err(_) => { + let ia5 = rcgen::Ia5String::try_from(host).change_context(CaError::Generate)?; + SanType::DnsName(ia5) + } + }; + + let mut params = + CertificateParams::new(Vec::::new()).change_context(CaError::Generate)?; + params.subject_alt_names = vec![san]; + + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(LEAF_VALIDITY_DAYS); + + let leaf = params + .signed_by(&leaf_key, &self.ca_cert, &self.ca_key) + .change_context(CaError::Generate)?; + + let chain = vec![leaf.der().clone(), self.ca_cert_der.clone()]; + let key_der = PrivateKeyDer::try_from(leaf_key.serialize_der()) + .map_err(|_| Report::new(CaError::Rustls))?; + + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(chain, key_der) + .change_context(CaError::Rustls)?; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(config) + } + + fn generate_pems() -> Result<(String, String), Report> { + let key = KeyPair::generate().change_context(CaError::Generate)?; + let mut params = + CertificateParams::new(Vec::::new()).change_context(CaError::Generate)?; + params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + // ~10 years from generation (spec §7.1); rotate via `ca regenerate`. + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(3650); + let cert = params.self_signed(&key).change_context(CaError::Generate)?; + Ok((cert.pem(), key.serialize_pem())) + } + + fn persist( + ca_dir: &Path, + cert_path: &Path, + key_path: &Path, + cert_pem: &str, + key_pem: &str, + ) -> Result<(), Report> { + fs::create_dir_all(ca_dir).change_context(CaError::Dir)?; + fs::set_permissions(ca_dir, fs::Permissions::from_mode(0o700)) + .change_context(CaError::Dir)?; + fs::write(cert_path, cert_pem).change_context(CaError::Io)?; + fs::write(key_path, key_pem).change_context(CaError::Io)?; + fs::set_permissions(key_path, fs::Permissions::from_mode(0o600)) + .change_context(CaError::Io)?; + Ok(()) + } +} + +fn pem_to_cert_der(cert_pem: &str) -> Result, Report> { + let mut reader = std::io::BufReader::new(cert_pem.as_bytes()); + rustls_pemfile::certs(&mut reader) + .next() + .ok_or_else(|| Report::new(CaError::Io))? + .change_context(CaError::Io) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_then_reloads_with_0600_key() { + let dir = tempfile::tempdir().expect("should create tempdir"); + let ca1 = CertAuthority::load_or_generate(dir.path()).expect("should generate"); + let key_path = dir.path().join("ca-key.pem"); + assert!(key_path.exists(), "key persisted"); + let mode = std::fs::metadata(&key_path) + .expect("should read key metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "key file is 0600"); + + // Second run reloads the same CA cert bytes (no regeneration). + let cert_before = std::fs::read(dir.path().join("ca-cert.pem")).expect("should read cert"); + let _ca2 = CertAuthority::load_or_generate(dir.path()).expect("should reload"); + let cert_after = std::fs::read(dir.path().join("ca-cert.pem")).expect("should read cert"); + assert_eq!(cert_before, cert_after, "reload does not rewrite the CA"); + drop(ca1); + } + + #[test] + fn leaf_cache_returns_same_arc_for_same_host() { + let dir = tempfile::tempdir().expect("should create tempdir"); + let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); + let a = ca + .server_config("www.example-publisher.com") + .expect("should mint"); + let b = ca + .server_config("www.example-publisher.com") + .expect("should return cached"); + assert!(Arc::ptr_eq(&a, &b), "same host returns the cached Arc"); + let c = ca + .server_config("other.example.com") + .expect("should mint other"); + assert!(!Arc::ptr_eq(&a, &c), "different host mints a new config"); + } + + #[test] + fn mints_leaf_for_ip_literal_host() { + // An IP-literal host must mint successfully (IP-type SAN, not DNS) — spec §8.3. + let dir = tempfile::tempdir().expect("should create tempdir"); + let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); + assert!( + ca.server_config("127.0.0.1").is_ok(), + "IP-literal host mints a leaf" + ); + } +} From 9e98c07f684367a7303e5704164f01bb62caf316 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:36:03 -0700 Subject: [PATCH 10/40] Create CA key file with mode 0600 from first byte Replace the write-then-chmod pattern in CertAuthority::persist() with a single OpenOptions::create_new(true).mode(0o600) open, eliminating the window where the private key was briefly world/group-readable on disk. --- .../trusted-server-cli/src/commands/dev/proxy/ca.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs index e41c4d6b0..43e54a7d4 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -2,7 +2,10 @@ use std::collections::HashMap; use std::fs; +use std::fs::OpenOptions; +use std::io::Write as _; use std::net::IpAddr; +use std::os::unix::fs::OpenOptionsExt as _; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -196,9 +199,13 @@ impl CertAuthority { fs::set_permissions(ca_dir, fs::Permissions::from_mode(0o700)) .change_context(CaError::Dir)?; fs::write(cert_path, cert_pem).change_context(CaError::Io)?; - fs::write(key_path, key_pem).change_context(CaError::Io)?; - fs::set_permissions(key_path, fs::Permissions::from_mode(0o600)) + let mut key_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(key_path) .change_context(CaError::Io)?; + key_file.write_all(key_pem.as_bytes()).change_context(CaError::Io)?; Ok(()) } } From cb67c499ccb4e75745906be02cc50be52f591a98 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:11:23 -0700 Subject: [PATCH 11/40] Add CONNECT MITM proxy server with blind tunnel and local PAC route --- crates/trusted-server-cli/Cargo.lock | 22 + crates/trusted-server-cli/Cargo.toml | 4 + .../src/commands/dev/proxy/mod.rs | 25 +- .../src/commands/dev/proxy/server.rs | 516 +++++++++++++++++ crates/trusted-server-cli/tests/proxy_e2e.rs | 93 ++++ .../trusted-server-cli/tests/support/mod.rs | 527 ++++++++++++++++++ 6 files changed, 1179 insertions(+), 8 deletions(-) create mode 100644 crates/trusted-server-cli/src/commands/dev/proxy/server.rs create mode 100644 crates/trusted-server-cli/tests/proxy_e2e.rs create mode 100644 crates/trusted-server-cli/tests/support/mod.rs diff --git a/crates/trusted-server-cli/Cargo.lock b/crates/trusted-server-cli/Cargo.lock index 9a8317856..e942d9ca1 100644 --- a/crates/trusted-server-cli/Cargo.lock +++ b/crates/trusted-server-cli/Cargo.lock @@ -1846,11 +1846,13 @@ name = "trusted-server-cli" version = "0.1.0" dependencies = [ "base64", + "bytes", "clap", "derive_more", "directories", "env_logger", "error-stack", + "http-body-util", "hyper", "hyper-util", "log", @@ -1862,6 +1864,8 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "webpki-roots 0.26.11", + "x509-parser", ] [[package]] @@ -2013,6 +2017,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 588a76235..117b037b7 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -18,8 +18,11 @@ path = "src/main.rs" tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } hyper = { version = "1", features = ["http1", "server", "client"] } hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" rustls = "0.23" tokio-rustls = "0.26" +webpki-roots = "0.26" rcgen = { version = "0.13", features = ["x509-parser"] } time = "0.3" rustls-pemfile = "2" @@ -34,6 +37,7 @@ directories = "5" [dev-dependencies] tempfile = "3" reqwest = { version = "0.12", features = ["blocking"] } +x509-parser = "0.16" [lints.clippy] unwrap_used = "deny" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 0868c0146..ab864f331 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -1,6 +1,9 @@ pub mod ca; pub mod config; pub mod rewrite; +pub mod server; + +use std::sync::Arc; use error_stack::ResultExt as _; @@ -114,12 +117,18 @@ pub enum CaCommand { /// Returns [`ProxyError`] if configuration, the CA, the server, or browser /// orchestration fails. pub fn run(args: ProxyArgs) -> Result<(), error_stack::Report> { - let cfg = config::resolve(&args).change_context(ProxyError::Config)?; - output::info(&format!( - "ts dev proxy: listen={} rules={} launch={:?}", - cfg.listen, - cfg.rules.0.len(), - cfg.launch, - )); - Ok(()) + let cfg = Arc::new(config::resolve(&args).change_context(ProxyError::Config)?); + let ca = Arc::new( + ca::CertAuthority::load_or_generate(&cfg.ca_dir).change_context(ProxyError::CertAuthority)?, + ); + // PAC generation arrives in Task 6; serve a DIRECT stub for now. + let pac: Arc = Arc::from("function FindProxyForURL(u, h) { return \"DIRECT\"; }"); + let runtime = tokio::runtime::Runtime::new().change_context(ProxyError::Server)?; + runtime.block_on(async move { + let listener = server::bind(cfg.listen) + .await + .change_context(ProxyError::Server)?; + output::info(&format!("ts dev proxy listening on {}", cfg.listen)); + server::serve_on(listener, cfg, ca, pac).await + }) } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs new file mode 100644 index 000000000..e39fc53e9 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -0,0 +1,516 @@ +//! Accept loop, CONNECT dispatch, blind tunnel, MITM, and local routes (spec §5). +//! +//! Each accepted connection's first request line decides the path: +//! a `CONNECT host:port` is matched against [`ResolvedConfig::rules`] *before* +//! replying — a match is MITM'd (a leaf is minted, the TLS stream is decrypted +//! and proxied request-by-request); a non-match is blind-tunnelled on loopback +//! or refused (`403`) off loopback. An origin-form `GET /proxy.pac` is served +//! locally. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use bytes::Bytes; +use error_stack::{Report, ResultExt as _}; +use http_body_util::{BodyExt as _, Full, combinators::BoxBody}; +use hyper::body::Incoming; +use hyper::header::{HeaderName, HeaderValue}; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode, Uri}; +use hyper_util::rt::TokioIo; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +use super::ProxyError; +use super::ca::CertAuthority; +use super::config::ResolvedConfig; +use super::rewrite::rewrite_for; + +const X_ORIG_HOST: &str = "x-orig-host"; + +/// Binds the listen socket. Separate from [`serve_on`] so the caller can open +/// the port (queueing connections) before launching browsers (spec §9, Task 6). +/// +/// # Errors +/// +/// Returns the bind I/O error if the address is unavailable. +pub async fn bind(addr: SocketAddr) -> std::io::Result { + TcpListener::bind(addr).await +} + +/// Accepts and serves connections on `listener` until the task is dropped. +/// +/// One bad connection never tears down the loop: per-connection failures are +/// logged and the loop continues. +/// +/// # Errors +/// +/// Returns [`ProxyError::Server`] only on an unrecoverable accept-loop failure. +pub async fn serve_on( + listener: TcpListener, + cfg: Arc, + ca: Arc, + pac: Arc, +) -> Result<(), Report> { + let is_loopback = is_loopback(cfg.listen.ip()); + log::info!("listening on {}", cfg.listen); + loop { + let (client, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(err) => { + log::warn!("accept failed: {err}"); + continue; + } + }; + let cfg = Arc::clone(&cfg); + let ca = Arc::clone(&ca); + let pac = Arc::clone(&pac); + tokio::spawn(async move { + if let Err(err) = handle_connection(client, is_loopback, &cfg, &ca, &pac).await { + log::debug!("connection from {peer} ended: {err:?}"); + } + }); + } +} + +fn is_loopback(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_loopback(), + IpAddr::V6(v6) => v6.is_loopback(), + } +} + +/// The first request line of an accepted connection, peeked far enough to route. +struct RequestHead { + method: String, + target: String, +} + +impl RequestHead { + /// `Some(host:port)` when the request is a `CONNECT`. + fn connect_authority(&self) -> Option<&str> { + (self.method.eq_ignore_ascii_case("CONNECT")).then_some(self.target.as_str()) + } + + /// Whether this is the local `GET /proxy.pac` route. + fn is_local_pac_route(&self) -> bool { + self.method.eq_ignore_ascii_case("GET") + && (self.target == "/proxy.pac" || self.target.ends_with("/proxy.pac")) + } +} + +/// Reads bytes until the end of the first request line and parses method/target. +/// +/// Only the request line is consumed; for `CONNECT` the rest of the head (the +/// blank-line terminator) is drained so the client's `200` arrives cleanly. +async fn read_request_head(client: &mut TcpStream) -> Result> { + let mut buf = Vec::with_capacity(256); + let mut byte = [0u8; 1]; + // Read up to the end of the headers (\r\n\r\n) or a sane cap. + loop { + let n = client + .read(&mut byte) + .await + .change_context(ProxyError::Server)?; + if n == 0 { + break; + } + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") || buf.len() > 8192 { + break; + } + } + let text = String::from_utf8_lossy(&buf); + let first_line = text.lines().next().unwrap_or_default(); + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let target = parts.next().unwrap_or_default().to_string(); + Ok(RequestHead { method, target }) +} + +async fn handle_connection( + mut client: TcpStream, + is_loopback: bool, + cfg: &ResolvedConfig, + ca: &CertAuthority, + pac: &str, +) -> Result<(), Report> { + let head = read_request_head(&mut client).await?; + if let Some(authority) = head.connect_authority() { + let authority = authority.to_string(); + return handle_connect(client, &authority, is_loopback, cfg, ca).await; + } + if head.is_local_pac_route() { + return serve_pac(&mut client, pac).await; + } + // Stray absolute-form plain HTTP. + if is_loopback { + blind_forward_http(client, &head).await + } else { + respond_status_line(&mut client, StatusCode::FORBIDDEN).await + } +} + +/// Splits `host:port`, defaulting the port to 443. +fn split_authority(authority: &str) -> (String, u16) { + match authority.rsplit_once(':') { + Some((host, port)) => (host.to_string(), port.parse().unwrap_or(443)), + None => (authority.to_string(), 443), + } +} + +async fn handle_connect( + mut client: TcpStream, + authority: &str, + is_loopback: bool, + cfg: &ResolvedConfig, + ca: &CertAuthority, +) -> Result<(), Report> { + let (host, port) = split_authority(authority); + + // Match BEFORE replying, so an unmatched non-loopback request is refused. + if cfg.rules.first_match(&host).is_some() { + write_connect_ok(&mut client).await?; + return mitm(client, &host, cfg, ca).await; + } + + if !is_loopback { + log::warn!("refusing un-mapped CONNECT {host} off loopback"); + return respond_status_line(&mut client, StatusCode::FORBIDDEN).await; + } + + // No match on loopback: connect upstream FIRST, then reply 200 (else 502). + blind_tunnel(client, &host, port).await +} + +/// Connects to the upstream first; on success replies `200` then pipes bytes +/// in both directions without decrypting anything. +async fn blind_tunnel( + mut client: TcpStream, + host: &str, + port: u16, +) -> Result<(), Report> { + let mut upstream = match TcpStream::connect((host, port)).await { + Ok(stream) => stream, + Err(err) => { + log::warn!("blind tunnel to {host}:{port} failed: {err}"); + return respond_status_line(&mut client, StatusCode::BAD_GATEWAY).await; + } + }; + write_connect_ok(&mut client).await?; + match tokio::io::copy_bidirectional(&mut client, &mut upstream).await { + Ok(_) => Ok(()), + Err(err) => { + log::debug!("blind tunnel to {host}:{port} closed: {err}"); + Ok(()) + } + } +} + +async fn write_connect_ok(client: &mut TcpStream) -> Result<(), Report> { + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .change_context(ProxyError::Server)?; + client.flush().await.change_context(ProxyError::Server) +} + +async fn respond_status_line( + client: &mut TcpStream, + status: StatusCode, +) -> Result<(), Report> { + let reason = status.canonical_reason().unwrap_or(""); + let body = format!( + "HTTP/1.1 {} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + status.as_u16(), + ); + client + .write_all(body.as_bytes()) + .await + .change_context(ProxyError::Server)?; + client.flush().await.change_context(ProxyError::Server) +} + +async fn serve_pac(client: &mut TcpStream, pac: &str) -> Result<(), Report> { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/x-ns-proxy-autoconfig\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{pac}", + pac.len(), + ); + client + .write_all(response.as_bytes()) + .await + .change_context(ProxyError::Server)?; + client.flush().await.change_context(ProxyError::Server) +} + +/// Blind-forwards a stray absolute-form plain-HTTP request on loopback by +/// connecting to its authority and replaying the original head. Best-effort: +/// failures are logged, never fatal. +async fn blind_forward_http( + mut client: TcpStream, + head: &RequestHead, +) -> Result<(), Report> { + let Ok(uri) = head.target.parse::() else { + return respond_status_line(&mut client, StatusCode::BAD_REQUEST).await; + }; + let Some(host) = uri.host() else { + return respond_status_line(&mut client, StatusCode::BAD_REQUEST).await; + }; + let port = uri.port_u16().unwrap_or(80); + let mut upstream = match TcpStream::connect((host, port)).await { + Ok(stream) => stream, + Err(err) => { + log::warn!("plain-HTTP forward to {host}:{port} failed: {err}"); + return respond_status_line(&mut client, StatusCode::BAD_GATEWAY).await; + } + }; + let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await; + Ok(()) +} + +/// MITM path: TLS-accept the client with a freshly minted leaf for `host`, then +/// run a hyper server connection whose service rewrites and forwards each +/// request to the upstream over a fresh client connection (spec §5/§8). +async fn mitm( + client: TcpStream, + host: &str, + cfg: &ResolvedConfig, + ca: &CertAuthority, +) -> Result<(), Report> { + let server_config = ca.server_config(host).change_context(ProxyError::Server)?; + let acceptor = TlsAcceptor::from(server_config); + let tls = acceptor + .accept(client) + .await + .change_context(ProxyError::Server)?; + + let host = host.to_string(); + let log_host = host.clone(); + let service = service_fn(move |req: Request| { + // Clone the per-request inputs into the future. + let host = host.clone(); + let rules = cfg.rules.clone(); + let basic_auth = cfg.basic_auth.clone(); + let insecure = cfg.insecure; + async move { forward_request(req, &host, &rules, basic_auth.as_ref(), insecure).await } + }); + + // serve_connection drives keep-alive: many sequential requests per tunnel. + if let Err(err) = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service) + .await + { + log::debug!("MITM connection for {log_host} ended: {err}"); + } + Ok(()) +} + +/// Rewrites one decrypted request and forwards it to the upstream. +/// +/// This is infallible at the hyper layer — upstream errors become a `502` so +/// the keep-alive tunnel survives a single bad request (spec §11). +async fn forward_request( + req: Request, + connect_host: &str, + rules: &super::rewrite::RuleTable, + basic_auth: Option<&super::config::BasicAuth>, + insecure: bool, +) -> Result>, Report> { + if req.headers().contains_key(hyper::header::UPGRADE) { + log::info!("closing tunnel for {connect_host}: Upgrade (WebSocket) is out of scope"); + return Ok(status_response(StatusCode::NOT_IMPLEMENTED)); + } + + let Some(rule) = rules.first_match(connect_host) else { + // Should not happen: MITM is only entered on a match. + return Ok(status_response(StatusCode::BAD_GATEWAY)); + }; + let outcome = rewrite_for(rule); + let upstream_host = rule.to.host().to_string(); + let upstream_port = rule.to.port; + + match proxy_to_upstream( + req, + &outcome, + basic_auth, + insecure, + &upstream_host, + upstream_port, + ) + .await + { + Ok(response) => Ok(response), + Err(err) => { + log::warn!("upstream {upstream_host}:{upstream_port} failed: {err:?}"); + Ok(status_response(StatusCode::BAD_GATEWAY)) + } + } +} + +async fn proxy_to_upstream( + mut req: Request, + outcome: &super::rewrite::RewriteOutcome, + basic_auth: Option<&super::config::BasicAuth>, + insecure: bool, + upstream_host: &str, + upstream_port: u16, +) -> Result>, Report> { + log::debug!( + "{} {} -> {}:{} (Host={}, X-Orig-Host={})", + req.method(), + redact_target(req.uri()), + upstream_host, + upstream_port, + outcome.host_header, + outcome.orig_host, + ); + + rewrite_headers(req.headers_mut(), outcome, basic_auth); + + let tcp = TcpStream::connect((upstream_host, upstream_port)) + .await + .change_context(ProxyError::Server)?; + + let response = if outcome.scheme_is_tls { + let connector = TlsConnector::from(client_config(insecure)); + let server_name = + ServerName::try_from(outcome.sni.clone()).change_context(ProxyError::Server)?; + let tls = connector + .connect(server_name, tcp) + .await + .change_context(ProxyError::Server)?; + send_over(TokioIo::new(tls), req).await? + } else { + send_over(TokioIo::new(tcp), req).await? + }; + + Ok(response.map(|body| body.boxed())) +} + +/// Drives one HTTP/1.1 request/response over an established (TLS or plain) IO. +async fn send_over( + io: I, + req: Request, +) -> Result, Report> +where + I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static, +{ + let (mut sender, conn) = hyper::client::conn::http1::handshake(io) + .await + .change_context(ProxyError::Server)?; + tokio::spawn(async move { + if let Err(err) = conn.await { + log::debug!("upstream connection closed: {err}"); + } + }); + sender + .send_request(req) + .await + .change_context(ProxyError::Server) +} + +/// Applies the rewrite outcome: upstream `Host`, `X-Orig-Host`, and (only when +/// absent) the injected `Authorization`. The request URI is left origin-form, +/// which is what an HTTP/1.1 upstream expects. +fn rewrite_headers( + headers: &mut hyper::HeaderMap, + outcome: &super::rewrite::RewriteOutcome, + basic_auth: Option<&super::config::BasicAuth>, +) { + if let Ok(value) = HeaderValue::from_str(&outcome.host_header) { + headers.insert(hyper::header::HOST, value); + } + if let Ok(value) = HeaderValue::from_str(&outcome.orig_host) { + headers.insert(HeaderName::from_static(X_ORIG_HOST), value); + } + if let Some(auth) = basic_auth + && !headers.contains_key(hyper::header::AUTHORIZATION) + && let Ok(value) = HeaderValue::from_str(&auth.header_value()) + { + headers.insert(hyper::header::AUTHORIZATION, value); + } +} + +/// Builds a rustls client config: a no-verification verifier when `insecure`, +/// otherwise the bundled webpki roots. +fn client_config(insecure: bool) -> Arc { + let config = if insecure { + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(insecure::NoVerifier)) + .with_no_client_auth() + } else { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + }; + let mut config = config; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Arc::new(config) +} + +fn status_response(status: StatusCode) -> Response> { + let body = Full::new(Bytes::new()) + .map_err(|never| match never {}) + .boxed(); + let mut response = Response::new(body); + *response.status_mut() = status; + response +} + +/// Renders the request target without exposing credentials in query strings. +fn redact_target(uri: &Uri) -> String { + uri.path().to_string() +} + +mod insecure { + use rustls::DigitallySignedStruct; + use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; + use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; + + /// A verifier that accepts any upstream certificate — only used under + /// `--insecure` for local development against self-signed origins. + #[derive(Debug)] + pub struct NoVerifier; + + impl ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::aws_lc_rs::default_provider() + .signature_verification_algorithms + .supported_schemes() + } + } +} diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs new file mode 100644 index 000000000..8c1e8d279 --- /dev/null +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -0,0 +1,93 @@ +//! End-to-end proxy tests: matched hosts are MITM'd and rewritten; unmatched +//! hosts on loopback are blind-tunnelled; injected Basic auth clears a gate; and +//! one keep-alive tunnel carries many sequential requests (spec §5/§8/§11/§14). +//! +//! Run with: cargo test --manifest-path crates/trusted-server-cli/Cargo.toml \ +//! --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e + +use std::sync::Arc; + +use trusted_server_cli::commands::dev::proxy::{ca, config}; + +mod support; + +#[tokio::test] +async fn matched_host_is_rewritten_and_forwarded() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let response = support::drive_request_through_proxy(cfg, ca).await; + + assert_eq!(response.status, 200, "response streamed back"); + assert_eq!( + response.seen_host, + support::FROM_HOST, + "Host preserved as FROM" + ); + assert_eq!( + response.seen_orig_host, + support::FROM_HOST, + "X-Orig-Host is FROM" + ); +} + +#[tokio::test] +async fn unmatched_host_is_blind_tunneled_on_loopback() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_without_rules(); + let ca = Arc::new(support::dev_ca()); + + let observed = support::connect_through_proxy_capturing_cert( + cfg, + ca, + &upstream.addr, + "upstream.localhost", + ) + .await; + + assert_eq!( + observed.issuer_common_name, "upstream.localhost", + "blind tunnel presents the upstream cert" + ); + assert_ne!( + observed.issuer_common_name, + ca::CA_COMMON_NAME, + "proxy did not MITM an unmatched host" + ); +} + +#[tokio::test] +async fn basic_auth_injected_when_absent_clears_401() { + let upstream = support::start_gated_upstream().await; + let mut cfg = support::test_config(&upstream.addr); + cfg.basic_auth = Some(config::BasicAuth { + user: "dev".into(), + pass: "secret".into(), + }); + let ca = Arc::new(support::dev_ca()); + + let response = support::drive_request_through_proxy(cfg, ca).await; + + assert_eq!(response.status, 200, "injected Basic auth clears the 401"); +} + +#[tokio::test] +async fn keep_alive_serves_multiple_sequential_requests() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let responses = support::drive_sequential_requests(cfg, ca, &["/one", "/two"]).await; + + assert_eq!(responses.len(), 2, "both requests answered"); + assert!( + responses.iter().all(|r| r.status == 200), + "each request gets 200" + ); + assert_eq!(responses[0].path, "/one", "first request"); + assert_eq!( + responses[1].path, "/two", + "second request reused the tunnel" + ); +} diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs new file mode 100644 index 000000000..a2d4033a7 --- /dev/null +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -0,0 +1,527 @@ +//! Shared fixtures for the proxy end-to-end tests: a self-signed TLS upstream, +//! a dev CA in a tempdir, and proxy-aware clients built on tokio + tokio-rustls. + +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; + +use rustls::DigitallySignedStruct; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; +use trusted_server_cli::commands::dev::proxy::{ca, config, server}; + +/// The production hostname the matched rule rewrites from (and preserves). +pub const FROM_HOST: &str = "www.example-publisher.com"; + +/// What the echo upstream reports back to the test. +pub struct ProxiedResponse { + pub status: u16, + pub seen_host: String, + pub seen_orig_host: String, + pub path: String, +} + +/// A running upstream and the loopback address it bound. +pub struct Upstream { + pub addr: SocketAddr, +} + +/// The leaf certificate the client observed at the end of a tunnel. +pub struct ObservedCert { + pub issuer_common_name: String, +} + +/// A dev [`CertAuthority`] generated in a fresh tempdir. +/// +/// The tempdir is leaked so the CA files outlive the test (they are tiny and +/// the test process is short-lived). +pub fn dev_ca() -> ca::CertAuthority { + let dir = tempfile::tempdir().expect("should create tempdir"); + let ca = ca::CertAuthority::load_or_generate(dir.path()).expect("should generate dev CA"); + // Keep the directory alive for the duration of the process. + std::mem::forget(dir); + ca +} + +/// Builds a config mapping [`FROM_HOST`] to the upstream `addr`, preserving the +/// FROM host, listening on an ephemeral loopback port, with `insecure = true`. +pub fn test_config(addr: &SocketAddr) -> config::ResolvedConfig { + let map = format!("{FROM_HOST}={}", addr); + resolve(&["ts", "--map", &map, "--listen", "127.0.0.1:0", "--insecure"]) +} + +/// A config with no rewrite rules (every CONNECT is unmatched), on loopback. +pub fn test_config_without_rules() -> config::ResolvedConfig { + // resolve() rejects an empty rule table, so map an unrelated host the tests + // never CONNECT to. The host under test stays unmatched → blind tunnel. + resolve(&[ + "ts", + "--map", + "unused.example.com=127.0.0.1:1", + "--listen", + "127.0.0.1:0", + "--insecure", + ]) +} + +fn resolve(argv: &[&str]) -> config::ResolvedConfig { + use clap::Parser as _; + #[derive(clap::Parser)] + struct Wrapper { + #[command(flatten)] + args: trusted_server_cli::commands::dev::proxy::ProxyArgs, + } + let parsed = Wrapper::parse_from(argv); + config::resolve(&parsed.args).expect("should resolve test config") +} + +// ---- self-signed upstream certificate (CN/SAN upstream.localhost) ---- + +fn upstream_identity() -> (Vec>, PrivateKeyDer<'static>) { + use rcgen::{CertificateParams, DnType, KeyPair, SanType}; + + let key_pair = KeyPair::generate().expect("should generate upstream key"); + let mut params = + CertificateParams::new(Vec::::new()).expect("should build cert params"); + // Subject == issuer for a self-signed cert; the test asserts on issuer CN. + params + .distinguished_name + .push(DnType::CommonName, "upstream.localhost"); + params.subject_alt_names = vec![ + SanType::DnsName("upstream.localhost".try_into().expect("dns san")), + SanType::DnsName("localhost".try_into().expect("dns san")), + SanType::IpAddress("127.0.0.1".parse().expect("ip san")), + ]; + let cert = params + .self_signed(&key_pair) + .expect("should self-sign upstream cert"); + let cert_der = CertificateDer::from(cert.der().to_vec()); + let key_der = + PrivateKeyDer::try_from(key_pair.serialize_der()).expect("should encode upstream key"); + (vec![cert_der], key_der) +} + +fn upstream_tls_acceptor() -> TlsAcceptor { + let (chain, key) = upstream_identity(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(chain, key) + .expect("should build upstream server config"); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + TlsAcceptor::from(Arc::new(config)) +} + +/// Starts an HTTPS upstream that echoes the `Host`/`X-Orig-Host`/path it saw and +/// always returns `200`. Serves keep-alive (many requests per connection). +pub async fn start_echo_upstream() -> Upstream { + start_upstream(false).await +} + +/// Starts an HTTPS upstream that returns `401` unless an `Authorization` header +/// is present, otherwise `200`. +pub async fn start_gated_upstream() -> Upstream { + start_upstream(true).await +} + +async fn start_upstream(gated: bool) -> Upstream { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind upstream"); + let addr = listener.local_addr().expect("should read upstream addr"); + let acceptor = upstream_tls_acceptor(); + tokio::spawn(async move { + loop { + let Ok((tcp, _)) = listener.accept().await else { + break; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut tls) = acceptor.accept(tcp).await else { + return; + }; + serve_upstream_connection(&mut tls, gated).await; + }); + } + }); + Upstream { addr } +} + +/// Minimal HTTP/1.1 keep-alive loop: parse each request head, echo the headers +/// the test cares about, respond, repeat until the peer closes. +async fn serve_upstream_connection(stream: &mut S, gated: bool) +where + S: AsyncReadExt + AsyncWriteExt + Unpin, +{ + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + // Read until we have a full header block. + let head_end = loop { + if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + break pos + 4; + } + let n = match stream.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + buf.extend_from_slice(&chunk[..n]); + }; + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + buf.drain(..head_end); + + let path = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let host = header_value(&head, "host").unwrap_or_default(); + let orig_host = header_value(&head, "x-orig-host").unwrap_or_default(); + let has_auth = header_value(&head, "authorization").is_some(); + + let (status_line, body) = if gated && !has_auth { + ("HTTP/1.1 401 Unauthorized", String::new()) + } else { + let body = format!("host={host};orig={orig_host};path={path}"); + ("HTTP/1.1 200 OK", body) + }; + let response = format!( + "{status_line}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + body.len() + ); + if stream.write_all(response.as_bytes()).await.is_err() { + return; + } + let _ = stream.flush().await; + } +} + +fn header_value(head: &str, name: &str) -> Option { + head.lines().skip(1).find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +// ---- proxy lifecycle ---- + +/// Spawns the proxy in the background and returns the loopback address it bound. +async fn spawn_proxy(cfg: config::ResolvedConfig, ca: Arc) -> SocketAddr { + let listener = server::bind(cfg.listen) + .await + .expect("should bind proxy listener"); + let addr = listener.local_addr().expect("should read proxy addr"); + let cfg = Arc::new(cfg); + let pac: Arc = Arc::from("function FindProxyForURL(u, h) { return \"DIRECT\"; }"); + tokio::spawn(async move { + let _ = server::serve_on(listener, cfg, ca, pac).await; + }); + addr +} + +// ---- client legs: a no-verify verifier so the test can trust either CA ---- + +#[derive(Debug)] +struct AcceptAny; + +impl ServerCertVerifier for AcceptAny { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::aws_lc_rs::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +fn accept_any_connector() -> TlsConnector { + let mut config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAny)) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + TlsConnector::from(Arc::new(config)) +} + +/// Sends `CONNECT host:port` to the proxy and reads its status line. +async fn proxy_connect(proxy: SocketAddr, authority: &str) -> TcpStream { + let mut stream = TcpStream::connect(proxy) + .await + .expect("should connect to proxy"); + let request = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("should send CONNECT"); + stream.flush().await.expect("should flush CONNECT"); + let status = read_status_line(&mut stream).await; + assert!( + status.contains(" 200 "), + "proxy should accept CONNECT, got: {status}" + ); + stream +} + +/// Reads bytes until the end of the response head and returns its first line. +async fn read_status_line(stream: &mut TcpStream) -> String { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = stream.read(&mut byte).await.expect("should read status"); + if n == 0 { + break; + } + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break; + } + } + String::from_utf8_lossy(&buf) + .lines() + .next() + .unwrap_or_default() + .to_string() +} + +/// Issues a single GET through the proxy (CONNECT to `FROM_HOST`, MITM, request +/// `/`) and parses the upstream echo. +pub async fn drive_request_through_proxy( + cfg: config::ResolvedConfig, + ca: Arc, +) -> ProxiedResponse { + let responses = drive_sequential_requests(cfg, ca, &["/"]).await; + responses + .into_iter() + .next() + .expect("should get one response") +} + +/// Issues several GETs over ONE keep-alive MITM tunnel and returns them in order. +pub async fn drive_sequential_requests( + cfg: config::ResolvedConfig, + ca: Arc, + paths: &[&str], +) -> Vec { + let proxy = spawn_proxy(cfg, ca).await; + let authority = format!("{FROM_HOST}:443"); + let tcp = proxy_connect(proxy, &authority).await; + + let connector = accept_any_connector(); + let server_name = ServerName::try_from(FROM_HOST.to_string()).expect("valid server name"); + let mut tls = connector + .connect(server_name, tcp) + .await + .expect("client TLS handshake with proxy leaf"); + + let mut results = Vec::with_capacity(paths.len()); + for path in paths { + let request = + format!("GET {path} HTTP/1.1\r\nHost: {FROM_HOST}\r\nConnection: keep-alive\r\n\r\n"); + tls.write_all(request.as_bytes()) + .await + .expect("should send request over tunnel"); + tls.flush().await.expect("should flush request"); + results.push(read_http_response(&mut tls).await); + } + results +} + +/// Reads one HTTP/1.1 response (head + Content-Length body) and parses the echo. +async fn read_http_response(stream: &mut S) -> ProxiedResponse +where + S: AsyncReadExt + Unpin, +{ + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + let head_end = loop { + if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + break pos + 4; + } + let n = stream + .read(&mut chunk) + .await + .expect("should read response head"); + assert!(n > 0, "upstream closed before sending a response"); + buf.extend_from_slice(&chunk[..n]); + }; + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let status = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) + .expect("should parse status code"); + let content_length: usize = header_value(&head, "content-length") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let mut body = buf[head_end..].to_vec(); + while body.len() < content_length { + let n = stream.read(&mut chunk).await.expect("should read body"); + if n == 0 { + break; + } + body.extend_from_slice(&chunk[..n]); + } + let body = String::from_utf8_lossy(&body[..content_length.min(body.len())]).to_string(); + let (seen_host, seen_orig_host, path) = parse_echo(&body); + ProxiedResponse { + status, + seen_host, + seen_orig_host, + path, + } +} + +/// Parses `host=..;orig=..;path=..` echoed by the upstream. +fn parse_echo(body: &str) -> (String, String, String) { + let mut host = String::new(); + let mut orig = String::new(); + let mut path = String::new(); + for field in body.split(';') { + if let Some(v) = field.strip_prefix("host=") { + host = v.to_string(); + } else if let Some(v) = field.strip_prefix("orig=") { + orig = v.to_string(); + } else if let Some(v) = field.strip_prefix("path=") { + path = v.to_string(); + } + } + (host, orig, path) +} + +/// CONNECTs through the proxy to an UNMATCHED authority, completes the TLS +/// handshake, and returns the issuer CN of the leaf the client received — used +/// to prove a blind tunnel presents the upstream cert (not the dev CA leaf). +pub async fn connect_through_proxy_capturing_cert( + cfg: config::ResolvedConfig, + ca: Arc, + upstream: &SocketAddr, + sni: &str, +) -> ObservedCert { + let proxy = spawn_proxy(cfg, ca).await; + // CONNECT to the upstream's real loopback authority (no rule matches it), + // so the proxy blind-tunnels straight to it. + let authority = format!("{}:{}", upstream.ip(), upstream.port()); + let tcp = proxy_connect(proxy, &authority).await; + + let captured = Arc::new(std::sync::Mutex::new(None)); + let connector = capturing_connector(Arc::clone(&captured)); + let server_name = ServerName::try_from(sni.to_string()).expect("valid sni"); + let _ = connector + .connect(server_name, tcp) + .await + .expect("client TLS handshake with upstream through blind tunnel"); + + let issuer_common_name = captured + .lock() + .expect("lock") + .clone() + .expect("verifier captured a leaf certificate"); + ObservedCert { issuer_common_name } +} + +/// A verifier that records the issuer CN of the presented leaf, then accepts it. +#[derive(Debug)] +struct CapturingVerifier { + issuer_common_name: Arc>>, +} + +impl ServerCertVerifier for CapturingVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + if let Some(cn) = issuer_cn(end_entity) { + *self.issuer_common_name.lock().expect("lock") = Some(cn); + } + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::aws_lc_rs::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +fn capturing_connector(slot: Arc>>) -> TlsConnector { + let mut config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(CapturingVerifier { + issuer_common_name: slot, + })) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + TlsConnector::from(Arc::new(config)) +} + +/// Extracts the issuer CN from a DER certificate (self-signed leaf → its own CN). +fn issuer_cn(cert: &CertificateDer<'_>) -> Option { + use x509_parser::prelude::FromDer as _; + let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(cert.as_ref()).ok()?; + parsed + .issuer() + .iter_common_name() + .next() + .and_then(|attr| attr.as_str().ok()) + .map(str::to_string) +} From 0f649caa789047dcf84b8942af4a28c1ef359a0c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:25:01 -0700 Subject: [PATCH 12/40] Fix blind-HTTP head replay and add 403 guard test for off-loopback CONNECT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread the raw buffered bytes through `RequestHead` so `blind_forward_http` can write the complete request head to the upstream before piping the rest of the socket bidirectionally (spec §8.4). Previously the head was discarded, sending a truncated/empty request. - Update `blind_forward_http` doc comment to reflect that it now replays the original head rather than falsely claiming it always did. - Add `unmatched_connect_off_loopback_is_refused_with_403` integration test. The proxy listener is bound on `127.0.0.1:0` (real socket) but `cfg.listen` is patched to `0.0.0.0:` before being handed to `serve_on`, so `is_loopback` is computed as false while the test can still connect via loopback. Asserts that an unmatched `CONNECT` receives `403` and no tunnel is established. --- .../src/commands/dev/proxy/server.rs | 30 ++++++++---- crates/trusted-server-cli/tests/proxy_e2e.rs | 17 +++++++ .../trusted-server-cli/tests/support/mod.rs | 47 +++++++++++++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index e39fc53e9..9bd24a1d8 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -82,10 +82,14 @@ fn is_loopback(ip: IpAddr) -> bool { } } -/// The first request line of an accepted connection, peeked far enough to route. +/// The first request head of an accepted connection, buffered for routing. struct RequestHead { method: String, target: String, + /// The raw bytes consumed from the client socket (the full HTTP head up to + /// and including `\r\n\r\n`). Retained so they can be forwarded verbatim + /// when the request is blind-forwarded as plain HTTP (spec §8.4). + raw: Vec, } impl RequestHead { @@ -101,10 +105,12 @@ impl RequestHead { } } -/// Reads bytes until the end of the first request line and parses method/target. +/// Reads bytes until the end of the request head (`\r\n\r\n`) and parses +/// method/target from the first request line. /// -/// Only the request line is consumed; for `CONNECT` the rest of the head (the -/// blank-line terminator) is drained so the client's `200` arrives cleanly. +/// The raw bytes are retained on the returned [`RequestHead`] so that a stray +/// absolute-form plain-HTTP request can be forwarded unchanged (spec §8.4) — +/// `blind_forward_http` writes them to the upstream before piping the remainder. async fn read_request_head(client: &mut TcpStream) -> Result> { let mut buf = Vec::with_capacity(256); let mut byte = [0u8; 1]; @@ -127,7 +133,7 @@ async fn read_request_head(client: &mut TcpStream) -> Result Result<(), Report) -> addr } +/// Spawns a proxy that behaves as if it were bound on a non-loopback address, +/// while the actual socket is on loopback so the test can connect without +/// privilege. +/// +/// The trick: bind the listener on `127.0.0.1:0` (real socket), then patch +/// `cfg.listen` to `0.0.0.0:` before handing it to `serve_on`. The +/// server derives `is_loopback = false` from `cfg.listen`, so CONNECT requests +/// to unmatched authorities are refused with `403` instead of blind-tunnelled. +pub async fn spawn_proxy_as_non_loopback( + mut cfg: config::ResolvedConfig, + ca: Arc, +) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind proxy listener on loopback"); + let real_port = listener.local_addr().expect("should read proxy addr").port(); + // Override listen so is_loopback computed in serve_on is false. + cfg.listen = format!("0.0.0.0:{real_port}") + .parse() + .expect("should parse non-loopback socket addr"); + let connect_addr: SocketAddr = format!("127.0.0.1:{real_port}") + .parse() + .expect("should parse loopback connect addr"); + let cfg = Arc::new(cfg); + let pac: Arc = Arc::from("function FindProxyForURL(u, h) { return \"DIRECT\"; }"); + tokio::spawn(async move { + let _ = server::serve_on(listener, cfg, ca, pac).await; + }); + connect_addr +} + +/// Sends a `CONNECT` request to `proxy` and returns the status line received. +/// Unlike `proxy_connect`, this does not assert on the status — it just returns +/// it so callers can check for `403` or other rejection codes. +pub async fn connect_and_read_status(proxy: SocketAddr, authority: &str) -> String { + let mut stream = TcpStream::connect(proxy) + .await + .expect("should connect to proxy"); + let request = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("should send CONNECT"); + stream.flush().await.expect("should flush CONNECT"); + read_status_line(&mut stream).await +} + // ---- client legs: a no-verify verifier so the test can trust either CA ---- #[derive(Debug)] From cdd9d6a79bcd58d45aafedaacb0704d5b641e70b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:46:31 -0700 Subject: [PATCH 13/40] Format trusted-server-cli with rustfmt --- .../src/commands/dev/proxy/ca.rs | 12 +- .../src/commands/dev/proxy/config.rs | 50 +++++-- .../src/commands/dev/proxy/mod.rs | 3 +- .../src/commands/dev/proxy/rewrite.rs | 123 ++++++++++++++---- .../src/commands/dev/proxy/server.rs | 6 +- .../trusted-server-cli/tests/support/mod.rs | 5 +- 6 files changed, 158 insertions(+), 41 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs index 43e54a7d4..385d2b7bd 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -96,7 +96,9 @@ impl CertAuthority { CertificateParams::from_ca_cert_pem(&cert_pem).change_context(CaError::Generate)?; let ca_cert_der = pem_to_cert_der(&cert_pem)?; // Reconstruct the Certificate struct so we can pass it as issuer to signed_by. - let ca_cert = ca_params.self_signed(&ca_key).change_context(CaError::Generate)?; + let ca_cert = ca_params + .self_signed(&ca_key) + .change_context(CaError::Generate)?; Ok(Self { ca_cert, @@ -177,7 +179,9 @@ impl CertAuthority { let key = KeyPair::generate().change_context(CaError::Generate)?; let mut params = CertificateParams::new(Vec::::new()).change_context(CaError::Generate)?; - params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME); + params + .distinguished_name + .push(DnType::CommonName, CA_COMMON_NAME); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; // ~10 years from generation (spec §7.1); rotate via `ca regenerate`. @@ -205,7 +209,9 @@ impl CertAuthority { .mode(0o600) .open(key_path) .change_context(CaError::Io)?; - key_file.write_all(key_pem.as_bytes()).change_context(CaError::Io)?; + key_file + .write_all(key_pem.as_bytes()) + .change_context(CaError::Io)?; Ok(()) } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 40511be50..05e891576 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -55,7 +55,10 @@ impl BasicAuth { fn parse(raw: &str) -> Result { let (user, pass) = raw.split_once(':').ok_or(ConfigError::BasicAuth)?; - Ok(Self { user: user.to_string(), pass: pass.to_string() }) + Ok(Self { + user: user.to_string(), + pass: pass.to_string(), + }) } } @@ -84,7 +87,9 @@ impl Browser { "chrome" => Ok(Self::Chrome), "firefox" => Ok(Self::Firefox), "safari" => Ok(Self::Safari), - other => Err(ConfigError::Browser { value: other.to_string() }), + other => Err(ConfigError::Browser { + value: other.to_string(), + }), }) .collect() } @@ -122,7 +127,9 @@ fn default_ca_dir() -> PathBuf { /// subcommands work without a `--map`/`--to` (spec §4.2). #[must_use] pub fn ca_dir(args: &ProxyArgs) -> PathBuf { - args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) + args.ca_dir + .as_ref() + .map_or_else(default_ca_dir, PathBuf::from) } fn build_rules(args: &ProxyArgs) -> Result { @@ -139,9 +146,19 @@ fn build_rules(args: &ProxyArgs) -> Result { Ok(RuleTable(rules)) } -fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Result { +fn make_rule( + from: &str, + to: &str, + preserve_host: bool, + plaintext: bool, +) -> Result { let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; - Ok(Rule { from: from.to_ascii_lowercase(), to, preserve_host, plaintext }) + Ok(Rule { + from: from.to_ascii_lowercase(), + to, + preserve_host, + plaintext, + }) } /// Resolves arguments into a [`ResolvedConfig`]. @@ -159,13 +176,17 @@ pub fn resolve(args: &ProxyArgs) -> Result> let listen: SocketAddr = args .listen .parse() - .change_context_lazy(|| ConfigError::Listen { value: args.listen.clone() })?; + .change_context_lazy(|| ConfigError::Listen { + value: args.listen.clone(), + })?; let is_loopback = match listen.ip() { IpAddr::V4(v4) => v4.is_loopback(), IpAddr::V6(v6) => v6.is_loopback(), }; if !is_loopback && !args.allow_non_loopback { - return Err(Report::new(ConfigError::NonLoopback { value: args.listen.clone() })); + return Err(Report::new(ConfigError::NonLoopback { + value: args.listen.clone(), + })); } let launch = match &args.launch { @@ -221,7 +242,10 @@ mod tests { args.from = Some("www.example-publisher.com".into()); args.to = Some("to.edgecompute.app".into()); let cfg = resolve(&args).expect("should resolve"); - let rule = cfg.rules.first_match("www.example-publisher.com").expect("rule present"); + let rule = cfg + .rules + .first_match("www.example-publisher.com") + .expect("rule present"); assert!(rule.preserve_host, "default preserves FROM host"); assert_eq!(rule.to.host(), "to.edgecompute.app"); } @@ -252,7 +276,10 @@ mod tests { let mut args = base_args(); args.map = vec!["a.example.com=b.edgecompute.app".into()]; args.listen = "0.0.0.0:8080".into(); - assert!(resolve(&args).is_err(), "non-loopback without flag is rejected"); + assert!( + resolve(&args).is_err(), + "non-loopback without flag is rejected" + ); args.allow_non_loopback = true; assert!(resolve(&args).is_ok(), "non-loopback allowed with flag"); } @@ -280,7 +307,10 @@ mod tests { Browser::parse_list("firefox,chrome").expect("parses"), vec![Browser::Firefox, Browser::Chrome] ); - assert!(Browser::parse_list("netscape").is_err(), "unknown browser errors"); + assert!( + Browser::parse_list("netscape").is_err(), + "unknown browser errors" + ); } #[test] diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index ab864f331..4092814ff 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -119,7 +119,8 @@ pub enum CaCommand { pub fn run(args: ProxyArgs) -> Result<(), error_stack::Report> { let cfg = Arc::new(config::resolve(&args).change_context(ProxyError::Config)?); let ca = Arc::new( - ca::CertAuthority::load_or_generate(&cfg.ca_dir).change_context(ProxyError::CertAuthority)?, + ca::CertAuthority::load_or_generate(&cfg.ca_dir) + .change_context(ProxyError::CertAuthority)?, ); // PAC generation arrives in Task 6; serve a DIRECT stub for now. let pac: Arc = Arc::from("function FindProxyForURL(u, h) { return \"DIRECT\"; }"); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index e8fcd0a2f..2273a90d6 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -38,19 +38,27 @@ impl Authority { let (host, port) = match raw.rsplit_once(':') { Some((h, p)) => { if p.is_empty() { - return Err(RuleError::Port { value: raw.to_string() }); + return Err(RuleError::Port { + value: raw.to_string(), + }); } - let port = p - .parse::() - .map_err(|_| RuleError::Port { value: raw.to_string() })?; + let port = p.parse::().map_err(|_| RuleError::Port { + value: raw.to_string(), + })?; (h, port) } None => (raw, default_port), }; if host.is_empty() { - return Err(RuleError::EmptyHost { value: raw.to_string() }); + return Err(RuleError::EmptyHost { + value: raw.to_string(), + }); } - Ok(Self { host: host.to_ascii_lowercase(), port, default_port }) + Ok(Self { + host: host.to_ascii_lowercase(), + port, + default_port, + }) } /// The bare hostname (for SNI and connection target). @@ -103,7 +111,9 @@ impl RuleTable { .rsplit_once(':') .map_or(host, |(h, _)| h) .to_ascii_lowercase(); - self.0.iter().find(|r| r.from.to_ascii_lowercase() == needle) + self.0 + .iter() + .find(|r| r.from.to_ascii_lowercase() == needle) } } @@ -155,7 +165,11 @@ mod tests { assert_eq!(a.host(), "staging.example.net", "should keep host"); assert_eq!(a.port, 443, "should default to 443 for TLS"); assert!(a.is_default_port(), "443 is default for TLS"); - assert_eq!(a.host_with_port(), "staging.example.net", "default port omitted"); + assert_eq!( + a.host_with_port(), + "staging.example.net", + "default port omitted" + ); } #[test] @@ -171,7 +185,11 @@ mod tests { assert_eq!(a.port, 3000, "should parse explicit port"); assert!(!a.is_default_port(), "3000 is not default"); assert_eq!(a.host(), "localhost", "SNI host must exclude port"); - assert_eq!(a.host_with_port(), "localhost:3000", "Host header includes non-default port"); + assert_eq!( + a.host_with_port(), + "localhost:3000", + "Host header includes non-default port" + ); } #[test] @@ -179,19 +197,43 @@ mod tests { // TLS authority on :80 is NOT default — :80 must appear in Host. let tls_80 = Authority::parse("host.example.com:80", false).expect("parse"); assert!(!tls_80.is_default_port(), "80 is not the TLS default"); - assert_eq!(tls_80.host_with_port(), "host.example.com:80", "Host keeps :80 for TLS"); + assert_eq!( + tls_80.host_with_port(), + "host.example.com:80", + "Host keeps :80 for TLS" + ); // Plaintext authority on :443 is NOT default — :443 must appear in Host. let plain_443 = Authority::parse("host.example.com:443", true).expect("parse"); - assert!(!plain_443.is_default_port(), "443 is not the plaintext default"); - assert_eq!(plain_443.host_with_port(), "host.example.com:443", "Host keeps :443 for plaintext"); + assert!( + !plain_443.is_default_port(), + "443 is not the plaintext default" + ); + assert_eq!( + plain_443.host_with_port(), + "host.example.com:443", + "Host keeps :443 for plaintext" + ); } #[test] fn matching_is_case_insensitive_and_port_stripped() { - let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", true, false)]); - let m = table.first_match("WWW.Example-Publisher.COM:443").expect("should match"); - assert_eq!(m.from, "www.example-publisher.com", "match ignores case and port"); - assert!(table.first_match("other.example.com").is_none(), "unmatched host returns None"); + let table = RuleTable(vec![rule( + "www.example-publisher.com", + "to.edgecompute.app", + true, + false, + )]); + let m = table + .first_match("WWW.Example-Publisher.COM:443") + .expect("should match"); + assert_eq!( + m.from, "www.example-publisher.com", + "match ignores case and port" + ); + assert!( + table.first_match("other.example.com").is_none(), + "unmatched host returns None" + ); } #[test] @@ -200,16 +242,37 @@ mod tests { rule("a.example.com", "first.edgecompute.app", true, false), rule("a.example.com", "second.edgecompute.app", true, false), ]); - assert_eq!(table.first_match("a.example.com").expect("should match").to.host(), "first.edgecompute.app"); + assert_eq!( + table + .first_match("a.example.com") + .expect("should match") + .to + .host(), + "first.edgecompute.app" + ); } #[test] fn rewrite_default_preserves_from_host_and_sets_sni_to_to() { - let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", true, false); + let r = rule( + "www.example-publisher.com", + "to.edgecompute.app:8443", + true, + false, + ); let out = rewrite_for(&r); - assert_eq!(out.sni, "to.edgecompute.app", "SNI is TO host only, no port"); - assert_eq!(out.host_header, "www.example-publisher.com", "default Host is FROM"); - assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host is FROM"); + assert_eq!( + out.sni, "to.edgecompute.app", + "SNI is TO host only, no port" + ); + assert_eq!( + out.host_header, "www.example-publisher.com", + "default Host is FROM" + ); + assert_eq!( + out.orig_host, "www.example-publisher.com", + "X-Orig-Host is FROM" + ); assert!(out.scheme_is_tls, "TLS rule yields a TLS outcome"); } @@ -218,14 +281,24 @@ mod tests { let r = rule("www.example-publisher.com", "localhost:3000", false, true); let out = rewrite_for(&r); assert_eq!(out.sni, "localhost", "SNI never carries a port"); - assert_eq!(out.host_header, "localhost:3000", "rewrite-host sends TO host:port"); - assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); - assert!(!out.scheme_is_tls, "plaintext rule yields a non-TLS outcome"); + assert_eq!( + out.host_header, "localhost:3000", + "rewrite-host sends TO host:port" + ); + assert_eq!( + out.orig_host, "www.example-publisher.com", + "X-Orig-Host stays FROM" + ); + assert!( + !out.scheme_is_tls, + "plaintext rule yields a non-TLS outcome" + ); } #[test] fn rejects_empty_or_missing_port() { - let err = Authority::parse("host.example.com:", true).expect_err("should reject trailing colon"); + let err = + Authority::parse("host.example.com:", true).expect_err("should reject trailing colon"); assert!( matches!(err, RuleError::Port { .. }), "trailing colon should be a Port error, got: {err}" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 9bd24a1d8..a6a89e9f9 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -133,7 +133,11 @@ async fn read_request_head(client: &mut TcpStream) -> Result Date: Mon, 22 Jun 2026 17:52:35 -0700 Subject: [PATCH 14/40] Add browser orchestration, PAC generation, and ca trust subcommands --- .../src/commands/dev/proxy/browser.rs | 423 ++++++++++++++++++ .../src/commands/dev/proxy/mod.rs | 55 ++- 2 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 crates/trusted-server-cli/src/commands/dev/proxy/browser.rs diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs new file mode 100644 index 000000000..8d5a7471c --- /dev/null +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -0,0 +1,423 @@ +//! Browser launch/config, PAC generation, and CA trust commands (spec §9, §7.3). + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::ProxyError; +use super::ca::CA_COMMON_NAME; +use super::config::{Browser, ResolvedConfig}; +use super::rewrite::RuleTable; +use crate::output; + +/// Generates a PAC script that proxies only `https://` requests for matched FROM hosts. +/// +/// All other requests fall through to `DIRECT`. +#[must_use] +pub fn generate_pac(rules: &RuleTable, listen: SocketAddr) -> String { + let mut checks = String::new(); + for rule in &rules.0 { + checks.push_str(&format!( + " if (url.substring(0,6) == \"https:\" && host == \"{}\") return \"PROXY {}\";\n", + rule.from, listen + )); + } + format!("function FindProxyForURL(url, host) {{\n{checks} return \"DIRECT\";\n}}\n") +} + +/// Adds the CA certificate to the macOS login keychain (spec §7.3). +/// +/// On non-macOS systems, or if the `security` command fails, prints manual +/// instructions via [`crate::output`]. Never panics. +pub fn ca_install(cert_path: &Path) { + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").unwrap_or_default(); + let keychain = format!("{home}/Library/Keychains/login.keychain-db"); + let status = Command::new("security") + .args(["add-trusted-cert", "-r", "trustRoot", "-k", &keychain]) + .arg(cert_path) + .status(); + match status { + Ok(s) if s.success() => { + output::info("CA added to the login keychain"); + } + _ => output::warn(&format!( + "could not auto-install; run manually: security add-trusted-cert -r trustRoot -k {keychain} {}", + cert_path.display() + )), + } + } + #[cfg(not(target_os = "macos"))] + output::info(&format!( + "add this CA to your OS trust store manually: {}", + cert_path.display() + )); +} + +/// Removes the dev CA from the macOS login keychain (spec §7.3). +/// +/// On non-macOS systems, prints a manual note. Never panics. +pub fn ca_uninstall() { + #[cfg(target_os = "macos")] + { + let status = Command::new("security") + .args(["delete-certificate", "-c", CA_COMMON_NAME]) + .status(); + match status { + Ok(s) if s.success() => output::info("CA removed from keychain"), + _ => output::info("CA was not found in keychain (already removed or never installed)"), + } + } + #[cfg(not(target_os = "macos"))] + output::info("remove the dev CA from your OS trust store manually"); +} + +/// Launches and configures each requested browser against the proxy (spec §9). +/// +/// A browser that cannot be launched or configured logs manual steps and is +/// skipped — `launch` returns `Ok(())` unless something truly unrecoverable +/// happens. +/// +/// # Errors +/// +/// Returns [`ProxyError::Browser`] only on an unrecoverable setup failure. +pub fn launch( + browsers: &[Browser], + cfg: &ResolvedConfig, +) -> core::result::Result<(), error_stack::Report> { + for browser in browsers { + match browser { + Browser::Chrome => launch_chrome(cfg), + Browser::Firefox => launch_firefox(cfg), + Browser::Safari => launch_safari(cfg), + } + } + Ok(()) +} + +/// Creates a unique temp directory under the system temp dir. +/// +/// Returns `None` and prints a warning with `label` if the directory cannot be created. +fn make_temp_dir(label: &str) -> Option { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let dir = std::env::temp_dir().join(format!("ts-dev-proxy-{label}-{ts}")); + match std::fs::create_dir_all(&dir) { + Ok(()) => Some(dir), + Err(err) => { + output::warn(&format!("{label}: could not create temp dir: {err}")); + None + } + } +} + +/// Launches Chrome in a temporary profile configured for HTTPS-only proxying. +/// +/// Uses `--proxy-server="https="` so only HTTPS traffic goes through the +/// proxy; HTTP and other schemes bypass it (spec §9). +fn launch_chrome(cfg: &ResolvedConfig) { + let port = cfg.listen.port(); + let proxy_arg = format!("https=127.0.0.1:{port}"); + + let Some(tmpdir) = make_temp_dir("chrome") else { + output::warn(&format!( + "Chrome: launch Chrome manually with --proxy-server=\"https=127.0.0.1:{port}\"" + )); + return; + }; + + let mut cmd = chrome_command(); + cmd.args([ + "--no-first-run", + "--no-default-browser-check", + &format!("--user-data-dir={}", tmpdir.display()), + &format!("--proxy-server={proxy_arg}"), + ]); + + if let Some(rule) = cfg.rules.0.first() { + cmd.arg(format!("https://{}", rule.from)); + } + + match cmd.spawn() { + Ok(mut child) => { + // Clean up the temp dir after the browser exits. + std::thread::spawn(move || { + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&tmpdir); + }); + } + Err(err) => { + output::warn(&format!( + "Chrome: could not launch: {err}; \ + start Chrome manually with --proxy-server=\"https=127.0.0.1:{port}\"" + )); + let _ = std::fs::remove_dir_all(&tmpdir); + } + } +} + +/// Returns the platform Chrome/Chromium command. +fn chrome_command() -> Command { + #[cfg(target_os = "macos")] + { + let app = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + Command::new(app) + } + #[cfg(target_os = "linux")] + { + // Try google-chrome first, then chromium-browser, then chromium. + Command::new("google-chrome") + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Command::new("chrome") + } +} + +/// Launches Firefox in a temporary profile configured for HTTPS-only proxying. +/// +/// Writes `user.js` with `network.proxy.type=1` (manual) and ssl/ssl_port +/// settings only — HTTP traffic is left to go direct (spec §9). Also imports +/// the CA into the profile's NSS database via `certutil` if available. +fn launch_firefox(cfg: &ResolvedConfig) { + let port = cfg.listen.port(); + + let Some(tmpdir) = make_temp_dir("firefox") else { + output::warn(&format!( + "Firefox: configure Firefox manually (proxy SSL/TLS: 127.0.0.1:{port})" + )); + return; + }; + + let user_js = format!( + "user_pref(\"network.proxy.type\", 1);\n\ + user_pref(\"network.proxy.ssl\", \"127.0.0.1\");\n\ + user_pref(\"network.proxy.ssl_port\", {port});\n" + ); + + if let Err(err) = std::fs::write(tmpdir.join("user.js"), &user_js) { + output::warn(&format!( + "Firefox: could not write user.js: {err}; \ + configure Firefox manually (proxy SSL/TLS: 127.0.0.1:{port})" + )); + let _ = std::fs::remove_dir_all(&tmpdir); + return; + } + + // Import CA into NSS DB if certutil is available. + let cert_path = super::ca::CertAuthority::cert_path(&cfg.ca_dir); + if cert_path.exists() { + let _ = Command::new("certutil") + .args([ + "-A", + "-n", + CA_COMMON_NAME, + "-t", + "CT,,", + "-i", + &cert_path.to_string_lossy(), + "-d", + &tmpdir.to_string_lossy(), + ]) + .status(); + } + + let mut cmd = firefox_command(); + cmd.args(["-profile", &tmpdir.to_string_lossy(), "--no-remote"]); + + if let Some(rule) = cfg.rules.0.first() { + cmd.arg(format!("https://{}", rule.from)); + } + + match cmd.spawn() { + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&tmpdir); + }); + } + Err(err) => { + output::warn(&format!( + "Firefox: could not launch: {err}; \ + start Firefox manually with SSL proxy 127.0.0.1:{port}" + )); + let _ = std::fs::remove_dir_all(&tmpdir); + } + } +} + +/// Returns the platform Firefox command. +fn firefox_command() -> Command { + #[cfg(target_os = "macos")] + { + let app = "/Applications/Firefox.app/Contents/MacOS/firefox"; + Command::new(app) + } + #[cfg(not(target_os = "macos"))] + { + Command::new("firefox") + } +} + +/// Configures Safari via the system PAC URL, restoring the prior setting on exit. +/// +/// Detects the active network service via `route`/`networksetup` and sets a +/// PAC URL pointing at the running proxy's `/proxy.pac` route (spec §9). +fn launch_safari(cfg: &ResolvedConfig) { + let port = cfg.listen.port(); + let pac_url = format!("http://127.0.0.1:{port}/proxy.pac"); + + let service = detect_network_service(); + let Some(service) = service else { + output::warn(&format!( + "Safari: could not detect active network service; \ + set PAC URL manually in System Settings → Network: {pac_url}" + )); + return; + }; + + // Save prior PAC URL so we can restore it on exit. + let prior_pac = get_auto_proxy_url(&service); + + let set_result = Command::new("networksetup") + .args(["-setautoproxyurl", &service, &pac_url]) + .status(); + + match set_result { + Ok(s) if s.success() => { + output::info(&format!( + "Safari: PAC URL set for '{service}'; open Safari and browse to a proxied host" + )); + } + _ => { + output::warn(&format!( + "Safari: could not set PAC URL automatically; \ + set it manually in System Settings → Network → {service}: {pac_url}" + )); + return; + } + } + + // Restore PAC on exit. + std::thread::spawn(move || { + // Wait for Ctrl-C or process exit — the restore runs when this thread + // is dropped (i.e., when the process exits). + // + // A cleaner approach (signal handler) is deferred; this is best-effort. + std::thread::park(); + restore_auto_proxy(&service, prior_pac.as_deref()); + }); +} + +/// Returns the active Wi-Fi/Ethernet network service name, or `None`. +fn detect_network_service() -> Option { + #[cfg(not(target_os = "macos"))] + return None; + + #[cfg(target_os = "macos")] + { + // Find the default-route interface name. + let route_out = Command::new("route") + .args(["-n", "get", "default"]) + .output() + .ok()?; + let route_text = String::from_utf8_lossy(&route_out.stdout); + let interface = route_text + .lines() + .find(|l| l.trim_start().starts_with("interface:"))? + .split(':') + .nth(1)? + .trim() + .to_string(); + + // Map interface → service name via networksetup -listnetworkserviceorder. + let ns_out = Command::new("networksetup") + .arg("-listnetworkserviceorder") + .output() + .ok()?; + let ns_text = String::from_utf8_lossy(&ns_out.stdout); + + let mut last_service: Option = None; + for line in ns_text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('(') && !trimmed.starts_with("(*) An asterisk") { + // Service name lines look like: "(1) Wi-Fi" + last_service = trimmed + .split_once(')') + .map(|x| x.1) + .map(str::trim) + .map(str::to_string); + } else if trimmed.contains(&interface) && last_service.is_some() { + return last_service; + } + } + None + } +} + +/// Returns the current auto-proxy URL for a network service, if set. +fn get_auto_proxy_url(service: &str) -> Option { + let out = Command::new("networksetup") + .args(["-getautoproxyurl", service]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + if let Some(url) = line.strip_prefix("URL: ") { + let url = url.trim(); + if !url.is_empty() && url != "(null)" { + return Some(url.to_string()); + } + } + } + None +} + +/// Restores the prior PAC URL (or disables auto-proxy if there was none). +fn restore_auto_proxy(service: &str, prior_pac: Option<&str>) { + match prior_pac { + Some(url) => { + let _ = Command::new("networksetup") + .args(["-setautoproxyurl", service, url]) + .status(); + } + None => { + let _ = Command::new("networksetup") + .args(["-setautoproxystate", service, "off"]) + .status(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; + + #[test] + fn pac_proxies_only_https_for_from_hosts() { + let rules = RuleTable(vec![Rule { + from: "www.example-publisher.com".into(), + to: Authority::parse("to.edgecompute.app", false).expect("should parse authority"), + preserve_host: true, + plaintext: false, + }]); + let pac = generate_pac(&rules, "127.0.0.1:8080".parse().expect("should parse addr")); + assert!(pac.contains("https:"), "PAC guards on https scheme"); + assert!( + pac.contains("www.example-publisher.com"), + "PAC lists the FROM host" + ); + assert!( + pac.contains("PROXY 127.0.0.1:8080"), + "PAC points at the listen addr" + ); + assert!( + pac.contains("return \"DIRECT\""), + "everything else is direct" + ); + } +} diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 4092814ff..7f046492b 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -1,3 +1,4 @@ +pub mod browser; pub mod ca; pub mod config; pub mod rewrite; @@ -116,20 +117,66 @@ pub enum CaCommand { /// /// Returns [`ProxyError`] if configuration, the CA, the server, or browser /// orchestration fails. -pub fn run(args: ProxyArgs) -> Result<(), error_stack::Report> { +pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report> { + // CA subcommands need only the CA directory — handle them before rule resolution. + if let Some(ProxySub::Ca { action }) = &args.command { + let ca_dir = config::ca_dir(&args); + let cert_path = ca::CertAuthority::cert_path(&ca_dir); + match action { + CaCommand::Path => { + // Ensure the CA exists so the printed path points at a real file. + ca::CertAuthority::load_or_generate(&ca_dir) + .change_context(ProxyError::CertAuthority)?; + output::info(&cert_path.display().to_string()); + } + CaCommand::Install => { + // A fresh machine has no CA yet — generate before trusting it. + ca::CertAuthority::load_or_generate(&ca_dir) + .change_context(ProxyError::CertAuthority)?; + browser::ca_install(&cert_path); + } + CaCommand::Uninstall => browser::ca_uninstall(), + CaCommand::Regenerate => { + std::fs::remove_file(&cert_path).ok(); + std::fs::remove_file(ca_dir.join("ca-key.pem")).ok(); + ca::CertAuthority::load_or_generate(&ca_dir) + .change_context(ProxyError::CertAuthority)?; + output::info("regenerated CA — re-run `ca install` to trust it"); + } + } + return Ok(()); + } + let cfg = Arc::new(config::resolve(&args).change_context(ProxyError::Config)?); let ca = Arc::new( ca::CertAuthority::load_or_generate(&cfg.ca_dir) .change_context(ProxyError::CertAuthority)?, ); - // PAC generation arrives in Task 6; serve a DIRECT stub for now. - let pac: Arc = Arc::from("function FindProxyForURL(u, h) { return \"DIRECT\"; }"); + let pac: Arc = Arc::from(browser::generate_pac(&cfg.rules, cfg.listen).as_str()); + let runtime = tokio::runtime::Runtime::new().change_context(ProxyError::Server)?; runtime.block_on(async move { + // Bind first: the port is open and connections queue before we launch browsers. let listener = server::bind(cfg.listen) .await .change_context(ProxyError::Server)?; output::info(&format!("ts dev proxy listening on {}", cfg.listen)); - server::serve_on(listener, cfg, ca, pac).await + let server = tokio::spawn(server::serve_on( + listener, + Arc::clone(&cfg), + Arc::clone(&ca), + Arc::clone(&pac), + )); + + if !cfg.launch.is_empty() { + // Browser launch spawns processes (blocking) — keep it off the reactor thread. + let launch_cfg = Arc::clone(&cfg); + tokio::task::spawn_blocking(move || browser::launch(&launch_cfg.launch, &launch_cfg)) + .await + .change_context(ProxyError::Browser)??; + } + + // Keep the runtime alive: serve until the accept loop ends (Ctrl-C / drop). + server.await.change_context(ProxyError::Server)? }) } From 5a5c5f5c5f56b839ab158708a86aaf26647adba5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:04:19 -0700 Subject: [PATCH 15/40] Persist and recover Safari system proxy state across hard kills Replace the dead `std::thread::park()` restore thread in `launch_safari` with a file-based persist-and-recover scheme. Before applying the PAC URL, write `/safari-proxy-restore` capturing the network service name and the prior auto-proxy URL (or an empty second line when auto-proxy was off). Add `restore_system_proxy_if_pending(ca_dir)`, which reads and deletes that file then runs the appropriate `networksetup` command to put things back. Wire it into `run()` in two places: at startup (crash recovery from a previous hard-killed run) and in a `tokio::select!` on `ctrl_c()` (clean exit). Also move the function-local `use std::time::{SystemTime, UNIX_EPOCH}` out of `make_temp_dir` to the top-level imports per project convention. --- .../src/commands/dev/proxy/browser.rs | 135 +++++++++++++++--- .../src/commands/dev/proxy/mod.rs | 15 +- 2 files changed, 132 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 8d5a7471c..116bd21fa 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; use super::ProxyError; use super::ca::CA_COMMON_NAME; @@ -10,6 +11,13 @@ use super::config::{Browser, ResolvedConfig}; use super::rewrite::RuleTable; use crate::output; +/// Name of the file persisted under `ca_dir` that records the Safari proxy +/// state that was active before this tool set its own PAC URL. +/// +/// Format: two lines, `\n` — or `\n` (empty +/// second line) when auto-proxy was previously off. +const SAFARI_RESTORE_FILE: &str = "safari-proxy-restore"; + /// Generates a PAC script that proxies only `https://` requests for matched FROM hosts. /// /// All other requests fall through to `DIRECT`. @@ -96,11 +104,75 @@ pub fn launch( Ok(()) } +/// Restores the macOS Safari system auto-proxy to its state before the last +/// `launch_safari` call, if a pending restore file exists under `ca_dir`. +/// +/// Called both at startup (to recover from a previously hard-killed run) and +/// on clean exit (Ctrl-C). On non-macOS systems or when no restore file is +/// present, this is a no-op. Deletes the restore file even when the +/// `networksetup` command fails, preventing an infinite restore loop. Never +/// panics. +pub fn restore_system_proxy_if_pending(ca_dir: &Path) { + #[cfg(target_os = "macos")] + { + let restore_path = ca_dir.join(SAFARI_RESTORE_FILE); + if !restore_path.exists() { + return; + } + + let contents = match std::fs::read_to_string(&restore_path) { + Ok(s) => s, + Err(err) => { + output::warn(&format!( + "Safari: could not read proxy restore file: {err}; \ + restore the auto-proxy URL in System Settings → Network manually" + )); + // Remove the file so we don't retry forever. + let _ = std::fs::remove_file(&restore_path); + return; + } + }; + + let mut lines = contents.splitn(2, '\n'); + let service = lines.next().unwrap_or("").trim().to_string(); + let prior_pac = lines.next().unwrap_or("").trim().to_string(); + + if service.is_empty() { + output::warn( + "Safari: proxy restore file has no service name; \ + restore the auto-proxy URL in System Settings → Network manually", + ); + let _ = std::fs::remove_file(&restore_path); + return; + } + + // Remove the file first so a hard kill during restore doesn't loop. + let _ = std::fs::remove_file(&restore_path); + + restore_auto_proxy( + &service, + if prior_pac.is_empty() { + None + } else { + Some(&prior_pac) + }, + ); + output::info(&format!( + "Safari: restored prior auto-proxy setting for '{service}'" + )); + } + + #[cfg(not(target_os = "macos"))] + { + // Non-macOS: nothing to restore (Safari/networksetup don't exist). + let _ = ca_dir; + } +} + /// Creates a unique temp directory under the system temp dir. /// /// Returns `None` and prints a warning with `label` if the directory cannot be created. fn make_temp_dir(label: &str) -> Option { - use std::time::{SystemTime, UNIX_EPOCH}; let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| d.as_nanos()); @@ -262,10 +334,15 @@ fn firefox_command() -> Command { } } -/// Configures Safari via the system PAC URL, restoring the prior setting on exit. +/// Configures Safari via the system PAC URL (spec §9). /// -/// Detects the active network service via `route`/`networksetup` and sets a -/// PAC URL pointing at the running proxy's `/proxy.pac` route (spec §9). +/// Detects the active network service via `route`/`networksetup`, persists the +/// prior auto-proxy state to `/safari-proxy-restore`, then sets the +/// PAC URL pointing at the running proxy's `/proxy.pac` route. +/// +/// The restore file is consumed by [`restore_system_proxy_if_pending`] — either +/// at the next startup (crash recovery) or on clean Ctrl-C exit. If the +/// process is SIGKILL'd the file remains and is recovered on the next run. fn launch_safari(cfg: &ResolvedConfig) { let port = cfg.listen.port(); let pac_url = format!("http://127.0.0.1:{port}/proxy.pac"); @@ -279,9 +356,22 @@ fn launch_safari(cfg: &ResolvedConfig) { return; }; - // Save prior PAC URL so we can restore it on exit. + // Read prior state before changing anything. let prior_pac = get_auto_proxy_url(&service); + // Persist the prior state so it can be recovered even after a hard kill. + let restore_path = cfg.ca_dir.join(SAFARI_RESTORE_FILE); + let restore_contents = format!( + "{service}\n{prior}\n", + prior = prior_pac.as_deref().unwrap_or("") + ); + if let Err(err) = std::fs::write(&restore_path, &restore_contents) { + output::warn(&format!( + "Safari: could not write proxy restore file: {err}; \ + PAC URL will not be automatically restored on exit" + )); + } + let set_result = Command::new("networksetup") .args(["-setautoproxyurl", &service, &pac_url]) .status(); @@ -297,19 +387,10 @@ fn launch_safari(cfg: &ResolvedConfig) { "Safari: could not set PAC URL automatically; \ set it manually in System Settings → Network → {service}: {pac_url}" )); - return; + // Remove the restore file — nothing was applied, nothing to restore. + let _ = std::fs::remove_file(&restore_path); } } - - // Restore PAC on exit. - std::thread::spawn(move || { - // Wait for Ctrl-C or process exit — the restore runs when this thread - // is dropped (i.e., when the process exits). - // - // A cleaner approach (signal handler) is deferred; this is best-effort. - std::thread::park(); - restore_auto_proxy(&service, prior_pac.as_deref()); - }); } /// Returns the active Wi-Fi/Ethernet network service name, or `None`. @@ -420,4 +501,26 @@ mod tests { "everything else is direct" ); } + + #[test] + fn restore_system_proxy_if_pending_is_noop_when_no_file() { + let dir = tempfile::tempdir().expect("should create temp dir"); + // No file present — should not panic or error. + restore_system_proxy_if_pending(dir.path()); + } + + #[test] + fn restore_system_proxy_if_pending_removes_file_with_empty_service() { + let dir = tempfile::tempdir().expect("should create temp dir"); + let restore_path = dir.path().join(SAFARI_RESTORE_FILE); + // Write a malformed restore file (no service name). + std::fs::write(&restore_path, "\nhttp://127.0.0.1:8080/proxy.pac\n") + .expect("should write restore file"); + restore_system_proxy_if_pending(dir.path()); + // File should be removed even when service name is missing. + assert!( + !restore_path.exists(), + "restore file should be removed after failed parse" + ); + } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 7f046492b..ca194d2bb 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -148,6 +148,10 @@ pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report core::result::Result<(), error_stack::Report result.change_context(ProxyError::Server)?, + _ = tokio::signal::ctrl_c() => { + browser::restore_system_proxy_if_pending(&cfg.ca_dir); + Ok(()) + } + } }) } From 6000366b5cf64dc43b0437ee0f91edce4ae6f118 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:58:31 -0700 Subject: [PATCH 16/40] Infer dev-proxy rule from trusted-server.toml for zero-arg use --- crates/trusted-server-cli/Cargo.toml | 1 + .../src/commands/dev/proxy/config.rs | 137 +++++++++++++++++- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 117b037b7..34d15b8bc 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -33,6 +33,7 @@ log = "0.4" env_logger = "0.11" base64 = "0.22" directories = "5" +toml = "0.8" [dev-dependencies] tempfile = "3" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 05e891576..55f2711f6 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -1,7 +1,7 @@ //! Resolves `ProxyArgs` (+ defaults) into a concrete [`ResolvedConfig`]. use std::net::{IpAddr, SocketAddr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use base64::Engine as _; use error_stack::{Report, ResultExt as _}; @@ -132,7 +132,37 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { .map_or_else(default_ca_dir, PathBuf::from) } -fn build_rules(args: &ProxyArgs) -> Result { +/// Reads `publisher.domain` from `/trusted-server.toml`. +/// +/// Returns `None` if the file is missing, the key is absent, or parsing fails. +fn infer_from_host(project_dir: &Path) -> Option { + let path = project_dir.join("trusted-server.toml"); + let raw = std::fs::read_to_string(path).ok()?; + let table: toml::Table = raw.parse().ok()?; + table + .get("publisher")? + .as_table()? + .get("domain")? + .as_str() + .map(str::to_owned) +} + +/// Reads `[dev_proxy].upstream` from `/trusted-server.toml`. +/// +/// Returns `None` if the file is missing, the key is absent, or parsing fails. +fn infer_to_host(project_dir: &Path) -> Option { + let path = project_dir.join("trusted-server.toml"); + let raw = std::fs::read_to_string(path).ok()?; + let table: toml::Table = raw.parse().ok()?; + table + .get("dev_proxy")? + .as_table()? + .get("upstream")? + .as_str() + .map(str::to_owned) +} + +fn build_rules(args: &ProxyArgs, project_dir: &Path) -> Result { let mut rules = Vec::new(); let preserve_host = !args.rewrite_host; for entry in &args.map { @@ -142,7 +172,18 @@ fn build_rules(args: &ProxyArgs) -> Result { if let (Some(from), Some(to)) = (&args.from, &args.to) { rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); } - // NOTE: lone --to / lone --from + project-config inference is added in Task 7. + if rules.is_empty() && args.map.is_empty() { + let from = args.from.clone().or_else(|| infer_from_host(project_dir)); + let to = args.to.clone().or_else(|| infer_to_host(project_dir)); + if let (Some(from), Some(to)) = (from, to) { + rules.push(make_rule( + &from, + &to, + preserve_host, + args.upstream_plaintext, + )?); + } + } Ok(RuleTable(rules)) } @@ -161,14 +202,18 @@ fn make_rule( }) } -/// Resolves arguments into a [`ResolvedConfig`]. +/// Resolves arguments into a [`ResolvedConfig`], consulting `project_dir` for +/// `trusted-server.toml` inference. /// /// # Errors /// /// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen /// address, malformed credentials, or an unknown browser. -pub fn resolve(args: &ProxyArgs) -> Result> { - let rules = build_rules(args).map_err(Report::from)?; +pub fn resolve_in( + args: &ProxyArgs, + project_dir: &Path, +) -> Result> { + let rules = build_rules(args, project_dir).map_err(Report::from)?; if rules.0.is_empty() { return Err(Report::new(ConfigError::NoRule)); } @@ -208,6 +253,18 @@ pub fn resolve(args: &ProxyArgs) -> Result> }) } +/// Resolves arguments into a [`ResolvedConfig`], inferring missing rule parts +/// from `trusted-server.toml` in the current working directory. +/// +/// # Errors +/// +/// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen +/// address, malformed credentials, or an unknown browser. +pub fn resolve(args: &ProxyArgs) -> Result> { + let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + resolve_in(args, &project_dir) +} + /// Credential precedence: `--basic-auth-file` > `--basic-auth`. fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { if let Some(path) = &args.basic_auth_file { @@ -328,4 +385,72 @@ mod tests { "should be a BasicAuthFile error, not BasicAuth" ); } + + /// Writes a minimal `trusted-server.toml` into `dir` with the given content. + fn write_toml(dir: &tempfile::TempDir, content: &str) { + std::fs::write(dir.path().join("trusted-server.toml"), content) + .expect("should write trusted-server.toml"); + } + + #[test] + fn lone_to_pairs_with_inferred_from() { + let dir = tempfile::tempdir().expect("should create temp dir"); + write_toml( + &dir, + "[publisher]\ndomain = \"www.example-publisher.com\"\n", + ); + + let mut args = base_args(); + args.to = Some("some.edgecompute.app".into()); + + let cfg = resolve_in(&args, dir.path()).expect("should resolve with inferred FROM"); + let rule = cfg + .rules + .first_match("www.example-publisher.com") + .expect("should have a rule matching the inferred FROM domain"); + assert_eq!( + rule.to.host(), + "some.edgecompute.app", + "TO should be the value passed via --to" + ); + } + + #[test] + fn zero_arg_requires_dev_proxy_upstream() { + let dir = tempfile::tempdir().expect("should create temp dir"); + write_toml( + &dir, + "[publisher]\ndomain = \"www.example-publisher.com\"\n", + ); + + let args = base_args(); + let err = resolve_in(&args, dir.path()) + .expect_err("should error when [dev_proxy].upstream is absent"); + assert!( + matches!(err.current_context(), ConfigError::NoRule), + "should be a NoRule error when TO cannot be inferred" + ); + } + + #[test] + fn zero_arg_infers_both_when_present() { + let dir = tempfile::tempdir().expect("should create temp dir"); + write_toml( + &dir, + "[publisher]\ndomain = \"www.example-publisher.com\"\n\n[dev_proxy]\nupstream = \"origin.edgecompute.app\"\n", + ); + + let args = base_args(); + let cfg = resolve_in(&args, dir.path()) + .expect("should resolve when both publisher.domain and dev_proxy.upstream are set"); + let rule = cfg + .rules + .first_match("www.example-publisher.com") + .expect("should have a rule matching the inferred FROM domain"); + assert_eq!( + rule.to.host(), + "origin.edgecompute.app", + "TO should be the inferred dev_proxy.upstream" + ); + } } From 41245de3b95362b9740161873441ef24700c3c64 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:53:06 -0700 Subject: [PATCH 17/40] Document ts dev proxy setup, trust, and troubleshooting --- docs/.vitepress/config.mts | 1 + docs/guide/ts-dev-proxy.md | 256 +++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 docs/guide/ts-dev-proxy.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b65cb511f..7cb2135f7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -108,6 +108,7 @@ export default withMermaid( { text: 'Configuration', link: '/guide/configuration' }, { text: 'Testing', link: '/guide/testing' }, { text: 'Integration Guide', link: '/guide/integration-guide' }, + { text: 'Dev Proxy', link: '/guide/ts-dev-proxy' }, ], }, { diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md new file mode 100644 index 000000000..32573f367 --- /dev/null +++ b/docs/guide/ts-dev-proxy.md @@ -0,0 +1,256 @@ +# Dev Proxy + +Test a production publisher hostname against a dev or staging upstream — with a +real browser, real TLS, and no DNS change — using `ts dev proxy`. + +## What it does + +`ts dev proxy` is a TLS-terminating forward (MITM) proxy that runs on your +machine. When you open `https://www.example-publisher.com` in a browser pointed +at it, the address bar shows the production hostname but the request is served +by the upstream you specify — a Trusted Server Compute service, a staging +instance, or `localhost`. No production DNS, Fastly service, or certificate is +touched, and no other users are affected. + +**Why a local proxy is necessary.** The browser binds TLS SNI to the URL +hostname. Fastly routes by SNI to the service that owns that domain. So even +if you rewrite `/etc/hosts` or use `--host-resolver-rules`, the SNI still +delivers the request to the production service. The only way to reach a +different upstream while keeping the production hostname in the address bar is +to rewrite the SNI in flight — which requires terminating the browser's TLS +locally. + +To terminate TLS, the proxy presents a certificate for the production hostname. +For this to produce a green padlock in Chrome, Firefox, and Safari — and to +satisfy HSTS — the certificate must be signed by a CA the browser trusts. `ts +dev proxy` generates a per-machine Certificate Authority on first run; you trust +it once. + +**Primary use case.** Validate routing and behavior of a new or changed Trusted +Server deployment at the publisher's real domain — cookies, `Host`-sensitive +logic, CMP/consent flows, first-party context — before any DNS cutover. + +**Non-goals.** Not a production proxy or load-testing tool. Does not modify any +Fastly service. Local only, developer-facing. + +## Build and run + +`ts dev proxy` is part of `crates/trusted-server-cli`, a native binary excluded +from the workspace (the workspace default target is `wasm32-wasip1`). Build and +run it with an explicit native target: + +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy --help +``` + +### Zero-argument usage + +When `trusted-server.toml` contains a `[dev_proxy]` section, you can start the +proxy with no arguments from the project root: + +```toml +[publisher] +domain = "www.example-publisher.com" + +[dev_proxy] +upstream = "trusted-server-example.edgecompute.app" +``` + +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy +``` + +The proxy infers `FROM` from `publisher.domain` and `TO` from +`[dev_proxy].upstream`. If `[dev_proxy].upstream` is absent, the tool exits +with a clear error showing the inferred `FROM` and asks for `--to` or `--map`. + +### Explicit rule and browser launch + +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy \ + --map www.example-publisher.com=trusted-server-example.edgecompute.app \ + --launch chrome,firefox,safari +``` + +`--launch` takes a comma list (`chrome`, `firefox`, `safari`) or `all`. When +omitted the proxy runs without opening any browser. + +### Other examples + +```bash +# Gated staging upstream, Firefox only: +ts dev proxy \ + -f www.example-publisher.com \ + -t staging.example.net \ + --basic-auth dev:secret \ + --launch firefox + +# Local instance over plain HTTP, no browser: +ts dev proxy \ + -f www.example-publisher.com \ + -t localhost:3000 \ + --upstream-plaintext +``` + +## First run: CA setup + +On first run the proxy generates a per-machine Certificate Authority and prints: + +``` +generated dev CA at ~/Library/Application Support/trusted-server/dev-proxy/ca-cert.pem +— run `ts dev proxy ca install` to trust it +``` + +The CA key is stored with mode `0600` outside the repository and is never +committed. + +### Trust the CA on macOS (Chrome and Safari) + +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy ca install +``` + +This adds the CA to the macOS login keychain (no `sudo` required; prompts for +your login password). Chrome and Safari both consult the macOS keychain and +will trust the proxy's certificates immediately. + +### Trust the CA in Firefox + +Firefox does not reliably consult the macOS login keychain. When you use +`--launch firefox`, the proxy automatically imports the CA into the temporary +Firefox profile's NSS database using `certutil`. If you are pointing an existing +Firefox profile at the proxy manually, run: + +```bash +certutil -A -n "Trusted Server DEV-ONLY Proxy CA" \ + -t "CT,," \ + -i "$(cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy ca path)" \ + -d "$HOME/Library/Application Support/Firefox/Profiles/" +``` + +### Revoking trust when done + +```bash +cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy ca uninstall +``` + +This removes the CA from the macOS keychain. Run it when you are finished — +the CA is trusted for ~10 years and its key sits on disk. + +### Security note + +The dev CA is a standing MITM capability on your machine. Its key (`ca-key.pem`) +must be treated like a credential: + +- It is generated per-machine and never committed to the repository. +- The CA directory has mode `0700` and the key file `0600`. +- The CA CN is `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION`. +- Trust it only on a development machine you control. +- Run `ca uninstall` when done; run `ca regenerate` to rotate. + +## CA companion commands + +```bash +ts dev proxy ca path # print the CA certificate path +ts dev proxy ca install # trust the CA (macOS login keychain) +ts dev proxy ca uninstall # remove the CA from the trust store +ts dev proxy ca regenerate # generate a new CA (invalidates prior trust) +``` + +`ca path` and `ca install` generate the CA if it does not exist yet, so they +work on a freshly cloned machine before the proxy has been run. + +## Host header behavior + +By default the proxy sends `Host: ` (the production hostname) to the +upstream. This is required for Trusted Server core to rewrite first-party URLs +correctly: it anchors all HTML/URL rewriting to the inbound `Host`, so keeping +`Host = FROM` ensures rewritten links stay on the production domain. + +This works well against a Trusted Server Compute upstream because Fastly routes +by SNI (`= TO`) and passes `Host` through to the application unchanged. + +If your upstream validates or routes on its own hostname, pass `--rewrite-host`: + +```bash +ts dev proxy \ + --map www.example-publisher.com=staging.example.net \ + --rewrite-host \ + --launch chrome +``` + +With `--rewrite-host`, the proxy sends `Host: staging.example.net`. An +`X-Orig-Host: www.example-publisher.com` header is always sent informally. + +**Port handling.** When `--rewrite-host` is active and `TO` carries a +non-default port (e.g. `localhost:3000`), the port is included in the `Host` +header but never in the SNI (a bare hostname; a port in SNI is invalid). + +## Non-loopback listen + +The proxy binds `127.0.0.1:8080` by default. A non-loopback `--listen` is +rejected unless you also pass `--allow-non-loopback`: + +```bash +ts dev proxy \ + --map www.example-publisher.com=trusted-server-example.edgecompute.app \ + --listen 0.0.0.0:8080 \ + --allow-non-loopback +``` + +Even with `--allow-non-loopback`, unmatched `CONNECT` authorities are refused +(`403`) rather than blind-tunneled, so the proxy cannot act as an open CONNECT +proxy on the LAN. + +## All options + +``` +ts dev proxy [OPTIONS] [COMMAND] + +Options: + --map Rewrite rule (repeatable) + -f, --from Single-rule FROM (optional when inferable from config) + -t, --to Single-rule TO + --listen Listen address [default: 127.0.0.1:8080] + --allow-non-loopback Permit non-loopback --listen (disables blind tunnel) + --launch Browsers to launch (chrome,firefox,safari or all) + --rewrite-host Send Host: instead of the default + --basic-auth Inject Basic auth (visible in ps — prefer --basic-auth-file) + --basic-auth-file Read USER:PASS from a file + --insecure Skip upstream TLS certificate verification + --upstream-plaintext Connect to upstream over plain HTTP + --ca-dir CA cert/key directory [default: ~/Library/Application Support/ + trusted-server/dev-proxy on macOS] +``` + +The tool is flags-only; there are no environment variable overrides. + +## Browser details + +| Browser | How the proxy is configured | CA trust | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| Chrome | Temp `--user-data-dir`; `--proxy-server="https=127.0.0.1:"` (HTTPS only — plain HTTP goes direct) | macOS login keychain via `ca install` | +| Firefox | Temp profile with `user.js` setting `network.proxy.ssl` (HTTPS only — `network.proxy.http` is unset so plain HTTP goes direct) | CA imported into the profile's NSS DB at launch | +| Safari | System PAC at `http://127.0.0.1:/proxy.pac` via `networksetup` on the active network service, scoped to the configured `FROM` hosts; prior setting restored on exit | macOS login keychain via `ca install` | + +Safari's system proxy change is system-wide (all apps) while the proxy is +running. On a clean exit the prior setting is restored. After a hard kill (`SIGKILL`) +the next `ts dev proxy` run detects and restores the leftover state, or prints +the manual `networksetup` command. + +## Troubleshooting + +| Symptom | Cause | Fix | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| "unknown domain" or `404` from upstream | The upstream service does not accept `Host: ` (the default). A domain can be active on only one Fastly service at a time, so you cannot add the production hostname to a dev service. | Use a Trusted Server Compute upstream (routes by SNI, not `Host`), or pass `--rewrite-host` to send `Host: `. | +| Upstream returns `401` | Upstream is behind Basic auth. | Pass `--basic-auth user:pass` or `--basic-auth-file ./creds.txt`. | +| Upstream unreachable (`502` / `503`) | Upstream service is down or the domain is not provisioned. | Verify the upstream URL and its Fastly service health. | +| Browser shows an untrusted-certificate warning | The dev CA is not trusted in the browser. | Run `ts dev proxy ca install` for Chrome and Safari. For Firefox, use `--launch firefox` (auto-imports) or run `certutil` manually (see above). After `ca regenerate`, re-trust with `ca install`. | +| Listen address already in use | Another process holds port 8080. | Pass `--listen 127.0.0.1:8081` (or another free port). | +| `--listen` rejected as non-loopback | A non-loopback address was given without the required flag. | Add `--allow-non-loopback`. | From abe3d17b0959355c935cce1da92a1c0b26c15723 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:28:04 -0700 Subject: [PATCH 18/40] Format dev proxy spec and plan with Prettier --- .../plans/2026-06-22-ts-dev-proxy.md | 33 ++++- .../specs/2026-06-22-ts-dev-proxy-design.md | 126 +++++++++--------- 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index c5047fda4..091fdf23b 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -4,7 +4,7 @@ **Goal:** Build a local TLS-terminating (MITM) dev proxy, shipped as `ts dev proxy`, that serves a production publisher hostname from a dev/staging upstream by swapping the TLS SNI, using a per-machine local CA so Chrome/Firefox/Safari all trust it. -**Architecture:** A native host binary (`crates/trusted-server-cli`, **excluded** from the wasm workspace) built on tokio + hyper + rustls + rcgen. The accept loop handles `CONNECT`: it matches the authority against a rule table *before* replying, blind-tunnels unmatched hosts, and MITM-terminates matched hosts with a leaf minted from a local CA, rewriting SNI→`TO` while preserving `Host: FROM`. Pure logic (rule matching, header outcomes, PAC generation, config resolution) is isolated from I/O so it is unit-testable without sockets. +**Architecture:** A native host binary (`crates/trusted-server-cli`, **excluded** from the wasm workspace) built on tokio + hyper + rustls + rcgen. The accept loop handles `CONNECT`: it matches the authority against a rule table _before_ replying, blind-tunnels unmatched hosts, and MITM-terminates matched hosts with a leaf minted from a local CA, rewriting SNI→`TO` while preserving `Host: FROM`. Pure logic (rule matching, header outcomes, PAC generation, config resolution) is isolated from I/O so it is unit-testable without sockets. **Tech Stack:** Rust 2024 edition; `tokio` (`net`, `rt-multi-thread`, `macros`, `io-util`), `hyper` 1 + `hyper-util`, `rustls` 0.23 + `tokio-rustls` 0.26, `rcgen` 0.13 (`Issuer`/leaf minting), `rustls-pemfile` 2, `clap` 4 (derive), `error-stack` 0.6, `derive_more` 2, `log`, `base64` 0.22, `directories` (platform data dir). The spec is the source of truth: [docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md](../specs/2026-06-22-ts-dev-proxy-design.md). @@ -52,6 +52,7 @@ One responsibility per file. `rewrite.rs`, `config.rs`, `browser.rs` (PAC gen) a ## Task 1: Crate skeleton + workspace wiring + CLI surface **Files:** + - Modify: `Cargo.toml` (workspace root) — add to `[workspace].exclude` - Create: `crates/trusted-server-cli/Cargo.toml` - Create: `crates/trusted-server-cli/src/lib.rs` @@ -62,6 +63,7 @@ One responsibility per file. `rewrite.rs`, `config.rs`, `browser.rs` (PAC gen) a - Create: `crates/trusted-server-cli/src/commands/dev/proxy/mod.rs` **Interfaces:** + - Produces: `ProxyArgs` (clap-derived struct, fields below), `CaCommand` enum (`Path`/`Install`/`Uninstall`/`Regenerate`), `run(args: ProxyArgs) -> error_stack::Result<(), ProxyError>` (stub), `output::info(&str)` / `output::warn(&str)`. - [ ] **Step 1: Add the crate to the workspace exclude list** @@ -356,10 +358,12 @@ Add empty `ca.rs`, `config.rs`, `rewrite.rs` with a `//!` doc line so the module - [ ] **Step 7: Verify it builds and runs on the native target** Run: + ```bash cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy --help ``` + Expected: clap prints the `ts dev proxy` help including `--map`, `--rewrite-host`, `--allow-non-loopback`, and the `ca` subcommand. No build errors. - [ ] **Step 8: Verify the workspace gates still pass (crate stays out of wasm build)** @@ -381,10 +385,12 @@ git commit -m "Add trusted-server-cli crate skeleton with ts dev proxy CLI surfa Pure logic, no I/O. Implements spec §8.1–§8.4. This is the most heavily unit-tested module. **Files:** + - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs` - Test: same file, `#[cfg(test)] mod tests` **Interfaces:** + - Produces: - `struct Authority { host: String, port: u16, default_port: u16 }` with `fn host(&self) -> &str`, `fn is_default_port(&self) -> bool` (port equals the scheme default it was parsed with), `fn host_with_port(&self) -> String` (host, plus `:port` only when non-default), `fn parse(raw: &str, plaintext: bool) -> Result`. - `struct Rule { from: String, to: Authority, preserve_host: bool, plaintext: bool }`. @@ -650,10 +656,12 @@ git commit -m "Add rewrite core with rule matching and header outcomes" Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective settings. Pure logic except project-config inference, which is deferred to Task 7 (here, missing rules produce a clear error). Implements spec §10.1 precedence (flags > inference > defaults). The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable overrides. **Files:** + - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/config.rs` - Test: same file **Interfaces:** + - Consumes: `ProxyArgs` (Task 1), `Rule`/`RuleTable`/`Authority`/`RuleError` (Task 2). - Produces: - `struct ResolvedConfig { rules: RuleTable, listen: SocketAddr, allow_non_loopback: bool, launch: Vec, insecure: bool, basic_auth: Option, ca_dir: PathBuf }`. @@ -975,10 +983,12 @@ git commit -m "Resolve proxy args and env into a concrete rule table and setting Implements spec §7. Testable against a temp `--ca-dir`. **Files:** + - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/ca.rs` - Test: same file **Interfaces:** + - Produces: - `struct CertAuthority` with: - `fn load_or_generate(ca_dir: &Path) -> error_stack::Result` — reads `ca-cert.pem`/`ca-key.pem` or generates and persists them (dir `0700`, key `0600`); logs a one-time trust hint on generation. @@ -987,7 +997,7 @@ Implements spec §7. Testable against a temp `--ca-dir`. - `const CA_COMMON_NAME: &str = "Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION";` - `enum CaError` (`Display` + `Error`). -> **Crate-API note:** rcgen 0.13 exposes `KeyPair`, `CertificateParams`, `Certificate`, and `Issuer`. Method names (`self_signed`, `signed_by`, `serialize_pem`) have shifted across 0.13.x — verify against `cargo doc -p rcgen` for the pinned version and adjust the calls below if needed; the *shape* (generate CA → persist PEM → load issuer → mint leaf with SAN) is stable. +> **Crate-API note:** rcgen 0.13 exposes `KeyPair`, `CertificateParams`, `Certificate`, and `Issuer`. Method names (`self_signed`, `signed_by`, `serialize_pem`) have shifted across 0.13.x — verify against `cargo doc -p rcgen` for the pinned version and adjust the calls below if needed; the _shape_ (generate CA → persist PEM → load issuer → mint leaf with SAN) is stable. - [ ] **Step 1: Write the failing tests** @@ -1225,6 +1235,7 @@ Expected: PASS (3 tests). If rcgen method names differ for the pinned 0.13.x, ad - [ ] **Step 5: Lint and commit** Run clippy (as in Task 3 step 5), then: + ```bash git add crates/trusted-server-cli/src/commands/dev/proxy/ca.rs git commit -m "Add per-machine local CA with leaf minting and caching" @@ -1237,17 +1248,20 @@ git commit -m "Add per-machine local CA with leaf minting and caching" Implements spec §5. This is the I/O core; it is exercised by a native integration test rather than pure unit tests. **Files:** + - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/server.rs` - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/mod.rs` (call `server::bind`/`serve_on`) - Test: `crates/trusted-server-cli/tests/proxy_e2e.rs` (+ `tests/support/mod.rs`) **Interfaces:** + - Consumes: `ResolvedConfig` (Task 3), `CertAuthority` (Task 4), `RuleTable`/`rewrite_for` (Task 2). - Produces (both `pub`, reachable from integration tests via the `trusted_server_cli` lib target): - `async fn bind(addr: SocketAddr) -> std::io::Result` — binds the listen socket so the port is open (connections queue) **before** browsers are launched (Task 6). - `async fn serve_on(listener: TcpListener, cfg: Arc, ca: Arc, pac: Arc) -> error_stack::Result<(), ProxyError>` — accept loop; serves until the task is dropped. Splitting bind from serve is what makes the launch ordering in Task 6 safe. **Behavior contract (from spec §5, §8, §11):** + 1. Read the first request line. If it is `CONNECT host:port`: match `host` (authority) against `cfg.rules`. - **Match:** reply `200`, select `ca.server_config(host)`, TLS-accept, then loop reading HTTP/1.1 requests; for each apply `rewrite_for`, open the upstream (TLS unless `plaintext`; skip cert verify if `insecure`), forward, stream the response back. Close on `Upgrade:`. - **No match, loopback bind:** connect upstream **first**, reply `200` only on success (else `502`), then copy bytes both directions. @@ -1436,7 +1450,7 @@ async fn handle_connection( // Redact Authorization/Cookie in any logging. ``` -Implement the helper bodies (`read_request_head`, `handle_connect`, `mitm_loop`, `blind_tunnel`, `serve_pac`, `respond_status`, upstream connect with optional `insecure` `ClientConfig`). Use `tokio::io::copy_bidirectional` for the blind tunnel. For the non-loopback path, `handle_connect` must return `403` for unmatched authorities *before* any upstream connect. +Implement the helper bodies (`read_request_head`, `handle_connect`, `mitm_loop`, `blind_tunnel`, `serve_pac`, `respond_status`, upstream connect with optional `insecure` `ClientConfig`). Use `tokio::io::copy_bidirectional` for the blind tunnel. For the non-loopback path, `handle_connect` must return `403` for unmatched authorities _before_ any upstream connect. - [ ] **Step 4: Wire `bind` + `serve_on` into `run` (interim — no browsers yet)** @@ -1478,11 +1492,13 @@ git commit -m "Add CONNECT MITM proxy server with blind tunnel and local PAC rou Implements spec §9 and §4.2/§7.3. **Files:** + - Create: `crates/trusted-server-cli/src/commands/dev/proxy/browser.rs` - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/mod.rs` (finalize `run`: `ca` dispatch before resolve + browser launch after bind) - Test: `browser.rs` (`#[cfg(test)]`) for PAC generation (pure) **Interfaces:** + - Consumes: `RuleTable` (Task 2), `Browser` (Task 3), `CertAuthority::cert_path` (Task 4). - Produces: - `fn generate_pac(rules: &RuleTable, listen: SocketAddr) -> String`. @@ -1610,8 +1626,9 @@ Expected: PASS. - [ ] **Step 5: Finalize `run` — `ca` dispatch before resolution, then bind → spawn → launch → await** Replace the interim `run` (Task 5 step 4) with the final version. Two ordering fixes are essential: -- **`ca` subcommands must be handled *before* `config::resolve`** — `resolve` errors when no rewrite rule exists, but `ca path/install/uninstall/regenerate` are standalone (spec §4.2). Use `config::ca_dir(&args)`, which needs no rule. -- **Bind the listener *before* launching browsers**, and keep the runtime alive by awaiting the server. Launching before the socket is bound would point browsers at a dead port; blocking on `serve_on` before launching would never reach the launch. + +- **`ca` subcommands must be handled _before_ `config::resolve`** — `resolve` errors when no rewrite rule exists, but `ca path/install/uninstall/regenerate` are standalone (spec §4.2). Use `config::ca_dir(&args)`, which needs no rule. +- **Bind the listener _before_ launching browsers**, and keep the runtime alive by awaiting the server. Launching before the socket is bound would point browsers at a dead port; blocking on `serve_on` before launching would never reach the launch. ```rust pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { @@ -1679,10 +1696,12 @@ git commit -m "Add browser orchestration, PAC generation, and ca trust subcomman Implements spec §10.2. Lets `ts dev proxy` (and lone `--to`/`--from`) resolve a rule from `trusted-server.toml`. **Files:** + - Modify: `crates/trusted-server-cli/src/commands/dev/proxy/config.rs` - Test: same file **Interfaces:** + - Produces: `fn infer_from_host() -> Option` (reads `publisher.domain` from `trusted-server.toml` in the CWD), `fn infer_to_host() -> Option` (reads `[dev_proxy].upstream`). `build_rules` is extended so a lone `--to` pairs with the inferred FROM and zero-arg pairs both. - [ ] **Step 1: Write the failing tests** @@ -1724,6 +1743,7 @@ git commit -m "Infer dev-proxy rule from trusted-server.toml for zero-arg use" Implements spec §15 step 8. **Files:** + - Create: `docs/guide/ts-dev-proxy.md` **Interfaces:** none (docs only). @@ -1747,6 +1767,7 @@ git commit -m "Document ts dev proxy setup, trust, and troubleshooting" ## Self-Review **Spec coverage:** + - §3 decisions → Tasks 1–6 (crate exclude, default Host=FROM, blind tunnel, non-loopback). ✓ - §4 CLI surface + §4.2 ca subcommands → Task 1 (args), Task 6 (`ca`). ✓ - §5 architecture/flow (200-ordering, blind tunnel, keep-alive loop, Upgrade close, local routes) → Task 5. ✓ @@ -1765,7 +1786,7 @@ git commit -m "Document ts dev proxy setup, trust, and troubleshooting" **Type consistency:** `Authority::{host,host_with_port,is_default_port}` (now scheme-relative via the stored `default_port`), `RuleTable::first_match`, `rewrite_for → RewriteOutcome{sni,host_header,orig_host,scheme_is_tls}`, `ResolvedConfig`, `config::ca_dir`, `CertAuthority::{load_or_generate,server_config,cert_path}`, `server::{bind,serve_on}`, `Browser::parse_list`, `generate_pac` are used consistently across tasks. -**Review-round fixes (2026-06-22):** (1) crate is a **lib + bin** so integration tests reach internal modules; (2) `ca` subcommands resolve via `config::ca_dir` *before* rule resolution; (3) `run` binds the listener, spawns `serve_on`, launches browsers via `spawn_blocking`, then awaits the server — correct ordering and the runtime stays alive; (4) `Authority` stores its scheme `default_port` so `:80`/`:443` are kept/omitted per scheme. +**Review-round fixes (2026-06-22):** (1) crate is a **lib + bin** so integration tests reach internal modules; (2) `ca` subcommands resolve via `config::ca_dir` _before_ rule resolution; (3) `run` binds the listener, spawns `serve_on`, launches browsers via `spawn_blocking`, then awaits the server — correct ordering and the runtime stays alive; (4) `Authority` stores its scheme `default_port` so `:80`/`:443` are kept/omitted per scheme. **Second review round (2026-06-22):** (6) `ca` is a **nested** subcommand (`ProxySub::Ca { action }`) so the path is `ts dev proxy ca `, not `ts dev proxy `; (7) `ca path`/`ca install` call `load_or_generate` first so a fresh machine works before any proxy run; (8) `default_ca_dir` builds `…/trusted-server/dev-proxy` from `XDG_DATA_HOME`/`BaseDirs` (not `ProjectDirs`, which yields a reverse-DNS leaf); (9) CA validity is ~10 years and the leaf ≤ 90 days, both `now`-relative via `time`; (10) the blind-tunnel and basic-auth E2E tests have real assertions, plus a new keep-alive/sequential-request test. diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 4552dce94..a134aefb0 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -93,17 +93,17 @@ satisfies **HSTS**, which an "ignored" cert does not. Resolved during brainstorming and design review (2026-06-22): -| Decision | Choice | -|---|---| -| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | -| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | -| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | -| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | -| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | -| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | -| Crate wiring | **Excluded** from the workspace (like `integration-tests`), *not* a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | -| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO` for upstreams that route/validate on their own host. `X-Orig-Host` is informational (§8.3). | -| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | +| Decision | Choice | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO` for upstreams that route/validate on their own host. `X-Orig-Host` is informational (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | --- @@ -115,20 +115,20 @@ ts dev proxy [OPTIONS] ### 4.1 Options -| Flag | Value | Default | Description | -|---|---|---|---| -| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | -| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Optional when `FROM` is inferable from config (§10.2). | -| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Combines with `--from`, or with the inferred publisher domain when `--from` is omitted. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | -| `--listen` | `ADDR` | `127.0.0.1:8080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | -| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | -| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | -| `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | -| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | -| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | -| `--insecure` | flag | false | Skip **upstream** certificate verification. | -| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | -| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | +| Flag | Value | Default | Description | +| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | +| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Optional when `FROM` is inferable from config (§10.2). | +| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Combines with `--from`, or with the inferred publisher domain when `--from` is omitted. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `--listen` | `ADDR` | `127.0.0.1:8080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | +| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | +| `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | +| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | +| `--insecure` | flag | false | Skip **upstream** certificate verification. | +| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | +| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | ### 4.2 Companion subcommands @@ -289,7 +289,7 @@ struct CertAuthority { - **Mint** a leaf per **matched** CONNECT host (unmatched hosts are blind-tunneled and never get a leaf — §5): `subject_alt_name = [host]`, short validity (≤ 90 days), signed by `issuer`; wrap in a `rustls::ServerConfig` (ALPN - `http/1.1`); cache keyed by host. Sign *outside* the cache lock and + `http/1.1`); cache keyed by host. Sign _outside_ the cache lock and double-check before insert so concurrent first-time hosts don't serialize on the signing work. (An IP-literal host needs an IP-type SAN, not DNS.) - **Acceptor selection:** the CONNECT handler knows the host and selects the @@ -343,16 +343,16 @@ are instead refused with `403` (§11), never blind-tunneled. ### 8.3 Header rewriting on match -| Header | Action | Rationale | -|---|---|---| -| upstream connection + **SNI** | `rule.to` **host only** (port stripped) | SNI is a bare hostname; a `:port` in SNI is invalid and breaks the handshake | -| `Host` | `rule.from` (default) or `rule.to` if `--rewrite-host` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | -| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | -| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | -| `Proxy-Connection` | removed | hop-by-hop hygiene | +| Header | Action | Rationale | +| ----------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| upstream connection + **SNI** | `rule.to` **host only** (port stripped) | SNI is a bare hostname; a `:port` in SNI is invalid and breaks the handshake | +| `Host` | `rule.from` (default) or `rule.to` if `--rewrite-host` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | +| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | +| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | +| `Proxy-Connection` | removed | hop-by-hop hygiene | **Why `Host = FROM` is the default (resolved).** The §1 goal — validate cookies, -`Host`-sensitive logic, CMP/consent, and first-party context at the *real* +`Host`-sensitive logic, CMP/consent, and first-party context at the _real_ domain — requires the upstream to see `Host = FROM`. Trusted Server core derives `request_host` from the inbound `Host` (`RequestInfo::from_request` in `http_util.rs`) and anchors all HTML/RSC URL rewriting to it @@ -370,7 +370,7 @@ dev service. For those upstreams, pass `--rewrite-host` (sends `Host = TO`) or a the domain to the service. `X-Orig-Host: FROM` is still sent for upstreams that opt to honor it, but it is -**informational only**: TS core does not read it today and in fact *strips* +**informational only**: TS core does not read it today and in fact _strips_ spoofable forwarded host headers (`X-Forwarded-Host`, etc.) as an anti-spoofing measure. Reconcile any future trusted-`X-Orig-Host` contract with the existing `publisher.origin_host_header_override` knob. **Validation:** an integration test @@ -397,7 +397,7 @@ applies no rewrite rules. Full absolute-form plain-HTTP rewriting is future work **Local routes.** Origin-form requests addressed to the proxy's **own** listen address — notably `GET /proxy.pac` for Safari (§9) — are served locally and never -forwarded. The listener dispatches these *before* proxy handling: a request is +forwarded. The listener dispatches these _before_ proxy handling: a request is proxy traffic only if it is `CONNECT` or absolute-form; an origin-form request to a local route (`/proxy.pac`, a health check) is answered directly. On a non-loopback bind (§11), blind-forwarding is disabled, so only `CONNECT` to @@ -412,20 +412,20 @@ without launching any browser. When set, each listed browser is launched in a throwaway/temporary profile configured against the proxy, opening the first rule's `FROM` URL. -| Browser | Launch + configure | Trust | -|---|---|---| -| **chrome** | temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"` (per-scheme — **HTTPS only**, plain HTTP stays direct), `--no-first-run`, open URL | local CA via OS keychain (`ca install`) | +| Browser | Launch + configure | Trust | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **chrome** | temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"` (per-scheme — **HTTPS only**, plain HTTP stays direct), `--no-first-run`, open URL | local CA via OS keychain (`ca install`) | | **firefox** | temp profile `user.js` (not `prefs.js`, which Firefox owns and rewrites): `network.proxy.type=1` + **`network.proxy.ssl` host+port only** (leave `network.proxy.http` unset, so plain HTTP stays direct); `firefox -profile ` | Import the CA into the profile's NSS DB with `certutil -A` (robust, no sudo). `security.enterprise_roots.enabled=true` is unreliable on macOS — it reads the **admin/System** keychain, not the login keychain — so NSS import is the primary path. | -| **safari** | no per-app proxy: best-effort, **system-wide** `networksetup -setautoproxyurl ` on the active service, scoped to `FROM` via the PAC; open URL; **restore prior setting on exit** | local CA via macOS keychain (`ca install`) | +| **safari** | no per-app proxy: best-effort, **system-wide** `networksetup -setautoproxyurl ` on the active service, scoped to `FROM` via the PAC; open URL; **restore prior setting on exit** | local CA via macOS keychain (`ca install`) | PAC (Safari/system scoping) sends only `FROM` hosts to the proxy, everything else `DIRECT`: ```javascript function FindProxyForURL(url, host) { - if (url.substring(0, 6) == "https:" && host == "www.example-publisher.com") - return "PROXY 127.0.0.1:8080"; - return "DIRECT"; + if (url.substring(0, 6) == 'https:' && host == 'www.example-publisher.com') + return 'PROXY 127.0.0.1:8080' + return 'DIRECT' } ``` @@ -526,7 +526,7 @@ overrides. Every setting is a CLI flag (§4); the only file inputs are - **Production credentials reach `TO`.** With the default `Host = FROM`, the browser attaches the production hostname's cookies and any existing `Authorization` for `FROM`, and the proxy forwards them to `TO` — and injected - `--basic-auth` is *skipped* when an `Authorization` is already present (§8.3). + `--basic-auth` is _skipped_ when an `Authorization` is already present (§8.3). Point `TO` only at a dev/staging upstream you control. Launched **temp profiles** start with no real cookies, so prefer `--launch` over running the proxy against your everyday browser profile; for manual use, treat `TO` as @@ -542,16 +542,16 @@ overrides. Every setting is a CLI flag (§4); the only file inputs are ## 12. Constants and Defaults -| Name | Value | -|---|---| -| Default listen | `127.0.0.1:8080` | -| Default `--launch` | _unset_ (proxy only) | -| CA storage dir | `$XDG_DATA_HOME/trusted-server/dev-proxy/{ca-cert.pem,ca-key.pem}` (macOS: `~/Library/Application Support/…`; dir `0700`, key `0600`) | -| CA CN | `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION` | -| Leaf validity | ≤ 90 days | -| ALPN (both legs) | `http/1.1` | -| Injected real-host header | `X-Orig-Host` | -| Upstream port (default) | `443` (`80` with `--upstream-plaintext`) | +| Name | Value | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Default listen | `127.0.0.1:8080` | +| Default `--launch` | _unset_ (proxy only) | +| CA storage dir | `$XDG_DATA_HOME/trusted-server/dev-proxy/{ca-cert.pem,ca-key.pem}` (macOS: `~/Library/Application Support/…`; dir `0700`, key `0600`) | +| CA CN | `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION` | +| Leaf validity | ≤ 90 days | +| ALPN (both legs) | `http/1.1` | +| Injected real-host header | `X-Orig-Host` | +| Upstream port (default) | `443` (`80` with `--upstream-plaintext`) | --- @@ -559,15 +559,15 @@ overrides. Every setting is a CLI flag (§4); the only file inputs are `error-stack` with actionable messages mapped to the failures we actually hit: -| Condition | Detection | Message guidance | -|---|---|---| -| Upstream TLS `unrecognized_name` | rustls alert on connect | "`TO` has no TLS cert for its SNI — verify the domain is provisioned on the upstream Fastly service." | -| Upstream `401` | response status | "Upstream is gated; pass `--basic-auth user:pass`." | -| Upstream `503` / connect refused | response/IO | "Upstream unreachable or backend unhealthy; check the service and its origin healthcheck." | -| CA not trusted (browser warning) | n/a (browser-side) | Surface `ca install` / Firefox-profile note in the run banner. | -| Listen addr in use | bind error | Suggest `--listen` with another port. | -| Upstream "unknown domain" | `404` / Fastly error body | "The default `Host = FROM` isn't a domain the `TO` service accepts. Use a TS Compute upstream (routes by SNI), pass `--rewrite-host` to send `Host = TO`, or add the domain to the upstream service." | -| `Upgrade:` / WebSocket on a matched host | request `Upgrade` header | "Upgrades aren't proxied in v1 (§16); the connection is closed with a logged note." | +| Condition | Detection | Message guidance | +| ---------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Upstream TLS `unrecognized_name` | rustls alert on connect | "`TO` has no TLS cert for its SNI — verify the domain is provisioned on the upstream Fastly service." | +| Upstream `401` | response status | "Upstream is gated; pass `--basic-auth user:pass`." | +| Upstream `503` / connect refused | response/IO | "Upstream unreachable or backend unhealthy; check the service and its origin healthcheck." | +| CA not trusted (browser warning) | n/a (browser-side) | Surface `ca install` / Firefox-profile note in the run banner. | +| Listen addr in use | bind error | Suggest `--listen` with another port. | +| Upstream "unknown domain" | `404` / Fastly error body | "The default `Host = FROM` isn't a domain the `TO` service accepts. Use a TS Compute upstream (routes by SNI), pass `--rewrite-host` to send `Host = TO`, or add the domain to the upstream service." | +| `Upgrade:` / WebSocket on a matched host | request `Upgrade` header | "Upgrades aren't proxied in v1 (§16); the connection is closed with a logged note." | Per-request errors become a `502` with a short diagnostic body plus a logged line; the accept loop continues. The process never panics on a single bad From f1cd505880f2842fbca44bb6b4604d0a21e05708 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:28:04 -0700 Subject: [PATCH 19/40] Commit trusted-server-cli lockfile with resolved dependencies --- crates/trusted-server-cli/Cargo.lock | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/trusted-server-cli/Cargo.lock b/crates/trusted-server-cli/Cargo.lock index e942d9ca1..d54848fb3 100644 --- a/crates/trusted-server-cli/Cargo.lock +++ b/crates/trusted-server-cli/Cargo.lock @@ -1504,6 +1504,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1777,6 +1786,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1864,6 +1914,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "toml", "webpki-roots 0.26.11", "x509-parser", ] @@ -2218,6 +2269,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" From f409f34ba8d194286ad02add8cb56fb47cbba143 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:00:06 -0700 Subject: [PATCH 20/40] Require explicit rewrite rule; drop trusted-server.toml inference --- crates/trusted-server-cli/Cargo.lock | 60 -------- crates/trusted-server-cli/Cargo.toml | 1 - .../src/commands/dev/proxy/config.rs | 136 ++---------------- .../src/commands/dev/proxy/mod.rs | 4 +- 4 files changed, 12 insertions(+), 189 deletions(-) diff --git a/crates/trusted-server-cli/Cargo.lock b/crates/trusted-server-cli/Cargo.lock index d54848fb3..e942d9ca1 100644 --- a/crates/trusted-server-cli/Cargo.lock +++ b/crates/trusted-server-cli/Cargo.lock @@ -1504,15 +1504,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1786,47 +1777,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower" version = "0.5.3" @@ -1914,7 +1864,6 @@ dependencies = [ "time", "tokio", "tokio-rustls", - "toml", "webpki-roots 0.26.11", "x509-parser", ] @@ -2269,15 +2218,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 34d15b8bc..117b037b7 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -33,7 +33,6 @@ log = "0.4" env_logger = "0.11" base64 = "0.22" directories = "5" -toml = "0.8" [dev-dependencies] tempfile = "3" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 55f2711f6..06820555c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -1,7 +1,7 @@ //! Resolves `ProxyArgs` (+ defaults) into a concrete [`ResolvedConfig`]. use std::net::{IpAddr, SocketAddr}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use base64::Engine as _; use error_stack::{Report, ResultExt as _}; @@ -12,8 +12,8 @@ use super::rewrite::{Authority, Rule, RuleTable}; /// Errors from configuration resolution. #[derive(Debug, derive_more::Display)] pub enum ConfigError { - /// No usable rule could be formed and none was inferable. - #[display("no rewrite rule: pass --map FROM=TO (or --to with an inferable FROM)")] + /// No usable rule was passed. + #[display("no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to)")] NoRule, /// A `--map`/authority value was malformed. #[display("invalid rule value")] @@ -132,37 +132,7 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { .map_or_else(default_ca_dir, PathBuf::from) } -/// Reads `publisher.domain` from `/trusted-server.toml`. -/// -/// Returns `None` if the file is missing, the key is absent, or parsing fails. -fn infer_from_host(project_dir: &Path) -> Option { - let path = project_dir.join("trusted-server.toml"); - let raw = std::fs::read_to_string(path).ok()?; - let table: toml::Table = raw.parse().ok()?; - table - .get("publisher")? - .as_table()? - .get("domain")? - .as_str() - .map(str::to_owned) -} - -/// Reads `[dev_proxy].upstream` from `/trusted-server.toml`. -/// -/// Returns `None` if the file is missing, the key is absent, or parsing fails. -fn infer_to_host(project_dir: &Path) -> Option { - let path = project_dir.join("trusted-server.toml"); - let raw = std::fs::read_to_string(path).ok()?; - let table: toml::Table = raw.parse().ok()?; - table - .get("dev_proxy")? - .as_table()? - .get("upstream")? - .as_str() - .map(str::to_owned) -} - -fn build_rules(args: &ProxyArgs, project_dir: &Path) -> Result { +fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); let preserve_host = !args.rewrite_host; for entry in &args.map { @@ -172,18 +142,6 @@ fn build_rules(args: &ProxyArgs, project_dir: &Path) -> Result Result> { - let rules = build_rules(args, project_dir).map_err(Report::from)?; +pub fn resolve(args: &ProxyArgs) -> Result> { + let rules = build_rules(args).map_err(Report::from)?; if rules.0.is_empty() { return Err(Report::new(ConfigError::NoRule)); } @@ -253,18 +207,6 @@ pub fn resolve_in( }) } -/// Resolves arguments into a [`ResolvedConfig`], inferring missing rule parts -/// from `trusted-server.toml` in the current working directory. -/// -/// # Errors -/// -/// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen -/// address, malformed credentials, or an unknown browser. -pub fn resolve(args: &ProxyArgs) -> Result> { - let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - resolve_in(args, &project_dir) -} - /// Credential precedence: `--basic-auth-file` > `--basic-auth`. fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError> { if let Some(path) = &args.basic_auth_file { @@ -386,71 +328,13 @@ mod tests { ); } - /// Writes a minimal `trusted-server.toml` into `dir` with the given content. - fn write_toml(dir: &tempfile::TempDir, content: &str) { - std::fs::write(dir.path().join("trusted-server.toml"), content) - .expect("should write trusted-server.toml"); - } - #[test] - fn lone_to_pairs_with_inferred_from() { - let dir = tempfile::tempdir().expect("should create temp dir"); - write_toml( - &dir, - "[publisher]\ndomain = \"www.example-publisher.com\"\n", - ); - - let mut args = base_args(); - args.to = Some("some.edgecompute.app".into()); - - let cfg = resolve_in(&args, dir.path()).expect("should resolve with inferred FROM"); - let rule = cfg - .rules - .first_match("www.example-publisher.com") - .expect("should have a rule matching the inferred FROM domain"); - assert_eq!( - rule.to.host(), - "some.edgecompute.app", - "TO should be the value passed via --to" - ); - } - - #[test] - fn zero_arg_requires_dev_proxy_upstream() { - let dir = tempfile::tempdir().expect("should create temp dir"); - write_toml( - &dir, - "[publisher]\ndomain = \"www.example-publisher.com\"\n", - ); - + fn no_rule_passed_is_a_no_rule_error() { let args = base_args(); - let err = resolve_in(&args, dir.path()) - .expect_err("should error when [dev_proxy].upstream is absent"); + let err = resolve(&args).expect_err("should error when no rule is passed"); assert!( matches!(err.current_context(), ConfigError::NoRule), - "should be a NoRule error when TO cannot be inferred" - ); - } - - #[test] - fn zero_arg_infers_both_when_present() { - let dir = tempfile::tempdir().expect("should create temp dir"); - write_toml( - &dir, - "[publisher]\ndomain = \"www.example-publisher.com\"\n\n[dev_proxy]\nupstream = \"origin.edgecompute.app\"\n", - ); - - let args = base_args(); - let cfg = resolve_in(&args, dir.path()) - .expect("should resolve when both publisher.domain and dev_proxy.upstream are set"); - let rule = cfg - .rules - .first_match("www.example-publisher.com") - .expect("should have a rule matching the inferred FROM domain"); - assert_eq!( - rule.to.host(), - "origin.edgecompute.app", - "TO should be the inferred dev_proxy.upstream" + "should be a NoRule error when no --map/-f/-t is given" ); } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index ca194d2bb..c4bff6731 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -36,11 +36,11 @@ pub struct ProxyArgs { #[arg(long = "map", value_name = "FROM=TO")] pub map: Vec, - /// Shorthand single-rule FROM (optional when inferable from config). + /// Shorthand single-rule FROM (pairs with `--to`). #[arg(short = 'f', long = "from", value_name = "HOST")] pub from: Option, - /// Shorthand single-rule TO (`HOST[:PORT]`). + /// Shorthand single-rule TO (`HOST[:PORT]`; pairs with `--from`). #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] pub to: Option, From a289b83317299e798b65ed37a54c96a70c2ad65e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:08:52 -0700 Subject: [PATCH 21/40] Update spec, plan, and guide: rules are explicit, no toml inference --- docs/guide/ts-dev-proxy.md | 27 +++---- .../plans/2026-06-22-ts-dev-proxy.md | 67 ++++------------ .../specs/2026-06-22-ts-dev-proxy-design.md | 79 ++++++++----------- 3 files changed, 57 insertions(+), 116 deletions(-) diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 32573f367..f343dd819 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -44,27 +44,20 @@ cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy --help ``` -### Zero-argument usage +### Passing the rewrite rule -When `trusted-server.toml` contains a `[dev_proxy]` section, you can start the -proxy with no arguments from the project root: - -```toml -[publisher] -domain = "www.example-publisher.com" - -[dev_proxy] -upstream = "trusted-server-example.edgecompute.app" -``` +The upstream is always passed explicitly — there is no inference from +`trusted-server.toml` or any config file. Give a single rule with the `-f`/`-t` +shorthand, or one or more `--map FROM=TO` rules: ```bash cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \ - --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy + --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy \ + -f www.example-publisher.com -t trusted-server-example.edgecompute.app ``` -The proxy infers `FROM` from `publisher.domain` and `TO` from -`[dev_proxy].upstream`. If `[dev_proxy].upstream` is absent, the tool exits -with a clear error showing the inferred `FROM` and asks for `--to` or `--map`. +With no `--map`/`-f`/`-t`, the proxy exits with +`no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to)`. ### Explicit rule and browser launch @@ -215,8 +208,8 @@ ts dev proxy [OPTIONS] [COMMAND] Options: --map Rewrite rule (repeatable) - -f, --from Single-rule FROM (optional when inferable from config) - -t, --to Single-rule TO + -f, --from Single-rule FROM (pairs with --to) + -t, --to Single-rule TO (pairs with --from) --listen Listen address [default: 127.0.0.1:8080] --allow-non-loopback Permit non-loopback --listen (disables blind tunnel) --launch Browsers to launch (chrome,firefox,safari or all) diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index 091fdf23b..fcd6ec33b 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -38,7 +38,7 @@ crates/trusted-server-cli/ proxy/ mod.rs # ProxyArgs (clap), CaArgs; orchestration entrypoint rewrite.rs # Rule, RuleTable, Match, RewriteOutcome — pure logic - config.rs # ResolvedConfig: args + env + project-config inference + config.rs # ResolvedConfig: arg resolution into a rule table ca.rs # CertAuthority: load-or-generate, mint+cache leaves server.rs # accept loop, CONNECT dispatch, blind tunnel, MITM, local routes browser.rs # PAC generation; Chrome/Firefox/Safari launch+configure; ca install/uninstall @@ -267,7 +267,7 @@ pub struct ProxyArgs { #[arg(long = "map", value_name = "FROM=TO")] pub map: Vec, - /// Shorthand single-rule FROM (optional when inferable from config). + /// Shorthand single-rule FROM (pairs with --to). #[arg(short = 'f', long = "from", value_name = "HOST")] pub from: Option, @@ -653,7 +653,7 @@ git commit -m "Add rewrite core with rule matching and header outcomes" ## Task 3: Config resolution (args + rule construction) -Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective settings. Pure logic except project-config inference, which is deferred to Task 7 (here, missing rules produce a clear error). Implements spec §10.1 precedence (flags > inference > defaults). The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable overrides. +Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective settings. Pure logic. Rules are passed explicitly (`--map`/`-f`/`-t`); a missing rule produces a clear `NoRule` error. The tool is **flags-only** — no `TS_DEV_PROXY_*` env overrides and no `trusted-server.toml` inference. **Files:** @@ -760,8 +760,8 @@ use super::rewrite::{Authority, Rule, RuleTable}; /// Errors from configuration resolution. #[derive(Debug, derive_more::Display)] pub enum ConfigError { - /// No usable rule could be formed and none was inferable. - #[display("no rewrite rule: pass --map FROM=TO (or --to with an inferable FROM)")] + /// No usable rule was passed. + #[display("no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to)")] NoRule, /// A `--map`/authority value was malformed. #[display("invalid rule value")] @@ -879,7 +879,6 @@ fn build_rules(args: &ProxyArgs) -> Result { if let (Some(from), Some(to)) = (&args.from, &args.to) { rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); } - // NOTE: lone --to / lone --from + project-config inference is added in Task 7. Ok(RuleTable(rules)) } @@ -1691,50 +1690,14 @@ git commit -m "Add browser orchestration, PAC generation, and ca trust subcomman --- -## Task 7: Project-config inference (zero-arg ergonomics) +## Task 7: ~~Project-config inference~~ — DROPPED -Implements spec §10.2. Lets `ts dev proxy` (and lone `--to`/`--from`) resolve a rule from `trusted-server.toml`. - -**Files:** - -- Modify: `crates/trusted-server-cli/src/commands/dev/proxy/config.rs` -- Test: same file - -**Interfaces:** - -- Produces: `fn infer_from_host() -> Option` (reads `publisher.domain` from `trusted-server.toml` in the CWD), `fn infer_to_host() -> Option` (reads `[dev_proxy].upstream`). `build_rules` is extended so a lone `--to` pairs with the inferred FROM and zero-arg pairs both. - -- [ ] **Step 1: Write the failing tests** - -```rust -#[test] -fn lone_to_pairs_with_inferred_from(/* uses a temp CWD with trusted-server.toml */) { - // Arrange: write trusted-server.toml with [publisher] domain = "www.example-publisher.com" - // and run resolve() with only --to set. - // Assert: a single rule with from = the inferred publisher domain. -} - -#[test] -fn zero_arg_requires_dev_proxy_upstream() { - // Arrange: trusted-server.toml with publisher.domain but no [dev_proxy].upstream. - // Assert: resolve() errors with NoRule and the message names --to/--map. -} -``` - -> Implement these with a helper that writes a `trusted-server.toml` into a `tempfile::tempdir()` and parses from an explicit path (add `fn resolve_in(args, project_dir)` so tests don't depend on the process CWD). Use a minimal hand-rolled TOML read (the two keys) or add a `toml` dev-dependency; keep the parser scoped to `publisher.domain` and `dev_proxy.upstream`. - -- [ ] **Step 2: Run to verify they fail.** Run the `config::` tests; expected FAIL. - -- [ ] **Step 3: Implement inference** — add `infer_from_host`/`infer_to_host` and extend `build_rules`: if `--map`/`-f`/`-t` produced no rule, try `(--from or inferred FROM, --to or inferred TO)`; if either side is missing, return `ConfigError::NoRule`. List candidates when multiple publishers exist. - -- [ ] **Step 4: Run to verify they pass.** Expected PASS. - -- [ ] **Step 5: Lint and commit** - -```bash -git add crates/trusted-server-cli/src/commands/dev/proxy/config.rs -git commit -m "Infer dev-proxy rule from trusted-server.toml for zero-arg use" -``` +**Status: removed (scope change 2026-06-22).** Rewrite rules must be passed +explicitly via `--map`/`-f`/`-t`; the proxy does **not** infer them from +`trusted-server.toml` (or any config file). There is no `infer_from_host` / +`infer_to_host`, no `[dev_proxy].upstream` field, and no `toml` dependency. With +no rule, `resolve` returns `ConfigError::NoRule` with a message naming +`--map`/`-f`/`-t` (see spec §10.2). --- @@ -1775,14 +1738,14 @@ git commit -m "Document ts dev proxy setup, trust, and troubleshooting" - §7 CA (load-or-generate, 0600/0700, mint+cache, install/uninstall) → Tasks 4, 6. ✓ - §8 rewrite (Authority/RuleTable/matching/header outcomes/port-vs-SNI) → Task 2. ✓ - §9 browser orchestration (HTTPS-only Chrome/Firefox, Safari PAC + active-service) → Task 6. ✓ -- §10 config (precedence, inference; flags-only — no env vars) → Tasks 3, 7. ✓ +- §10 config (precedence; explicit rules only — no env vars, no config-file inference) → Task 3. ✓ - §11 security (non-loopback guard, redaction, credential input, blind-tunnel privacy) → Tasks 3, 5. ✓ - §12 constants → encoded in Tasks 2/4 (ports, ALPN, validity, CN). ✓ - §13 error handling → Task 5 status mapping + Task 8 troubleshooting table. ✓ - §14 testing (rewrite unit, ca unit, native integration incl. blind-tunnel, basic-auth, and keep-alive/sequential-request coverage) → Tasks 2, 4, 5. ✓ - §16 out-of-scope (HTTP/2, WebSocket, plain-HTTP rewriting) → respected (Upgrade closed; stray HTTP blind-forwarded only). ✓ -**Placeholder scan:** I/O-bound helper bodies in Tasks 5–7 (forwarding loops, browser launch, inference TOML read) are described by an explicit behavior contract with signatures rather than full literal bodies, because their exact code depends on the pinned tokio/hyper/rcgen APIs; the pure-logic tasks (2, 3, 6-PAC) carry complete code and tests. Flagged the rcgen API drift explicitly in Task 4. No `TODO`/`TBD` left in committed code. +**Placeholder scan:** I/O-bound helper bodies in Tasks 5–6 (forwarding loops, browser launch) are described by an explicit behavior contract with signatures rather than full literal bodies, because their exact code depends on the pinned tokio/hyper/rcgen APIs; the pure-logic tasks (2, 3, 6-PAC) carry complete code and tests. Flagged the rcgen API drift explicitly in Task 4. No `TODO`/`TBD` left in committed code. **Type consistency:** `Authority::{host,host_with_port,is_default_port}` (now scheme-relative via the stored `default_port`), `RuleTable::first_match`, `rewrite_for → RewriteOutcome{sni,host_header,orig_host,scheme_is_tls}`, `ResolvedConfig`, `config::ca_dir`, `CertAuthority::{load_or_generate,server_config,cert_path}`, `server::{bind,serve_on}`, `Browser::parse_list`, `generate_pac` are used consistently across tasks. @@ -1794,6 +1757,8 @@ git commit -m "Document ts dev proxy setup, trust, and troubleshooting" **Scope change (2026-06-22):** environment-variable support (`TS_DEV_PROXY_*`, former spec §10.3) was **dropped** — the tool is flags-only. `ProxyArgs` no longer carries clap `env`, `build_rules` has no `TS_DEV_PROXY_MAP` path, and `warn_unknown_env` is gone (config tests: 6). +**Scope change (2026-06-22):** project-config inference (former Task 7 / spec §10.2) was **dropped** — rules must be passed explicitly via `--map`/`-f`/`-t`. Removed `infer_from_host`/`infer_to_host`, the `resolve_in` plumbing, the `[dev_proxy].upstream` idea, the `toml` dependency, and the inference tests; `resolve` returns `ConfigError::NoRule` when no rule is given. + --- ## Execution Handoff diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index a134aefb0..6d9a47403 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -115,20 +115,20 @@ ts dev proxy [OPTIONS] ### 4.1 Options -| Flag | Value | Default | Description | -| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | -| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Optional when `FROM` is inferable from config (§10.2). | -| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Combines with `--from`, or with the inferred publisher domain when `--from` is omitted. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | -| `--listen` | `ADDR` | `127.0.0.1:8080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | -| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | -| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | -| `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | -| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | -| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | -| `--insecure` | flag | false | Skip **upstream** certificate verification. | -| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | -| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | +| Flag | Value | Default | Description | +| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | +| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | +| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `--listen` | `ADDR` | `127.0.0.1:8080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | +| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | +| `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | +| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | +| `--insecure` | flag | false | Skip **upstream** certificate verification. | +| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | +| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | ### 4.2 Companion subcommands @@ -142,8 +142,8 @@ ts dev proxy ca regenerate # regenerate the per-machine CA (invalidates prior t ### 4.3 Examples ```bash -# Default: infer rule from project config, run proxy only (no browser): -ts dev proxy +# Single rule via shorthand, run proxy only (no browser): +ts dev proxy -f www.example-publisher.com -t trusted-server-example.edgecompute.app # Explicit map to a Compute service, launch+configure all three browsers: ts dev proxy --map www.example-publisher.com=trusted-server-example.edgecompute.app \ @@ -224,7 +224,7 @@ crates/trusted-server-cli/ ca.rs # CertAuthority: load-or-generate per-machine CA, mint+cache per-host leaves rewrite.rs # RuleTable, Rule, RewriteOutcome browser.rs # launch+configure Chrome/Firefox/Safari; PAC generation - config.rs # arg + project-config resolution into RuleTable + config.rs # arg resolution into RuleTable ``` **Workspace integration.** Add `crates/trusted-server-cli` to the `[workspace] @@ -460,38 +460,23 @@ with the others. ### 10.1 Precedence -CLI flags > project-config inference (§10.2) > built-in -defaults. `--map`/`-f`/`-t` rules are unioned (first-match-wins by declared -order). `--from` and `--to` may be supplied independently: a lone `--to` pairs -with the inferred `FROM`, and a lone `--from` pairs with the inferred `TO` -(§10.2). A rule is complete only when both sides resolve; otherwise the tool -errors with what it could and couldn't infer. +CLI flags > built-in defaults. `--map`/`-f`/`-t` rules are unioned +(first-match-wins by declared order). A rule is **passed explicitly**: either a +`--map FROM=TO`, or `-f/--from` **and** `-t/--to` together. If no complete rule +is given, the tool exits with `no rewrite rule: pass --map FROM=TO …`. There is +**no** inference from `trusted-server.toml` or any other config file. -### 10.2 Project-config inference (zero-arg ergonomics) +### 10.2 No config-file inference -With no `--map`/`-f`/`-t`, infer a single rule from the Trusted Server project -config so the common case is argument-free: - -- `FROM` ← the publisher first-party domain (`publisher.domain` in - `trusted-server.toml` — the public hostname, **not** `publisher.origin_url`'s - host, which is the upstream origin). -- `TO` ← a dev-proxy upstream that **must be added to config**: no existing field - carries the Compute/staging hostname (`fastly.toml` has only `service_id`, and - `edgecompute.app` appears only in comments). Add an explicit field, honored - only when no `--map`/`-f`/`-t`/`--to` is given: - - ```toml - [dev_proxy] - upstream = "trusted-server-example.edgecompute.app" - ``` - -Until `[dev_proxy].upstream` exists, zero-arg `ts dev proxy` cannot infer `TO`: -exit with a clear error showing the inferred `FROM` and asking for `--to`/`--map`. -If `FROM` is ambiguous (multiple publishers), list candidates. +Rewrite rules are **never** inferred from `trusted-server.toml` (or any other +file) — the upstream must always be passed on the command line via `--map` or +`-f`/`-t`. This keeps what the proxy does fully explicit and visible in the +invocation, with no hidden dependence on the working directory or on a config +key. The only file input the proxy reads is `--basic-auth-file` (and the +per-machine CA under `--ca-dir`). The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable -overrides. Every setting is a CLI flag (§4); the only file inputs are -`trusted-server.toml` (inference, §10.2) and `--basic-auth-file`. +overrides either. Every setting is a CLI flag (§4). --- @@ -617,9 +602,7 @@ padlock and the production hostname in the address bar. 6. **Browser orchestration.** `--launch` list (no default — unset runs proxy only): Chrome + Firefox profiles, Safari PAC via `networksetup` with restore-on-exit; PAC generation; `ts dev proxy ca {path,install,uninstall,regenerate}`. -7. **Project-config inference.** Zero-arg resolution from `trusted-server.toml` - / `.env.ts.*`. -8. **Docs.** A `docs/guide/` page: setup, per-browser trust, the §13 +7. **Docs.** A `docs/guide/` page: setup, per-browser trust, the §13 troubleshooting table, and the per-machine CA security note. Steps 1–4 already deliver a usable tool; each step is independently shippable. From 2f79728628a2727e7628a668378a64ffdf60e32d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:53:03 -0700 Subject: [PATCH 22/40] Change default proxy port to 18080 to avoid 8080 collisions --- .../trusted-server-cli/src/commands/dev/proxy/browser.rs | 9 ++++++--- .../trusted-server-cli/src/commands/dev/proxy/config.rs | 2 +- crates/trusted-server-cli/src/commands/dev/proxy/mod.rs | 2 +- docs/guide/ts-dev-proxy.md | 8 ++++---- docs/superpowers/plans/2026-06-22-ts-dev-proxy.md | 8 ++++---- docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md | 8 ++++---- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 116bd21fa..91ffd4257 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -486,14 +486,17 @@ mod tests { preserve_host: true, plaintext: false, }]); - let pac = generate_pac(&rules, "127.0.0.1:8080".parse().expect("should parse addr")); + let pac = generate_pac( + &rules, + "127.0.0.1:18080".parse().expect("should parse addr"), + ); assert!(pac.contains("https:"), "PAC guards on https scheme"); assert!( pac.contains("www.example-publisher.com"), "PAC lists the FROM host" ); assert!( - pac.contains("PROXY 127.0.0.1:8080"), + pac.contains("PROXY 127.0.0.1:18080"), "PAC points at the listen addr" ); assert!( @@ -514,7 +517,7 @@ mod tests { let dir = tempfile::tempdir().expect("should create temp dir"); let restore_path = dir.path().join(SAFARI_RESTORE_FILE); // Write a malformed restore file (no service name). - std::fs::write(&restore_path, "\nhttp://127.0.0.1:8080/proxy.pac\n") + std::fs::write(&restore_path, "\nhttp://127.0.0.1:18080/proxy.pac\n") .expect("should write restore file"); restore_system_proxy_if_pending(dir.path()); // File should be removed even when service name is missing. diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 06820555c..0b4345e32 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -274,7 +274,7 @@ mod tests { fn non_loopback_listen_requires_flag() { let mut args = base_args(); args.map = vec!["a.example.com=b.edgecompute.app".into()]; - args.listen = "0.0.0.0:8080".into(); + args.listen = "0.0.0.0:18080".into(); assert!( resolve(&args).is_err(), "non-loopback without flag is rejected" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index c4bff6731..e9f42c9cf 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -45,7 +45,7 @@ pub struct ProxyArgs { pub to: Option, /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. - #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080")] + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:18080")] pub listen: String, /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index f343dd819..9bf85afe3 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -187,13 +187,13 @@ header but never in the SNI (a bare hostname; a port in SNI is invalid). ## Non-loopback listen -The proxy binds `127.0.0.1:8080` by default. A non-loopback `--listen` is +The proxy binds `127.0.0.1:18080` by default. A non-loopback `--listen` is rejected unless you also pass `--allow-non-loopback`: ```bash ts dev proxy \ --map www.example-publisher.com=trusted-server-example.edgecompute.app \ - --listen 0.0.0.0:8080 \ + --listen 0.0.0.0:18080 \ --allow-non-loopback ``` @@ -210,7 +210,7 @@ Options: --map Rewrite rule (repeatable) -f, --from Single-rule FROM (pairs with --to) -t, --to Single-rule TO (pairs with --from) - --listen Listen address [default: 127.0.0.1:8080] + --listen Listen address [default: 127.0.0.1:18080] --allow-non-loopback Permit non-loopback --listen (disables blind tunnel) --launch Browsers to launch (chrome,firefox,safari or all) --rewrite-host Send Host: instead of the default @@ -245,5 +245,5 @@ the manual `networksetup` command. | Upstream returns `401` | Upstream is behind Basic auth. | Pass `--basic-auth user:pass` or `--basic-auth-file ./creds.txt`. | | Upstream unreachable (`502` / `503`) | Upstream service is down or the domain is not provisioned. | Verify the upstream URL and its Fastly service health. | | Browser shows an untrusted-certificate warning | The dev CA is not trusted in the browser. | Run `ts dev proxy ca install` for Chrome and Safari. For Firefox, use `--launch firefox` (auto-imports) or run `certutil` manually (see above). After `ca regenerate`, re-trust with `ca install`. | -| Listen address already in use | Another process holds port 8080. | Pass `--listen 127.0.0.1:8081` (or another free port). | +| Listen address already in use | Another process holds port 18080. | Pass `--listen 127.0.0.1:18081` (or another free port). | | `--listen` rejected as non-loopback | A non-loopback address was given without the required flag. | Add `--allow-non-loopback`. | diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index fcd6ec33b..20626173f 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -276,7 +276,7 @@ pub struct ProxyArgs { pub to: Option, /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. - #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8080")] + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:18080")] pub listen: String, /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). @@ -717,7 +717,7 @@ mod tests { fn non_loopback_listen_requires_flag() { let mut args = base_args(); args.map = vec!["a.example.com=b.edgecompute.app".into()]; - args.listen = "0.0.0.0:8080".into(); + args.listen = "0.0.0.0:18080".into(); assert!(resolve(&args).is_err(), "non-loopback without flag is rejected"); args.allow_non_loopback = true; assert!(resolve(&args).is_ok(), "non-loopback allowed with flag"); @@ -1520,10 +1520,10 @@ mod tests { preserve_host: true, plaintext: false, }]); - let pac = generate_pac(&rules, "127.0.0.1:8080".parse().expect("addr")); + let pac = generate_pac(&rules, "127.0.0.1:18080".parse().expect("addr")); assert!(pac.contains("https:"), "PAC guards on https scheme"); assert!(pac.contains("www.example-publisher.com"), "PAC lists the FROM host"); - assert!(pac.contains("PROXY 127.0.0.1:8080"), "PAC points at the listen addr"); + assert!(pac.contains("PROXY 127.0.0.1:18080"), "PAC points at the listen addr"); assert!(pac.contains("return \"DIRECT\""), "everything else is direct"); } } diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 6d9a47403..736c5a787 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -120,7 +120,7 @@ ts dev proxy [OPTIONS] | `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | | `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | | `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | -| `--listen` | `ADDR` | `127.0.0.1:8080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | | `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | | `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | | `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | @@ -164,7 +164,7 @@ ts dev proxy -f www.example-publisher.com -t localhost:3000 \ ```mermaid sequenceDiagram - participant B as Browser
(proxy = 127.0.0.1:8080) + participant B as Browser
(proxy = 127.0.0.1:18080) participant P as ts dev proxy participant U as Upstream
(Compute / staging) @@ -424,7 +424,7 @@ else `DIRECT`: ```javascript function FindProxyForURL(url, host) { if (url.substring(0, 6) == 'https:' && host == 'www.example-publisher.com') - return 'PROXY 127.0.0.1:8080' + return 'PROXY 127.0.0.1:18080' return 'DIRECT' } ``` @@ -529,7 +529,7 @@ overrides either. Every setting is a CLI flag (§4). | Name | Value | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Default listen | `127.0.0.1:8080` | +| Default listen | `127.0.0.1:18080` | | Default `--launch` | _unset_ (proxy only) | | CA storage dir | `$XDG_DATA_HOME/trusted-server/dev-proxy/{ca-cert.pem,ca-key.pem}` (macOS: `~/Library/Application Support/…`; dir `0700`, key `0600`) | | CA CN | `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION` | From a8ba32605344d4b669eaed9f344578218abebdc1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:00:38 -0700 Subject: [PATCH 23/40] Open Safari at the FROM URL on --launch safari, like Chrome and Firefox --- .../src/commands/dev/proxy/browser.rs | 13 ++++++++++--- docs/guide/ts-dev-proxy.md | 19 +++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 91ffd4257..8ea87c3e0 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -378,9 +378,16 @@ fn launch_safari(cfg: &ResolvedConfig) { match set_result { Ok(s) if s.success() => { - output::info(&format!( - "Safari: PAC URL set for '{service}'; open Safari and browse to a proxied host" - )); + // Open Safari at the first rule's FROM URL, like Chrome and Firefox do. + if let Some(rule) = cfg.rules.0.first() { + let url = format!("https://{}", rule.from); + let _ = Command::new("open").args(["-a", "Safari", &url]).status(); + output::info(&format!("Safari: PAC set for '{service}'; opened {url}")); + } else { + output::info(&format!( + "Safari: PAC URL set for '{service}'; open Safari and browse to a proxied host" + )); + } } _ => { output::warn(&format!( diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 9bf85afe3..b59414738 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -226,14 +226,17 @@ The tool is flags-only; there are no environment variable overrides. ## Browser details -| Browser | How the proxy is configured | CA trust | -| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| Chrome | Temp `--user-data-dir`; `--proxy-server="https=127.0.0.1:"` (HTTPS only — plain HTTP goes direct) | macOS login keychain via `ca install` | -| Firefox | Temp profile with `user.js` setting `network.proxy.ssl` (HTTPS only — `network.proxy.http` is unset so plain HTTP goes direct) | CA imported into the profile's NSS DB at launch | -| Safari | System PAC at `http://127.0.0.1:/proxy.pac` via `networksetup` on the active network service, scoped to the configured `FROM` hosts; prior setting restored on exit | macOS login keychain via `ca install` | - -Safari's system proxy change is system-wide (all apps) while the proxy is -running. On a clean exit the prior setting is restored. After a hard kill (`SIGKILL`) +| Browser | How the proxy is configured | CA trust | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| Chrome | Temp `--user-data-dir`; `--proxy-server="https=127.0.0.1:"` (HTTPS only — plain HTTP goes direct) | macOS login keychain via `ca install` | +| Firefox | Temp profile with `user.js` setting `network.proxy.ssl` (HTTPS only — `network.proxy.http` is unset so plain HTTP goes direct) | CA imported into the profile's NSS DB at launch | +| Safari | System PAC at `http://127.0.0.1:/proxy.pac` via `networksetup` on the active network service, scoped to the configured `FROM` hosts; then opens Safari at the first rule's `FROM` URL; prior setting restored on exit | macOS login keychain via `ca install` | + +Unlike Chrome/Firefox (which run in a throwaway profile), Safari uses your +**system** proxy settings, so `--launch safari` sets the macOS auto-proxy on the +active network service (e.g. Wi-Fi) and then opens Safari at the `FROM` URL. The +change is system-wide (all apps) but PAC-scoped to the `FROM` hosts, and only +while the proxy runs. On a clean exit the prior setting is restored. After a hard kill (`SIGKILL`) the next `ts dev proxy` run detects and restores the leftover state, or prints the manual `networksetup` command. From e6989b5d0d2c80ad9f72a9c640de5750c30e0fd7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:04:15 -0700 Subject: [PATCH 24/40] Note Safari opens the FROM URL in the plan --- docs/superpowers/plans/2026-06-22-ts-dev-proxy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index 20626173f..b6edff93b 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -1615,7 +1615,7 @@ pub fn launch(browsers: &[Browser], cfg: &ResolvedConfig) -> error_stack::Result } ``` -Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping → `networksetup -setautoproxyurl http://127.0.0.1:/proxy.pac`; persist prior state to a file and restore on exit + on next run). Each helper logs manual steps on failure and continues. +Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping → `networksetup -setautoproxyurl http://127.0.0.1:/proxy.pac`; then open the first rule's `FROM` URL in Safari via `open -a Safari`; persist prior state to a file and restore on exit + on next run). Each helper logs manual steps on failure and continues. - [ ] **Step 4: Run the PAC test to verify it passes** From 762c1a2063bb633c40a6323020e55d6006f073ed Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:23:55 -0700 Subject: [PATCH 25/40] Fix Safari network-service detection and elevate networksetup with sudo --- .../src/commands/dev/proxy/browser.rs | 136 +++++++++++++----- docs/guide/ts-dev-proxy.md | 14 +- .../plans/2026-06-22-ts-dev-proxy.md | 2 +- .../specs/2026-06-22-ts-dev-proxy-design.md | 18 ++- 4 files changed, 129 insertions(+), 41 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 8ea87c3e0..61e8ee63c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -157,9 +157,6 @@ pub fn restore_system_proxy_if_pending(ca_dir: &Path) { Some(&prior_pac) }, ); - output::info(&format!( - "Safari: restored prior auto-proxy setting for '{service}'" - )); } #[cfg(not(target_os = "macos"))] @@ -372,8 +369,16 @@ fn launch_safari(cfg: &ResolvedConfig) { )); } - let set_result = Command::new("networksetup") - .args(["-setautoproxyurl", &service, &pac_url]) + // Changing the system network proxy requires admin, so the `networksetup` + // call is elevated with `sudo` (only this command — the proxy itself keeps + // running as the current user). sudo prompts once in this terminal; the + // credential is cached so the restore on exit does not prompt again. + output::info( + "Safari: setting the system auto-proxy needs admin — sudo will prompt for your password \ + (only `networksetup` is elevated; the proxy keeps running as you).", + ); + let set_result = Command::new("sudo") + .args(["networksetup", "-setautoproxyurl", &service, &pac_url]) .status(); match set_result { @@ -391,8 +396,10 @@ fn launch_safari(cfg: &ResolvedConfig) { } _ => { output::warn(&format!( - "Safari: could not set PAC URL automatically; \ - set it manually in System Settings → Network → {service}: {pac_url}" + "Safari: could not set the system PAC (sudo declined or no terminal). Set it \ + manually in System Settings → Network → {service} → Details → Proxies → \ + Automatic Proxy Configuration: {pac_url} \ + (or run: sudo networksetup -setautoproxyurl \"{service}\" {pac_url})" )); // Remove the restore file — nothing was applied, nothing to restore. let _ = std::fs::remove_file(&restore_path); @@ -427,23 +434,43 @@ fn detect_network_service() -> Option { .output() .ok()?; let ns_text = String::from_utf8_lossy(&ns_out.stdout); + service_for_interface(&ns_text, &interface) + } +} - let mut last_service: Option = None; - for line in ns_text.lines() { - let trimmed = line.trim(); - if trimmed.starts_with('(') && !trimmed.starts_with("(*) An asterisk") { - // Service name lines look like: "(1) Wi-Fi" - last_service = trimmed - .split_once(')') - .map(|x| x.1) - .map(str::trim) - .map(str::to_string); - } else if trimmed.contains(&interface) && last_service.is_some() { - return last_service; - } +/// Maps a default-route interface (e.g. `en0`) to its macOS network-service name +/// (e.g. `Wi-Fi`) given `networksetup -listnetworkserviceorder` output, whose +/// entries look like: +/// +/// ```text +/// (7) Wi-Fi +/// (Hardware Port: Wi-Fi, Device: en0) +/// ``` +/// +/// Both lines start with `(`, so the service line (`(N) Name`) is distinguished +/// from the hardware-port line by the digit after `(`; the device is matched on +/// the exact `Device: ` marker. +#[cfg(target_os = "macos")] +fn service_for_interface(ns_output: &str, interface: &str) -> Option { + let device_marker = format!("Device: {interface}"); + let mut last_service: Option = None; + for line in ns_output.lines() { + let trimmed = line.trim(); + if trimmed.contains(&device_marker) { + return last_service; + } + // Service-name line "(N) Name": a '(' immediately followed by a digit + // (the "(Hardware Port: …)" line starts with '(' + 'H', so it is skipped). + if let Some(rest) = trimmed.strip_prefix('(') + && rest.starts_with(|c: char| c.is_ascii_digit()) + { + last_service = trimmed + .split_once(')') + .map(|(_, name)| name.trim().to_string()) + .filter(|s| !s.is_empty()); } - None } + None } /// Returns the current auto-proxy URL for a network service, if set. @@ -465,18 +492,36 @@ fn get_auto_proxy_url(service: &str) -> Option { } /// Restores the prior PAC URL (or disables auto-proxy if there was none). +/// Restores `service`'s prior auto-proxy state. +/// +/// Uses `sudo -n` (non-interactive) so it never blocks on a password prompt: on +/// a clean Ctrl-C exit the sudo credential is still cached from launch, so the +/// restore runs silently; when a fresh run recovers a hard-killed session there +/// is no cached credential, so it prints the manual command instead of stalling +/// an unrelated startup on a password prompt. fn restore_auto_proxy(service: &str, prior_pac: Option<&str>) { - match prior_pac { - Some(url) => { - let _ = Command::new("networksetup") - .args(["-setautoproxyurl", service, url]) - .status(); - } - None => { - let _ = Command::new("networksetup") - .args(["-setautoproxystate", service, "off"]) - .status(); - } + let status = match prior_pac { + Some(url) => Command::new("sudo") + .args(["-n", "networksetup", "-setautoproxyurl", service, url]) + .status(), + None => Command::new("sudo") + .args(["-n", "networksetup", "-setautoproxystate", service, "off"]) + .status(), + }; + + if matches!(status, Ok(s) if s.success()) { + output::info(&format!( + "Safari: restored prior auto-proxy setting for '{service}'" + )); + } else { + let manual = match prior_pac { + Some(url) => format!("sudo networksetup -setautoproxyurl \"{service}\" {url}"), + None => format!("sudo networksetup -setautoproxystate \"{service}\" off"), + }; + output::warn(&format!( + "Safari: could not auto-restore the system proxy for '{service}' (needs admin). \ + Run: {manual}" + )); } } @@ -485,6 +530,33 @@ mod tests { use super::*; use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; + #[cfg(target_os = "macos")] + #[test] + fn service_for_interface_maps_device_to_service() { + // Real shape of `networksetup -listnetworkserviceorder` output. + let ns = "An asterisk (*) denotes that a network service is disabled.\n\ + (1) Display Ethernet\n\ + (Hardware Port: Display Ethernet, Device: en11)\n\ + \n\ + (7) Wi-Fi\n\ + (Hardware Port: Wi-Fi, Device: en0)\n"; + assert_eq!( + service_for_interface(ns, "en0").as_deref(), + Some("Wi-Fi"), + "en0 should map to its preceding service name, not the hardware-port line" + ); + assert_eq!( + service_for_interface(ns, "en11").as_deref(), + Some("Display Ethernet"), + "en11 should map to Display Ethernet" + ); + assert_eq!( + service_for_interface(ns, "en99"), + None, + "an unknown interface yields no service" + ); + } + #[test] fn pac_proxies_only_https_for_from_hosts() { let rules = RuleTable(vec![Rule { diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index b59414738..1434f0b00 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -234,9 +234,17 @@ The tool is flags-only; there are no environment variable overrides. Unlike Chrome/Firefox (which run in a throwaway profile), Safari uses your **system** proxy settings, so `--launch safari` sets the macOS auto-proxy on the -active network service (e.g. Wi-Fi) and then opens Safari at the `FROM` URL. The -change is system-wide (all apps) but PAC-scoped to the `FROM` hosts, and only -while the proxy runs. On a clean exit the prior setting is restored. After a hard kill (`SIGKILL`) +active network service (e.g. Wi-Fi) and then opens Safari at the `FROM` URL. + +Changing the system network proxy requires admin, so `--launch safari` runs the +`networksetup` command under `sudo` — it prompts **once** for your password in +the terminal (only that command is elevated; the proxy keeps running as you). If +`sudo` is declined or there is no terminal (e.g. the proxy is backgrounded), it +prints the exact `networksetup` command and the System Settings path so you can +set the PAC manually. The change is system-wide (all apps) but PAC-scoped to the +`FROM` hosts, and only while the proxy runs. On a clean exit the prior setting is +restored (the restore reuses the cached `sudo` credential, so it does not +re-prompt). After a hard kill (`SIGKILL`) the next `ts dev proxy` run detects and restores the leftover state, or prints the manual `networksetup` command. diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index b6edff93b..d329dc57e 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -1615,7 +1615,7 @@ pub fn launch(browsers: &[Browser], cfg: &ResolvedConfig) -> error_stack::Result } ``` -Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping → `networksetup -setautoproxyurl http://127.0.0.1:/proxy.pac`; then open the first rule's `FROM` URL in Safari via `open -a Safari`; persist prior state to a file and restore on exit + on next run). Each helper logs manual steps on failure and continues. +Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping — distinguishing the `(N) Name` service line from the `(Hardware Port: …, Device: enX)` line, which both start with `(` — → set the PAC with **`sudo networksetup -setautoproxyurl`** (admin required; one interactive prompt) → then open the first rule's `FROM` URL in Safari via `open -a Safari`; persist prior state to a file and restore on exit + on next run via **`sudo -n networksetup`** (cached credential, no re-prompt). Each helper logs manual steps on failure and continues. - [ ] **Step 4: Run the PAC test to verify it passes** diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 736c5a787..60ead60de 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -444,12 +444,20 @@ service. Chrome/`--ignore-certificate-errors` is not used (we rely on the truste CA); a developer who prefers not to trust the CA can launch Chrome manually with that flag. +Setting the system proxy **requires admin**, so the `networksetup -setautoproxyurl` +call is run under `sudo` (interactive — it prompts once in the terminal; only +that command is elevated, the proxy keeps running as the user). If `sudo` is +declined or there is no TTY, the proxy prints the exact `networksetup` command +and the System Settings path for a manual one-time setup. + Because `networksetup` changes are **system-wide** (every app, not just Safari), -the proxy persists the prior auto-proxy state to a file and restores it via an -exit hook plus signal handlers. A hard kill (`SIGKILL`) skips cleanup, so on the -next run `ts dev proxy` re-reads that file and restores it (or prints the manual -`networksetup` command). On multi-service machines it must target the correct -service, and managed networks may require admin rights. +the proxy persists the prior auto-proxy state to a file and restores it on exit +(Ctrl-C) and on the next run after a hard kill (`SIGKILL`). The restore runs +`sudo -n networksetup` (non-interactive): on a clean exit the credential is still +cached from launch so it restores silently; a fresh-run recovery has no cached +credential, so it prints the manual `networksetup` command rather than prompting +an unrelated startup. On multi-service machines it must target the correct +service (mapped from the default-route interface above). If any browser can't be auto-configured, print its manual steps and continue with the others. From 7b83b90f99de0aaae9d610c14cc0ff7160bc4f59 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:52:01 -0700 Subject: [PATCH 26/40] Address dev-proxy review: Safari restore, PAC route, per-request Host, header hygiene --- .../src/commands/dev/proxy/browser.rs | 263 +++++++++++++----- .../src/commands/dev/proxy/mod.rs | 18 +- .../src/commands/dev/proxy/server.rs | 103 ++++++- 3 files changed, 303 insertions(+), 81 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 61e8ee63c..1f26f4ac5 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -14,8 +14,11 @@ use crate::output; /// Name of the file persisted under `ca_dir` that records the Safari proxy /// state that was active before this tool set its own PAC URL. /// -/// Format: two lines, `\n` — or `\n` (empty -/// second line) when auto-proxy was previously off. +/// Format: three lines, `\n\n`, where +/// `` is empty when auto-proxy had no URL and `` +/// is `on` or `off`. A missing third line is tolerated when reading (treated as +/// `on` if a URL is present, else `off`) for forward-compatibility with the +/// earlier two-line format. const SAFARI_RESTORE_FILE: &str = "safari-proxy-restore"; /// Generates a PAC script that proxies only `https://` requests for matched FROM hosts. @@ -107,12 +110,17 @@ pub fn launch( /// Restores the macOS Safari system auto-proxy to its state before the last /// `launch_safari` call, if a pending restore file exists under `ca_dir`. /// -/// Called both at startup (to recover from a previously hard-killed run) and -/// on clean exit (Ctrl-C). On non-macOS systems or when no restore file is -/// present, this is a no-op. Deletes the restore file even when the -/// `networksetup` command fails, preventing an infinite restore loop. Never -/// panics. -pub fn restore_system_proxy_if_pending(ca_dir: &Path) { +/// Called both at startup (to recover from a previously hard-killed run, with +/// `interactive = false` so it never blocks an unrelated launch on a password +/// prompt) and on clean Ctrl-C exit (with `interactive = true` so the restore +/// can prompt for the password the cached sudo credential may have outlived). +/// +/// On non-macOS systems or when no restore file is present, this is a no-op. +/// The restore file is **kept** when the restore commands fail so a later run +/// (or the manual command printed via [`crate::output::warn`]) can still fix the +/// system proxy; it is deleted only after a successful restore or when the file +/// is malformed (so a bad file cannot loop forever). Never panics. +pub fn restore_system_proxy_if_pending(ca_dir: &Path, interactive: bool) { #[cfg(target_os = "macos")] { let restore_path = ca_dir.join(SAFARI_RESTORE_FILE); @@ -127,42 +135,52 @@ pub fn restore_system_proxy_if_pending(ca_dir: &Path) { "Safari: could not read proxy restore file: {err}; \ restore the auto-proxy URL in System Settings → Network manually" )); - // Remove the file so we don't retry forever. + // Unreadable file: remove it so we don't retry forever. let _ = std::fs::remove_file(&restore_path); return; } }; - let mut lines = contents.splitn(2, '\n'); + let mut lines = contents.lines(); let service = lines.next().unwrap_or("").trim().to_string(); - let prior_pac = lines.next().unwrap_or("").trim().to_string(); + let prior_url = lines.next().unwrap_or("").trim().to_string(); + // Tolerate a missing third line (older two-line format): a saved URL + // implies it was enabled; no URL implies auto-proxy was off. + let prior_enabled = match lines.next() { + Some(line) => line.trim().eq_ignore_ascii_case("on"), + None => !prior_url.is_empty(), + }; if service.is_empty() { output::warn( "Safari: proxy restore file has no service name; \ restore the auto-proxy URL in System Settings → Network manually", ); + // Malformed file: remove it so a bad file cannot loop forever. let _ = std::fs::remove_file(&restore_path); return; } - // Remove the file first so a hard kill during restore doesn't loop. - let _ = std::fs::remove_file(&restore_path); - - restore_auto_proxy( - &service, - if prior_pac.is_empty() { - None - } else { - Some(&prior_pac) - }, - ); + let prior_url = (!prior_url.is_empty()).then_some(prior_url.as_str()); + if restore_auto_proxy(&service, prior_url, prior_enabled, interactive) { + // Only drop the file once the system proxy is actually restored. + let _ = std::fs::remove_file(&restore_path); + } else { + // Keep the file; print the exact manual recovery steps. + let manual = manual_restore_command(&service, prior_url, prior_enabled); + output::warn(&format!( + "Safari: could not auto-restore the system proxy for '{service}' (needs admin). \ + Run: {manual} \ + (or in System Settings → Network → {service} → Details → Proxies → \ + Automatic Proxy Configuration)" + )); + } } #[cfg(not(target_os = "macos"))] { // Non-macOS: nothing to restore (Safari/networksetup don't exist). - let _ = ca_dir; + let _ = (ca_dir, interactive); } } @@ -276,10 +294,14 @@ fn launch_firefox(cfg: &ResolvedConfig) { return; } - // Import CA into NSS DB if certutil is available. + // Import CA into the profile's NSS DB via certutil. If certutil is missing + // or fails, Firefox would launch with no CA trust, so warn with the exact + // manual command instead of silently continuing. let cert_path = super::ca::CertAuthority::cert_path(&cfg.ca_dir); if cert_path.exists() { - let _ = Command::new("certutil") + let cert = cert_path.to_string_lossy(); + let profile = tmpdir.to_string_lossy(); + let certutil = Command::new("certutil") .args([ "-A", "-n", @@ -287,11 +309,18 @@ fn launch_firefox(cfg: &ResolvedConfig) { "-t", "CT,,", "-i", - &cert_path.to_string_lossy(), + &cert, "-d", - &tmpdir.to_string_lossy(), + &profile, ]) .status(); + if !matches!(certutil, Ok(ref s) if s.success()) { + output::warn(&format!( + "Firefox: could not import the dev CA into the profile (certutil missing or \ + failed); HTTPS to proxied hosts will fail until you trust it. Run: \ + certutil -A -n \"{CA_COMMON_NAME}\" -t \"CT,,\" -i {cert} -d {profile}" + )); + } } let mut cmd = firefox_command(); @@ -353,14 +382,15 @@ fn launch_safari(cfg: &ResolvedConfig) { return; }; - // Read prior state before changing anything. - let prior_pac = get_auto_proxy_url(&service); + // Read prior state (URL + enabled flag) before changing anything. + let (prior_url, prior_enabled) = get_auto_proxy_state(&service); // Persist the prior state so it can be recovered even after a hard kill. let restore_path = cfg.ca_dir.join(SAFARI_RESTORE_FILE); let restore_contents = format!( - "{service}\n{prior}\n", - prior = prior_pac.as_deref().unwrap_or("") + "{service}\n{url}\n{enabled}\n", + url = prior_url.as_deref().unwrap_or(""), + enabled = if prior_enabled { "on" } else { "off" }, ); if let Err(err) = std::fs::write(&restore_path, &restore_contents) { output::warn(&format!( @@ -371,8 +401,9 @@ fn launch_safari(cfg: &ResolvedConfig) { // Changing the system network proxy requires admin, so the `networksetup` // call is elevated with `sudo` (only this command — the proxy itself keeps - // running as the current user). sudo prompts once in this terminal; the - // credential is cached so the restore on exit does not prompt again. + // running as the current user). sudo prompts once in this terminal; if the + // cached credential outlives a long run, the Ctrl-C restore may prompt again + // (and otherwise prints the manual command). output::info( "Safari: setting the system auto-proxy needs admin — sudo will prompt for your password \ (only `networksetup` is elevated; the proxy keeps running as you).", @@ -473,56 +504,110 @@ fn service_for_interface(ns_output: &str, interface: &str) -> Option { None } -/// Returns the current auto-proxy URL for a network service, if set. -fn get_auto_proxy_url(service: &str) -> Option { - let out = Command::new("networksetup") - .args(["-getautoproxyurl", service]) - .output() - .ok()?; - let text = String::from_utf8_lossy(&out.stdout); +/// Parses `networksetup -getautoproxyurl` output into `(url, enabled)`. +/// +/// The command prints two lines, e.g.: +/// +/// ```text +/// URL: http://127.0.0.1:18080/proxy.pac +/// Enabled: Yes +/// ``` +/// +/// A `URL:` of `(null)` (or empty) yields `None`; `Enabled:` is `true` only for +/// a `Yes` (case-insensitive). Pure and unit-testable. +fn parse_auto_proxy_state(text: &str) -> (Option, bool) { + let mut url = None; + let mut enabled = false; for line in text.lines() { - if let Some(url) = line.strip_prefix("URL: ") { - let url = url.trim(); - if !url.is_empty() && url != "(null)" { - return Some(url.to_string()); + if let Some(value) = line.strip_prefix("URL:") { + let value = value.trim(); + if !value.is_empty() && value != "(null)" { + url = Some(value.to_string()); } + } else if let Some(value) = line.strip_prefix("Enabled:") { + enabled = value.trim().eq_ignore_ascii_case("Yes"); } } - None + (url, enabled) } -/// Restores the prior PAC URL (or disables auto-proxy if there was none). -/// Restores `service`'s prior auto-proxy state. +/// Returns the current auto-proxy `(url, enabled)` state for a network service. /// -/// Uses `sudo -n` (non-interactive) so it never blocks on a password prompt: on -/// a clean Ctrl-C exit the sudo credential is still cached from launch, so the -/// restore runs silently; when a fresh run recovers a hard-killed session there -/// is no cached credential, so it prints the manual command instead of stalling -/// an unrelated startup on a password prompt. -fn restore_auto_proxy(service: &str, prior_pac: Option<&str>) { - let status = match prior_pac { - Some(url) => Command::new("sudo") - .args(["-n", "networksetup", "-setautoproxyurl", service, url]) - .status(), - None => Command::new("sudo") - .args(["-n", "networksetup", "-setautoproxystate", service, "off"]) - .status(), +/// A failure to run `networksetup` is reported as `(None, false)`. +fn get_auto_proxy_state(service: &str) -> (Option, bool) { + let Ok(out) = Command::new("networksetup") + .args(["-getautoproxyurl", service]) + .output() + else { + return (None, false); }; + parse_auto_proxy_state(&String::from_utf8_lossy(&out.stdout)) +} - if matches!(status, Ok(s) if s.success()) { +/// Builds the manual `networksetup` command line that recovers `service`'s prior +/// auto-proxy state, for printing when the automatic restore fails. +#[cfg(target_os = "macos")] +fn manual_restore_command(service: &str, prior_url: Option<&str>, prior_enabled: bool) -> String { + match prior_url { + Some(url) if prior_enabled => { + format!("sudo networksetup -setautoproxyurl \"{service}\" {url}") + } + Some(url) => format!( + "sudo networksetup -setautoproxyurl \"{service}\" {url} && \ + sudo networksetup -setautoproxystate \"{service}\" off" + ), + None => format!("sudo networksetup -setautoproxystate \"{service}\" off"), + } +} + +/// Restores `service`'s prior auto-proxy state, preserving the enabled/disabled +/// flag, and returns whether every invoked command succeeded. +/// +/// `-setautoproxyurl` re-enables auto-proxy, so when the prior state had a URL +/// that was **disabled** a follow-up `-setautoproxystate off` is issued; when +/// there was no prior URL the state is simply turned off. When `interactive` is +/// true the `networksetup` calls run under `sudo` (which may prompt for a +/// password — used on clean Ctrl-C exit, where the cached credential may have +/// expired); when false they run under `sudo -n` (never prompts — used during +/// an unrelated startup recovery so it cannot stall on a password prompt). +fn restore_auto_proxy( + service: &str, + prior_url: Option<&str>, + prior_enabled: bool, + interactive: bool, +) -> bool { + // Run `networksetup ` under sudo, honoring the interactive flag. + let run = |args: &[&str]| -> bool { + let mut cmd = Command::new("sudo"); + if !interactive { + cmd.arg("-n"); + } + cmd.arg("networksetup"); + cmd.args(args); + matches!(cmd.status(), Ok(s) if s.success()) + }; + + let ok = match prior_url { + Some(url) => { + // Re-apply the URL (this re-enables auto-proxy)... + let set = run(&["-setautoproxyurl", service, url]); + // ...then disable it again if it was previously off. + if prior_enabled { + set + } else { + let off = run(&["-setautoproxystate", service, "off"]); + set && off + } + } + None => run(&["-setautoproxystate", service, "off"]), + }; + + if ok { output::info(&format!( "Safari: restored prior auto-proxy setting for '{service}'" )); - } else { - let manual = match prior_pac { - Some(url) => format!("sudo networksetup -setautoproxyurl \"{service}\" {url}"), - None => format!("sudo networksetup -setautoproxystate \"{service}\" off"), - }; - output::warn(&format!( - "Safari: could not auto-restore the system proxy for '{service}' (needs admin). \ - Run: {manual}" - )); } + ok } #[cfg(test)] @@ -588,7 +673,7 @@ mod tests { fn restore_system_proxy_if_pending_is_noop_when_no_file() { let dir = tempfile::tempdir().expect("should create temp dir"); // No file present — should not panic or error. - restore_system_proxy_if_pending(dir.path()); + restore_system_proxy_if_pending(dir.path(), false); } #[test] @@ -596,13 +681,45 @@ mod tests { let dir = tempfile::tempdir().expect("should create temp dir"); let restore_path = dir.path().join(SAFARI_RESTORE_FILE); // Write a malformed restore file (no service name). - std::fs::write(&restore_path, "\nhttp://127.0.0.1:18080/proxy.pac\n") + std::fs::write(&restore_path, "\nhttp://127.0.0.1:18080/proxy.pac\noff\n") .expect("should write restore file"); - restore_system_proxy_if_pending(dir.path()); + restore_system_proxy_if_pending(dir.path(), false); // File should be removed even when service name is missing. assert!( !restore_path.exists(), "restore file should be removed after failed parse" ); } + + #[test] + fn parse_auto_proxy_state_reads_url_and_enabled() { + let (url, enabled) = + parse_auto_proxy_state("URL: http://127.0.0.1:18080/proxy.pac\nEnabled: Yes\n"); + assert_eq!( + url.as_deref(), + Some("http://127.0.0.1:18080/proxy.pac"), + "should read the URL line" + ); + assert!(enabled, "Enabled: Yes parses as enabled"); + } + + #[test] + fn parse_auto_proxy_state_handles_disabled_with_url() { + // A saved-but-disabled PAC URL: URL present, Enabled No. + let (url, enabled) = + parse_auto_proxy_state("URL: http://example.com/old.pac\nEnabled: No\n"); + assert_eq!( + url.as_deref(), + Some("http://example.com/old.pac"), + "URL is read even when disabled" + ); + assert!(!enabled, "Enabled: No parses as disabled"); + } + + #[test] + fn parse_auto_proxy_state_treats_null_url_as_none() { + let (url, enabled) = parse_auto_proxy_state("URL: (null)\nEnabled: No\n"); + assert_eq!(url, None, "(null) URL parses as no URL"); + assert!(!enabled, "disabled with no URL"); + } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index e9f42c9cf..5ae7e0db8 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -147,10 +147,13 @@ pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report core::result::Result<(), error_stack::Report = Arc::from(browser::generate_pac(&cfg.rules, cfg.listen).as_str()); + // `--insecure` disables all upstream TLS verification — make it loud. + if cfg.insecure { + output::warn("--insecure: upstream TLS verification is DISABLED for all upstreams"); + } + let runtime = tokio::runtime::Runtime::new().change_context(ProxyError::Server)?; runtime.block_on(async move { // Bind first: the port is open and connections queue before we launch browsers. @@ -185,7 +193,9 @@ pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report result.change_context(ProxyError::Server)?, _ = tokio::signal::ctrl_c() => { - browser::restore_system_proxy_if_pending(&cfg.ca_dir); + // Interactive: the cached sudo credential may have expired during + // a long run, so allow `sudo networksetup` to prompt for it. + browser::restore_system_proxy_if_pending(&cfg.ca_dir, true); Ok(()) } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index a6a89e9f9..bbae09d40 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -99,9 +99,12 @@ impl RequestHead { } /// Whether this is the local `GET /proxy.pac` route. + /// + /// Matches **origin-form only** (`target == "/proxy.pac"`); an absolute-form + /// `http://host/proxy.pac` is proxy traffic, not the local route, so it + /// falls through to blind-forward (spec §8.4). fn is_local_pac_route(&self) -> bool { - self.method.eq_ignore_ascii_case("GET") - && (self.target == "/proxy.pac" || self.target.ends_with("/proxy.pac")) + self.method.eq_ignore_ascii_case("GET") && self.target == "/proxy.pac" } } @@ -327,6 +330,12 @@ async fn mitm( /// Rewrites one decrypted request and forwards it to the upstream. /// +/// Each request is matched by its inbound `Host` header (falling back to the +/// CONNECT authority), so a keep-alive tunnel that carries requests for several +/// hosts routes each one by its own `Host` (spec §8.2). If a request's `Host` +/// matches no rule, the rule that matched the CONNECT authority is used as a +/// fallback, keeping an already-MITM'd tunnel usable. +/// /// This is infallible at the hyper layer — upstream errors become a `502` so /// the keep-alive tunnel survives a single bad request (spec §11). async fn forward_request( @@ -341,8 +350,14 @@ async fn forward_request( return Ok(status_response(StatusCode::NOT_IMPLEMENTED)); } - let Some(rule) = rules.first_match(connect_host) else { - // Should not happen: MITM is only entered on a match. + // Match on the inbound Host (before any rewrite), else the CONNECT + // authority; fall back to the CONNECT-authority rule when neither matches. + let match_host = request_host(&req).unwrap_or_else(|| connect_host.to_string()); + let Some(rule) = rules + .first_match(&match_host) + .or_else(|| rules.first_match(connect_host)) + else { + // Should not happen: MITM is only entered on a CONNECT-authority match. return Ok(status_response(StatusCode::BAD_GATEWAY)); }; let outcome = rewrite_for(rule); @@ -367,6 +382,23 @@ async fn forward_request( } } +/// Extracts the inbound request host from the `Host` header (origin-form +/// requests over a MITM tunnel always carry one), else the URI authority. +/// +/// Returned verbatim (including any `:port`); [`RuleTable::first_match`] strips +/// the port when matching. +fn request_host(req: &Request) -> Option { + if let Some(value) = req.headers().get(hyper::header::HOST) + && let Ok(text) = value.to_str() + { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + req.uri().host().map(str::to_string) +} + async fn proxy_to_upstream( mut req: Request, outcome: &super::rewrite::RewriteOutcome, @@ -449,6 +481,8 @@ fn rewrite_headers( { headers.insert(hyper::header::AUTHORIZATION, value); } + // Drop the hop-by-hop proxy header so it never reaches the upstream (spec §8.3). + headers.remove("proxy-connection"); } /// Builds a rustls client config: a no-verification verifier when `insecure`, @@ -532,3 +566,64 @@ mod insecure { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::dev::proxy::rewrite::RewriteOutcome; + + fn head(method: &str, target: &str) -> RequestHead { + RequestHead { + method: method.to_string(), + target: target.to_string(), + raw: Vec::new(), + } + } + + #[test] + fn local_pac_route_is_origin_form_get_only() { + assert!( + head("GET", "/proxy.pac").is_local_pac_route(), + "origin-form GET /proxy.pac is the local route" + ); + assert!( + head("get", "/proxy.pac").is_local_pac_route(), + "method match is case-insensitive" + ); + assert!( + !head("GET", "http://x.example.com/proxy.pac").is_local_pac_route(), + "absolute-form /proxy.pac is proxy traffic, not the local route" + ); + assert!( + !head("POST", "/proxy.pac").is_local_pac_route(), + "non-GET is never the local PAC route" + ); + } + + #[test] + fn rewrite_headers_strips_proxy_connection() { + let outcome = RewriteOutcome { + sni: "to.edgecompute.app".to_string(), + host_header: "www.example-publisher.com".to_string(), + orig_host: "www.example-publisher.com".to_string(), + scheme_is_tls: true, + }; + let mut headers = hyper::HeaderMap::new(); + headers.insert( + HeaderName::from_static("proxy-connection"), + HeaderValue::from_static("keep-alive"), + ); + rewrite_headers(&mut headers, &outcome, None); + assert!( + !headers.contains_key("proxy-connection"), + "Proxy-Connection is a hop-by-hop header and must be removed" + ); + assert_eq!( + headers + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()), + Some("www.example-publisher.com"), + "Host is still rewritten alongside the strip" + ); + } +} From 4033fc8f7b73ad7849eb67739d83dfd87d039782 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:04:22 -0700 Subject: [PATCH 27/40] Document Safari restore state/interactivity and explicit-rule order --- docs/guide/ts-dev-proxy.md | 12 +++--- .../plans/2026-06-22-ts-dev-proxy.md | 2 +- .../specs/2026-06-22-ts-dev-proxy-design.md | 37 +++++++++++++------ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 1434f0b00..35be4543d 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -242,11 +242,13 @@ the terminal (only that command is elevated; the proxy keeps running as you). If `sudo` is declined or there is no terminal (e.g. the proxy is backgrounded), it prints the exact `networksetup` command and the System Settings path so you can set the PAC manually. The change is system-wide (all apps) but PAC-scoped to the -`FROM` hosts, and only while the proxy runs. On a clean exit the prior setting is -restored (the restore reuses the cached `sudo` credential, so it does not -re-prompt). After a hard kill (`SIGKILL`) -the next `ts dev proxy` run detects and restores the leftover state, or prints -the manual `networksetup` command. +`FROM` hosts, and only while the proxy runs. The prior setting — including +whether auto-proxy was enabled or disabled — is saved and restored. On a clean +exit (Ctrl-C) the restore uses `sudo` and may prompt once more if a long run +outlived the cached credential. After a hard kill (`SIGKILL`) the next +`ts dev proxy` run restores the leftover state non-interactively (`sudo -n`), or, +if it can't, keeps the saved state and prints the exact manual `networksetup` +command. ## Troubleshooting diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index d329dc57e..b11c057f7 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -1615,7 +1615,7 @@ pub fn launch(browsers: &[Browser], cfg: &ResolvedConfig) -> error_stack::Result } ``` -Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping — distinguishing the `(N) Name` service line from the `(Hardware Port: …, Device: enX)` line, which both start with `(` — → set the PAC with **`sudo networksetup -setautoproxyurl`** (admin required; one interactive prompt) → then open the first rule's `FROM` URL in Safari via `open -a Safari`; persist prior state to a file and restore on exit + on next run via **`sudo -n networksetup`** (cached credential, no re-prompt). Each helper logs manual steps on failure and continues. +Implement `launch_chrome` (temp `--user-data-dir`, `--proxy-server="https=127.0.0.1:"`, `--no-first-run`, open the first rule's `FROM` URL), `launch_firefox` (temp profile, write `user.js` with `network.proxy.type=1` + `network.proxy.ssl`/`network.proxy.ssl_port` only, `certutil -A` into the profile NSS DB), and `launch_safari` (serve PAC via the server's local route; detect the active service via `route -n get default` → device → `networksetup -listnetworkserviceorder` mapping — distinguishing the `(N) Name` service line from the `(Hardware Port: …, Device: enX)` line, which both start with `(` — → set the PAC with **`sudo networksetup -setautoproxyurl`** (admin required; one interactive prompt) → then open the first rule's `FROM` URL in Safari via `open -a Safari`; persist prior state (service, URL, **and enabled/disabled**) to a file and restore it — re-disabling auto-proxy if the prior state was off — via interactive `sudo` on clean Ctrl-C exit (may re-prompt if a long run outlived the cached credential) and via `sudo -n` on a hard-kill startup recovery (keeps the file + prints the manual command if it can't). Each helper logs manual steps on failure and continues. Per-request matching uses the inbound `Host` (else the CONNECT authority); `is_local_pac_route` is origin-form `GET /proxy.pac` only; `rewrite_headers` also strips `Proxy-Connection`; `--insecure` prints a startup banner. - [ ] **Step 4: Run the PAC test to verify it passes** diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 60ead60de..0ee60de64 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -451,13 +451,22 @@ declined or there is no TTY, the proxy prints the exact `networksetup` command and the System Settings path for a manual one-time setup. Because `networksetup` changes are **system-wide** (every app, not just Safari), -the proxy persists the prior auto-proxy state to a file and restores it on exit -(Ctrl-C) and on the next run after a hard kill (`SIGKILL`). The restore runs -`sudo -n networksetup` (non-interactive): on a clean exit the credential is still -cached from launch so it restores silently; a fresh-run recovery has no cached -credential, so it prints the manual `networksetup` command rather than prompting -an unrelated startup. On multi-service machines it must target the correct -service (mapped from the default-route interface above). +the proxy persists the prior auto-proxy state — service, prior PAC URL, **and the +prior enabled/disabled state** — to a file and restores all of it. The restore +re-applies the URL and, when the prior state was _disabled_ despite a saved URL, +issues `-setautoproxystate off` so a previously-disabled PAC is not silently +re-enabled. Interactivity differs by trigger: + +- **Clean exit (Ctrl-C):** interactive `sudo` — a long-running proxy may outlive + the cached credential, so the restore is allowed to prompt once. +- **Startup recovery after a hard kill (`SIGKILL`):** `sudo -n` (non-interactive) + so it never blocks an unrelated launch on a password prompt; if it can't + restore non-interactively it **keeps** the restore file and prints the exact + manual `networksetup` command. The file is deleted only after a successful + restore (or if it is malformed). + +On multi-service machines it must target the correct service (mapped from the +default-route interface above). If any browser can't be auto-configured, print its manual steps and continue with the others. @@ -468,11 +477,15 @@ with the others. ### 10.1 Precedence -CLI flags > built-in defaults. `--map`/`-f`/`-t` rules are unioned -(first-match-wins by declared order). A rule is **passed explicitly**: either a -`--map FROM=TO`, or `-f/--from` **and** `-t/--to` together. If no complete rule -is given, the tool exits with `no rewrite rule: pass --map FROM=TO …`. There is -**no** inference from `trusted-server.toml` or any other config file. +CLI flags > built-in defaults. Rules are unioned **first-match-wins**, in this +order: all `--map FROM=TO` rules (in the order given), then the single `-f`/`-t` +shorthand rule. (`clap` does not preserve the interleaved argv order across +different options, so `--map` rules always precede the shorthand rather than +following raw command-line position; this only matters if the same `FROM` is +given by both forms.) A rule is **passed explicitly**: either a `--map FROM=TO`, +or `-f/--from` **and** `-t/--to` together. If no complete rule is given, the tool +exits with `no rewrite rule: pass --map FROM=TO …`. There is **no** inference +from `trusted-server.toml` or any other config file. ### 10.2 No config-file inference From 9485abe73e5338a9727bb638813515ac972dc51a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:48:27 -0700 Subject: [PATCH 28/40] Harden Safari recovery, CA partial state, regenerate trust, Host match, and service detection --- .../src/commands/dev/proxy/browser.rs | 45 ++++++++++++++++--- .../src/commands/dev/proxy/ca.rs | 6 +++ .../src/commands/dev/proxy/mod.rs | 5 +++ .../src/commands/dev/proxy/server.rs | 34 ++++++++------ crates/trusted-server-cli/tests/proxy_e2e.rs | 19 ++++++++ .../trusted-server-cli/tests/support/mod.rs | 29 ++++++++++++ 6 files changed, 118 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 1f26f4ac5..8ace05a10 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -373,6 +373,25 @@ fn launch_safari(cfg: &ResolvedConfig) { let port = cfg.listen.port(); let pac_url = format!("http://127.0.0.1:{port}/proxy.pac"); + // A restore file left by a previous (hard-killed) run records the user's real + // original proxy state. The startup recovery in `run()` is non-interactive, so + // it may have failed to restore. If we captured state now we would record the + // dead dev-proxy PAC as the "original" and lose the user's setting forever. + // So first try an interactive restore; only proceed once the file is gone + // (meaning the current system state really is the user's original). + let restore_path = cfg.ca_dir.join(SAFARI_RESTORE_FILE); + if restore_path.exists() { + restore_system_proxy_if_pending(&cfg.ca_dir, true); + if restore_path.exists() { + output::warn( + "Safari: a previous proxy setting is still pending restore; skipping Safari \ + auto-configuration to avoid losing it. Restore it first (see the printed \ + networksetup command).", + ); + return; + } + } + let service = detect_network_service(); let Some(service) = service else { output::warn(&format!( @@ -382,11 +401,12 @@ fn launch_safari(cfg: &ResolvedConfig) { return; }; - // Read prior state (URL + enabled flag) before changing anything. + // Read prior state (URL + enabled flag) before changing anything. The + // restore file (if any) was cleared above, so this captures the user's real + // original setting, not a stale dev-proxy PAC. let (prior_url, prior_enabled) = get_auto_proxy_state(&service); // Persist the prior state so it can be recovered even after a hard kill. - let restore_path = cfg.ca_dir.join(SAFARI_RESTORE_FILE); let restore_contents = format!( "{service}\n{url}\n{enabled}\n", url = prior_url.as_deref().unwrap_or(""), @@ -480,14 +500,17 @@ fn detect_network_service() -> Option { /// /// Both lines start with `(`, so the service line (`(N) Name`) is distinguished /// from the hardware-port line by the digit after `(`; the device is matched on -/// the exact `Device: ` marker. +/// the exact `Device:` field value (so `en1` does not match `Device: en11`). #[cfg(target_os = "macos")] fn service_for_interface(ns_output: &str, interface: &str) -> Option { - let device_marker = format!("Device: {interface}"); let mut last_service: Option = None; for line in ns_output.lines() { let trimmed = line.trim(); - if trimmed.contains(&device_marker) { + // Match the `Device:` field exactly: take the value after `Device: `, + // strip the trailing `)`, and compare — `en1` must not match `en11`. + if let Some(after) = trimmed.split_once("Device: ") + && after.1.trim_end_matches(')').trim() == interface + { return last_service; } // Service-name line "(N) Name": a '(' immediately followed by a digit @@ -618,11 +641,16 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn service_for_interface_maps_device_to_service() { - // Real shape of `networksetup -listnetworkserviceorder` output. + // Real shape of `networksetup -listnetworkserviceorder` output. `en1` and + // `en11` both appear so the test proves the `Device:` match is exact (a + // substring match would let `en1` match `Device: en11`). let ns = "An asterisk (*) denotes that a network service is disabled.\n\ (1) Display Ethernet\n\ (Hardware Port: Display Ethernet, Device: en11)\n\ \n\ + (4) Thunderbolt Bridge\n\ + (Hardware Port: Thunderbolt Bridge, Device: en1)\n\ + \n\ (7) Wi-Fi\n\ (Hardware Port: Wi-Fi, Device: en0)\n"; assert_eq!( @@ -635,6 +663,11 @@ mod tests { Some("Display Ethernet"), "en11 should map to Display Ethernet" ); + assert_eq!( + service_for_interface(ns, "en1").as_deref(), + Some("Thunderbolt Bridge"), + "en1 should map to Thunderbolt Bridge, not cross-match Device: en11" + ); assert_eq!( service_for_interface(ns, "en99"), None, diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs index 385d2b7bd..22ec52010 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -202,6 +202,12 @@ impl CertAuthority { fs::create_dir_all(ca_dir).change_context(CaError::Dir)?; fs::set_permissions(ca_dir, fs::Permissions::from_mode(0o700)) .change_context(CaError::Dir)?; + // Clear any stale pair first so `create_new` on the key always succeeds + // and the written cert/key pair is always self-consistent. A leftover + // key from a prior partial write would otherwise survive next to the new + // cert, leaving a mismatched (cert, key) that future runs would load. + fs::remove_file(cert_path).ok(); + fs::remove_file(key_path).ok(); fs::write(cert_path, cert_pem).change_context(CaError::Io)?; let mut key_file = OpenOptions::new() .write(true) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 5ae7e0db8..8b7192429 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -137,6 +137,11 @@ pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report browser::ca_uninstall(), CaCommand::Regenerate => { + // Revoke OS trust for the OLD CA first. The old and new CA share + // CA_COMMON_NAME, so `ca_uninstall` (delete-by-CN, a no-op when + // absent) removes the soon-to-be-stale cert from the keychain + // before we replace the files on disk. + browser::ca_uninstall(); std::fs::remove_file(&cert_path).ok(); std::fs::remove_file(ca_dir.join("ca-key.pem")).ok(); ca::CertAuthority::load_or_generate(&ca_dir) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index bbae09d40..539abcb35 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -330,11 +330,13 @@ async fn mitm( /// Rewrites one decrypted request and forwards it to the upstream. /// -/// Each request is matched by its inbound `Host` header (falling back to the -/// CONNECT authority), so a keep-alive tunnel that carries requests for several -/// hosts routes each one by its own `Host` (spec §8.2). If a request's `Host` -/// matches no rule, the rule that matched the CONNECT authority is used as a -/// fallback, keeping an already-MITM'd tunnel usable. +/// Each request is routed by its own inbound `Host` (before any rewrite), so a +/// keep-alive tunnel that carries requests for several hosts routes each one +/// independently (spec §8.2). A request whose `Host` matches no rule is **not** +/// rerouted through the CONNECT-authority rule — it is refused with `421` +/// (Misdirected Request), so a client cannot `CONNECT mapped.example` then send +/// `Host: other.example` to smuggle traffic through a rule it never matched. The +/// CONNECT authority is consulted only when the request carries no `Host` at all. /// /// This is infallible at the hyper layer — upstream errors become a `502` so /// the keep-alive tunnel survives a single bad request (spec §11). @@ -350,15 +352,19 @@ async fn forward_request( return Ok(status_response(StatusCode::NOT_IMPLEMENTED)); } - // Match on the inbound Host (before any rewrite), else the CONNECT - // authority; fall back to the CONNECT-authority rule when neither matches. - let match_host = request_host(&req).unwrap_or_else(|| connect_host.to_string()); - let Some(rule) = rules - .first_match(&match_host) - .or_else(|| rules.first_match(connect_host)) - else { - // Should not happen: MITM is only entered on a CONNECT-authority match. - return Ok(status_response(StatusCode::BAD_GATEWAY)); + // Route by the request's own Host when present (spec §8.2). A Host that + // matches no rule is refused (421) rather than rerouted through the CONNECT + // authority. Only a request with no Host falls back to the CONNECT authority. + let rule = match request_host(&req) { + Some(host) => match rules.first_match(&host) { + Some(rule) => rule, + None => return Ok(status_response(StatusCode::MISDIRECTED_REQUEST)), + }, + None => match rules.first_match(connect_host) { + Some(rule) => rule, + // Should not happen: MITM is only entered on a CONNECT-authority match. + None => return Ok(status_response(StatusCode::BAD_GATEWAY)), + }, }; let outcome = rewrite_for(rule); let upstream_host = rule.to.host().to_string(); diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 9157a0226..ad804e0c7 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -92,6 +92,25 @@ async fn keep_alive_serves_multiple_sequential_requests() { ); } +#[tokio::test] +async fn mismatched_host_over_mitm_tunnel_is_refused_with_421() { + // CONNECT a mapped host (so the tunnel is MITM'd), then send a request whose + // Host header matches NO rule. It must be refused with 421 (Misdirected + // Request), never rerouted through the CONNECT-authority rule — otherwise a + // client could CONNECT a mapped host and smuggle traffic for any other host + // through that rule (spec §8.2). + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let status = support::drive_request_with_host_header(cfg, ca, "unmapped.example.com").await; + + assert_eq!( + status, 421, + "a Host that matches no rule must be refused with 421, not rerouted" + ); +} + #[tokio::test] async fn unmatched_connect_off_loopback_is_refused_with_403() { // The proxy is set up with no rule matching "unmapped.example.com", and the diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index 47787a979..7e43aa732 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -413,6 +413,35 @@ pub async fn drive_sequential_requests( results } +/// CONNECTs to the mapped [`FROM_HOST`] (so the tunnel is MITM'd), then sends a +/// single `GET /` over it carrying an arbitrary `Host` header, and returns the +/// response status. Used to prove a `Host` that matches no rule is refused with +/// `421` rather than rerouted through the CONNECT-authority rule (spec §8.2). +pub async fn drive_request_with_host_header( + cfg: config::ResolvedConfig, + ca: Arc, + host_header: &str, +) -> u16 { + let proxy = spawn_proxy(cfg, ca).await; + let authority = format!("{FROM_HOST}:443"); + let tcp = proxy_connect(proxy, &authority).await; + + let connector = accept_any_connector(); + let server_name = ServerName::try_from(FROM_HOST.to_string()).expect("valid server name"); + let mut tls = connector + .connect(server_name, tcp) + .await + .expect("client TLS handshake with proxy leaf"); + + let request = + format!("GET / HTTP/1.1\r\nHost: {host_header}\r\nConnection: keep-alive\r\n\r\n"); + tls.write_all(request.as_bytes()) + .await + .expect("should send request over tunnel"); + tls.flush().await.expect("should flush request"); + read_http_response(&mut tls).await.status +} + /// Reads one HTTP/1.1 response (head + Content-Length body) and parses the echo. async fn read_http_response(stream: &mut S) -> ProxiedResponse where From 668a2bf1096d8e4fd75e10682a903a42cef14a19 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:02:51 -0700 Subject: [PATCH 29/40] Abort Safari config without restore record, confirm CA revocation, shell-quote restore URL --- .../src/commands/dev/proxy/browser.rs | 85 +++++++++++++++---- .../src/commands/dev/proxy/mod.rs | 4 +- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 8ace05a10..1535e493c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -68,20 +68,46 @@ pub fn ca_install(cert_path: &Path) { /// Removes the dev CA from the macOS login keychain (spec §7.3). /// -/// On non-macOS systems, prints a manual note. Never panics. -pub fn ca_uninstall() { +/// Returns `true` when the CA is confirmed absent afterward (removed, or never +/// installed), and `false` when a removal may have failed and old trust could +/// remain — in which case it warns loudly. There can be more than one entry with +/// the CA's common name after repeated installs, so it deletes until none are +/// found. On non-macOS systems, prints a manual note and returns `true`. Never +/// panics. +pub fn ca_uninstall() -> bool { #[cfg(target_os = "macos")] { - let status = Command::new("security") - .args(["delete-certificate", "-c", CA_COMMON_NAME]) - .status(); - match status { - Ok(s) if s.success() => output::info("CA removed from keychain"), - _ => output::info("CA was not found in keychain (already removed or never installed)"), + // Delete every keychain entry matching the CA's CN; stop when none remain. + for _ in 0..16 { + let present = Command::new("security") + .args(["find-certificate", "-c", CA_COMMON_NAME]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !present { + output::info("CA is not present in the keychain (removed or never installed)"); + return true; + } + let deleted = Command::new("security") + .args(["delete-certificate", "-c", CA_COMMON_NAME]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !deleted { + break; + } } + output::warn( + "could not fully remove the dev CA from the keychain; it may still be trusted — \ + remove it manually via Keychain Access to revoke trust", + ); + false } #[cfg(not(target_os = "macos"))] - output::info("remove the dev CA from your OS trust store manually"); + { + output::info("remove the dev CA from your OS trust store manually"); + true + } } /// Launches and configures each requested browser against the proxy (spec §9). @@ -414,9 +440,10 @@ fn launch_safari(cfg: &ResolvedConfig) { ); if let Err(err) = std::fs::write(&restore_path, &restore_contents) { output::warn(&format!( - "Safari: could not write proxy restore file: {err}; \ - PAC URL will not be automatically restored on exit" + "Safari: could not write the proxy restore file ({err}); skipping Safari \ + auto-configuration so the system proxy is not changed without a way to restore it" )); + return; } // Changing the system network proxy requires admin, so the `networksetup` @@ -570,16 +597,27 @@ fn get_auto_proxy_state(service: &str) -> (Option, bool) { /// Builds the manual `networksetup` command line that recovers `service`'s prior /// auto-proxy state, for printing when the automatic restore fails. #[cfg(target_os = "macos")] +/// Single-quotes a value for safe inclusion in a printed POSIX shell command +/// (handles spaces, `&`, and other metacharacters; embedded `'` are escaped). +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + fn manual_restore_command(service: &str, prior_url: Option<&str>, prior_enabled: bool) -> String { + let svc = shell_quote(service); match prior_url { Some(url) if prior_enabled => { - format!("sudo networksetup -setautoproxyurl \"{service}\" {url}") + format!( + "sudo networksetup -setautoproxyurl {svc} {}", + shell_quote(url) + ) } Some(url) => format!( - "sudo networksetup -setautoproxyurl \"{service}\" {url} && \ - sudo networksetup -setautoproxystate \"{service}\" off" + "sudo networksetup -setautoproxyurl {svc} {url} && \ + sudo networksetup -setautoproxystate {svc} off", + url = shell_quote(url) ), - None => format!("sudo networksetup -setautoproxystate \"{service}\" off"), + None => format!("sudo networksetup -setautoproxystate {svc} off"), } } @@ -638,6 +676,23 @@ mod tests { use super::*; use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; + #[test] + fn shell_quote_wraps_and_escapes() { + // Metacharacters (`&`, space, `?`) are neutralized by single-quoting. + assert_eq!( + shell_quote("http://h/proxy.pac?a=1&b=2"), + "'http://h/proxy.pac?a=1&b=2'", + "ampersand/query must be quoted, not left bare" + ); + assert_eq!(shell_quote("a b"), "'a b'", "spaces are quoted"); + // An embedded single quote is closed, escaped, and reopened. + assert_eq!( + shell_quote("it's"), + r"'it'\''s'", + "embedded quote is escaped" + ); + } + #[cfg(target_os = "macos")] #[test] fn service_for_interface_maps_device_to_service() { diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 8b7192429..6a0eff923 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -135,7 +135,9 @@ pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report browser::ca_uninstall(), + CaCommand::Uninstall => { + browser::ca_uninstall(); + } CaCommand::Regenerate => { // Revoke OS trust for the OLD CA first. The old and new CA share // CA_COMMON_NAME, so `ca_uninstall` (delete-by-CN, a no-op when From 0133242ce2e5a3c20790d9590d98bce88cbdb5d2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:26:56 -0700 Subject: [PATCH 30/40] Add native CI job for the dev-proxy CLI crate and document regenerate trust revocation --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++++++ docs/guide/ts-dev-proxy.md | 6 ++++++ 2 files changed, 40 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4133b574..472d655d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,6 +57,40 @@ jobs: TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + test-cli: + # The native `trusted-server-cli` (`ts dev proxy`) crate is excluded from the + # workspace (the workspace default target is wasm32-wasip1), so the workspace + # fmt/clippy/test jobs do not cover it. It is a macOS tool (Safari/keychain/ + # networksetup), so validate it on macOS with an explicit native --target + # (the repo's .cargo/config.toml otherwise forces wasm32-wasip1). + name: cargo test (dev-proxy CLI, native) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: "clippy, rustfmt" + cache-shared-key: cargo-cli-${{ runner.os }} + + - name: cargo fmt (check) + run: cargo fmt --manifest-path crates/trusted-server-cli/Cargo.toml --check + + - name: cargo clippy + run: | + cargo clippy --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --all-targets -- -D warnings + + - name: cargo test + run: | + cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 35be4543d..c0b5c541d 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -159,6 +159,12 @@ ts dev proxy ca regenerate # generate a new CA (invalidates prior trust) `ca path` and `ca install` generate the CA if it does not exist yet, so they work on a freshly cloned machine before the proxy has been run. +`ca regenerate` first removes the previously-installed CA from the macOS keychain +(revoking its trust) before generating fresh key material, so an exfiltrated old +key is no longer accepted. If the keychain removal can't be confirmed it warns +loudly — verify in Keychain Access and remove the old CA manually if needed. Run +`ca install` afterward to trust the new CA. + ## Host header behavior By default the proxy sends `Host: ` (the production hostname) to the From 47905d975f715a8ebb8078771a481c2b39262c4f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:42:11 -0700 Subject: [PATCH 31/40] Address dev-proxy review round 4: macOS-only gate, regenerate abort, FROM validation - Abort `ca regenerate` when the old CA's keychain revocation can't be confirmed, so on-disk key material never outlives its OS trust. - Declare the CLI macOS-only via `compile_error!` on non-macOS targets (keychain, Safari, and networksetup are all macOS-specific), and gate the macOS-only helpers (`manual_restore_command`, `restore_auto_proxy`) while ungating `shell_quote` so the shared launch path compiles cleanly. - Shell-quote the keychain, cert, and profile paths in the `ca install` and Firefox `certutil` fallback instructions. - Validate rule FROM as a bare hostname before embedding it in the generated PAC, browser URL, and upstream Host header. - Ignore the workspace-excluded crate's `target/` directory. Spec, plan, and guide updated to match. --- .gitignore | 1 + .../src/commands/dev/proxy/browser.rs | 16 ++++--- .../src/commands/dev/proxy/config.rs | 32 ++++++++++++- .../src/commands/dev/proxy/mod.rs | 25 +++++++++- docs/guide/ts-dev-proxy.md | 7 +-- .../plans/2026-06-22-ts-dev-proxy.md | 47 +++++++++++++++++-- .../specs/2026-06-22-ts-dev-proxy-design.md | 9 +++- 7 files changed, 120 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 25e2fa11f..32de67cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /pkg /target /crates/trusted-server-integration-tests/target +/crates/trusted-server-cli/target # env .env* diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 1535e493c..fa1b38177 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -54,8 +54,9 @@ pub fn ca_install(cert_path: &Path) { output::info("CA added to the login keychain"); } _ => output::warn(&format!( - "could not auto-install; run manually: security add-trusted-cert -r trustRoot -k {keychain} {}", - cert_path.display() + "could not auto-install; run manually: security add-trusted-cert -r trustRoot -k {} {}", + shell_quote(&keychain), + shell_quote(&cert_path.display().to_string()) )), } } @@ -344,7 +345,9 @@ fn launch_firefox(cfg: &ResolvedConfig) { output::warn(&format!( "Firefox: could not import the dev CA into the profile (certutil missing or \ failed); HTTPS to proxied hosts will fail until you trust it. Run: \ - certutil -A -n \"{CA_COMMON_NAME}\" -t \"CT,,\" -i {cert} -d {profile}" + certutil -A -n \"{CA_COMMON_NAME}\" -t \"CT,,\" -i {} -d {}", + shell_quote(&cert), + shell_quote(&profile) )); } } @@ -594,15 +597,15 @@ fn get_auto_proxy_state(service: &str) -> (Option, bool) { parse_auto_proxy_state(&String::from_utf8_lossy(&out.stdout)) } -/// Builds the manual `networksetup` command line that recovers `service`'s prior -/// auto-proxy state, for printing when the automatic restore fails. -#[cfg(target_os = "macos")] /// Single-quotes a value for safe inclusion in a printed POSIX shell command /// (handles spaces, `&`, and other metacharacters; embedded `'` are escaped). fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } +/// Builds the manual `networksetup` command line that recovers `service`'s prior +/// auto-proxy state, for printing when the automatic restore fails. +#[cfg(target_os = "macos")] fn manual_restore_command(service: &str, prior_url: Option<&str>, prior_enabled: bool) -> String { let svc = shell_quote(service); match prior_url { @@ -631,6 +634,7 @@ fn manual_restore_command(service: &str, prior_url: Option<&str>, prior_enabled: /// password — used on clean Ctrl-C exit, where the cached credential may have /// expired); when false they run under `sudo -n` (never prompts — used during /// an unrelated startup recovery so it cannot stall on a password prompt). +#[cfg(target_os = "macos")] fn restore_auto_proxy( service: &str, prior_url: Option<&str>, diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 0b4345e32..dbea5cde1 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -18,6 +18,9 @@ pub enum ConfigError { /// A `--map`/authority value was malformed. #[display("invalid rule value")] Rule, + /// The FROM host contained characters not valid in a hostname. + #[display("invalid FROM host `{value}` (expected a hostname: letters, digits, '-', '.')")] + InvalidFrom { value: String }, /// `--listen` was not a valid socket address. #[display("invalid --listen address `{value}`")] Listen { value: String }, @@ -145,15 +148,30 @@ fn build_rules(args: &ProxyArgs) -> Result { Ok(RuleTable(rules)) } +/// Whether `host` is a syntactically valid hostname — ASCII letters, digits, +/// `-`, and `.` only — so it is safe to embed verbatim in the generated PAC +/// JavaScript, the browser URL, and the upstream `Host` header. +fn is_valid_host(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') +} + fn make_rule( from: &str, to: &str, preserve_host: bool, plaintext: bool, ) -> Result { + let from = from.to_ascii_lowercase(); + if !is_valid_host(&from) { + return Err(ConfigError::InvalidFrom { value: from }); + } let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; Ok(Rule { - from: from.to_ascii_lowercase(), + from, to, preserve_host, plaintext, @@ -270,6 +288,18 @@ mod tests { assert!(resolve(&args).is_err(), "malformed --map errors"); } + #[test] + fn invalid_from_host_is_rejected() { + // A FROM with characters that would break the PAC JS / Host header. + let mut args = base_args(); + args.map = vec!["bad\"host=to.edgecompute.app".into()]; + let err = resolve(&args).expect_err("a malformed FROM host should error"); + assert!( + matches!(err.current_context(), ConfigError::InvalidFrom { .. }), + "should be InvalidFrom for a hostname with invalid characters" + ); + } + #[test] fn non_loopback_listen_requires_flag() { let mut args = base_args(); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 6a0eff923..046f06977 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -4,6 +4,18 @@ pub mod config; pub mod rewrite; pub mod server; +// `ts dev proxy` is macOS-only for v1: certificate trust uses the macOS login +// keychain, Safari is configured via `networksetup`, and browser launching uses +// macOS app conventions. Other platforms are scoped as future work (design spec +// §16). Fail the build with a clear message rather than a confusing +// missing-symbol error on the platform-specific helpers. +#[cfg(not(target_os = "macos"))] +compile_error!( + "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ + networksetup). Cross-platform support is tracked as future work in the \ + design spec (§16)." +); + use std::sync::Arc; use error_stack::ResultExt as _; @@ -142,8 +154,17 @@ pub fn run(args: ProxyArgs) -> core::result::Result<(), error_stack::Report Result { fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Result { let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; - Ok(Rule { from: from.to_ascii_lowercase(), to, preserve_host, plaintext }) + let from = from.to_ascii_lowercase(); + // FROM is interpolated into the generated PAC JavaScript and matched against + // request authorities — reject anything that isn't a bare hostname so it + // cannot inject into the PAC or smuggle path/query characters. + if !is_valid_host(&from) { + return Err(ConfigError::InvalidFrom { value: from }); + } + Ok(Rule { from, to, preserve_host, plaintext }) +} + +/// Returns whether `host` is a plausible bare hostname (letters, digits, `-`, +/// `.`; non-empty; ≤253 bytes). Used to validate rule `FROM` values. +fn is_valid_host(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') } /// Resolves arguments into a [`ResolvedConfig`]. @@ -1502,7 +1531,7 @@ Implements spec §9 and §4.2/§7.3. - Produces: - `fn generate_pac(rules: &RuleTable, listen: SocketAddr) -> String`. - `fn launch(browsers: &[Browser], cfg: &ResolvedConfig) -> error_stack::Result<(), ProxyError>`. - - `fn ca_install(cert_path: &Path)`, `fn ca_uninstall()`, `fn ca_path(cert_path: &Path)`, `fn ca_regenerate(ca_dir: &Path)` — invoked from `run` for the `ca` subcommand. + - `fn ca_install(cert_path: &Path)`, `fn ca_uninstall() -> bool` (returns whether the CA's absence from the keychain was confirmed, so `regenerate` can abort on failed revocation), `fn ca_path(cert_path: &Path)`, `fn ca_regenerate(ca_dir: &Path)` — invoked from `run` for the `ca` subcommand. - [ ] **Step 1: Write the failing PAC test** @@ -1646,8 +1675,20 @@ pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { ca::CertAuthority::load_or_generate(&ca_dir).change_context(ProxyError::CertAuthority)?; browser::ca_install(&cert_path); } - CaCommand::Uninstall => browser::ca_uninstall(), + CaCommand::Uninstall => { + browser::ca_uninstall(); + } CaCommand::Regenerate => { + // Revoke OS trust for the old key BEFORE writing new key material. + // If revocation can't be confirmed, abort so the on-disk key never + // outlives its keychain trust (an exfiltrated old key stays usable). + if !browser::ca_uninstall() { + return Err(error_stack::Report::new(ProxyError::CertAuthority).attach( + "could not revoke the previously-installed CA from the keychain; \ + aborting regenerate so on-disk key material still matches OS trust. \ + Remove the old CA manually (Keychain Access), then retry.", + )); + } std::fs::remove_file(&cert_path).ok(); std::fs::remove_file(ca_dir.join("ca-key.pem")).ok(); ca::CertAuthority::load_or_generate(&ca_dir).change_context(ProxyError::CertAuthority)?; diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 0ee60de64..beeb1a3f1 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -513,7 +513,9 @@ overrides either. Every setting is a CLI flag (§4). `CONNECT` gets `403`, only configured rules are served), so the tool can never become a generic open CONNECT/HTTP proxy on the LAN. - Leaves are short-lived; the CA is never used by any deployed artifact. - - `ca regenerate` rotates the CA (forces re-trust). + - `ca regenerate` revokes the old CA from the keychain **before** writing new + key material, and **aborts** if that revocation can't be confirmed — so the + on-disk key never outlives its OS trust. Forces re-trust afterward. - `ca uninstall` removes it from the trust store. Trust is **not** auto-revoked on exit, so an OS-trusted 10-year dev CA whose key sits on disk is a standing MITM risk if that key is ever exfiltrated by user-level malware: run @@ -638,5 +640,8 @@ Steps 1–4 already deliver a usable tool; each step is independently shippable. - **WebSocket / non-HTTP upgrades** through the MITM tunnel. - **Response rewriting / fixture injection** (mock upstreams, latency). - **Multiple simultaneous upstreams per host** (A/B / weighted). -- **Windows/Linux trust + Safari automation** beyond printing instructions. +- **Windows/Linux support.** v1 is macOS-only — the crate is gated to + `target_os = "macos"` (`compile_error!` elsewhere) because CA trust, Safari + automation, and browser launching all rely on macOS tooling (login keychain, + `networksetup`). Cross-platform trust + Safari automation is future work. - **Recording/replay** of proxied traffic. From 060273037a4a303449830a56af785b5cf8decf47 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:50:53 -0700 Subject: [PATCH 32/40] Scope dev-proxy deps to macOS so wasm builds fail with the clear message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS-only `compile_error!` lived inside the proxy module while all native deps stayed unconditional, so a build for the repo-default wasm32-wasip1 target failed first in tokio/ring/aws-lc-sys instead of with the intended "macOS only" error — an easy developer footgun (a plain `cargo check` in the crate inherits the wasm default from .cargo/config.toml). - Move every dependency (and dev-dependency) under `[target.'cfg(target_os = "macos")'.dependencies]` so unsupported targets build none of the native TLS/networking stack. - Lift the platform gate to the crate root: `compile_error!` in lib.rs and `#[cfg(target_os = "macos")]` on the command modules, the CLI types, and the binary entry point. The proxy module's own `compile_error!` is removed. - Gate the e2e test crate to macOS (its deps are now macOS-scoped). Now `cargo check --target wasm32-wasip1` emits exactly one error — the macOS-only message — with no dependency build attempts. Native build/test unchanged (31 unit + 6 e2e pass). Spec and plan updated to match. --- crates/trusted-server-cli/Cargo.toml | 11 +++- .../src/commands/dev/proxy/mod.rs | 12 ---- crates/trusted-server-cli/src/lib.rs | 23 +++++++- crates/trusted-server-cli/src/main.rs | 8 +++ crates/trusted-server-cli/tests/proxy_e2e.rs | 4 ++ .../plans/2026-06-22-ts-dev-proxy.md | 59 ++++++++++++++----- .../specs/2026-06-22-ts-dev-proxy-design.md | 16 +++-- 7 files changed, 99 insertions(+), 34 deletions(-) diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 117b037b7..1e638add4 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -14,7 +14,14 @@ path = "src/lib.rs" name = "ts" path = "src/main.rs" -[dependencies] +# `ts dev proxy` (this crate's only command) is macOS-only — CA trust via the +# login keychain, Safari automation via `networksetup`, and a native TLS / +# networking stack. Scoping every dependency to macOS means unsupported targets +# (notably the repo-default `wasm32-wasip1` from `.cargo/config.toml`) surface +# the `compile_error!` in `lib.rs` as a single clear message, instead of first +# failing to build `tokio`, `ring`, or `aws-lc-sys` for a target they do not +# support. +[target.'cfg(target_os = "macos")'.dependencies] tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } hyper = { version = "1", features = ["http1", "server", "client"] } hyper-util = { version = "0.1", features = ["tokio"] } @@ -34,7 +41,7 @@ env_logger = "0.11" base64 = "0.22" directories = "5" -[dev-dependencies] +[target.'cfg(target_os = "macos")'.dev-dependencies] tempfile = "3" reqwest = { version = "0.12", features = ["blocking"] } x509-parser = "0.16" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 046f06977..ce0ceeae1 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -4,18 +4,6 @@ pub mod config; pub mod rewrite; pub mod server; -// `ts dev proxy` is macOS-only for v1: certificate trust uses the macOS login -// keychain, Safari is configured via `networksetup`, and browser launching uses -// macOS app conventions. Other platforms are scoped as future work (design spec -// §16). Fail the build with a clear message rather than a confusing -// missing-symbol error on the platform-specific helpers. -#[cfg(not(target_os = "macos"))] -compile_error!( - "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ - networksetup). Cross-platform support is tracked as future work in the \ - design spec (§16)." -); - use std::sync::Arc; use error_stack::ResultExt as _; diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index d7126ec4f..1a102753c 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -1,12 +1,31 @@ //! Trusted Server developer CLI library. The `ts` binary is a thin wrapper; //! all logic lives here so integration tests can exercise it. -pub mod commands; + +// `ts dev proxy` — the crate's sole command — is macOS-only: CA trust uses the +// login keychain, Safari is driven via `networksetup`, and browser launching +// uses macOS app conventions. The native networking/TLS dependencies are scoped +// to macOS in `Cargo.toml`, so on other targets (notably the repo-default +// `wasm32-wasip1`) this is the single, clear build error instead of a cascade of +// failed dependency builds. Cross-platform support is future work (design spec §16). +#[cfg(not(target_os = "macos"))] +compile_error!( + "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ + networksetup). Cross-platform support is tracked as future work in the \ + design spec (§16)." +); + pub mod output; +#[cfg(target_os = "macos")] +pub mod commands; + +#[cfg(target_os = "macos")] use clap::Parser; +#[cfg(target_os = "macos")] use commands::dev::DevCommand; /// The `ts` command-line interface. +#[cfg(target_os = "macos")] #[derive(Debug, Parser)] #[command(name = "ts", version, about = "Trusted Server developer CLI")] pub struct Cli { @@ -14,6 +33,7 @@ pub struct Cli { command: TopCommand, } +#[cfg(target_os = "macos")] #[derive(Debug, clap::Subcommand)] enum TopCommand { /// Local development tools. @@ -21,6 +41,7 @@ enum TopCommand { Dev(DevCommand), } +#[cfg(target_os = "macos")] impl Cli { /// Runs the parsed CLI, returning a process exit code. #[must_use] diff --git a/crates/trusted-server-cli/src/main.rs b/crates/trusted-server-cli/src/main.rs index 8bebe20b4..27df7031a 100644 --- a/crates/trusted-server-cli/src/main.rs +++ b/crates/trusted-server-cli/src/main.rs @@ -1,7 +1,15 @@ +#[cfg(target_os = "macos")] use clap::Parser as _; +#[cfg(target_os = "macos")] use trusted_server_cli::Cli; +#[cfg(target_os = "macos")] fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); std::process::exit(Cli::parse().run()); } + +// On unsupported targets the library's `compile_error!` is the real failure; +// this trivial entry point just keeps the binary target's shape valid. +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index ad804e0c7..7564f9cab 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -5,6 +5,10 @@ //! Run with: cargo test --manifest-path crates/trusted-server-cli/Cargo.toml \ //! --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e +// The proxy under test is macOS-only (see `lib.rs`); skip this entire test crate +// on other targets so it does not reference the macOS-scoped dev-dependencies. +#![cfg(target_os = "macos")] + use std::sync::Arc; use trusted_server_cli::commands::dev::proxy::{ca, config}; diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index efb00cb3d..04cb3d4e5 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -95,13 +95,21 @@ path = "src/lib.rs" name = "ts" path = "src/main.rs" -[dependencies] +# The proxy (this crate's only command) is macOS-only, so every dependency is +# scoped to macOS. On other targets — notably the repo-default `wasm32-wasip1` +# from `.cargo/config.toml` — none of this native TLS/networking stack is built, +# so the `compile_error!` in `src/lib.rs` is the single, clear failure instead of +# a cascade of failed `tokio`/`ring`/`aws-lc-sys` builds. +[target.'cfg(target_os = "macos")'.dependencies] tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } hyper = { version = "1", features = ["http1", "server", "client"] } hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" rustls = "0.23" tokio-rustls = "0.26" -rcgen = "0.13" +webpki-roots = "0.26" +rcgen = { version = "0.13", features = ["x509-parser"] } time = "0.3" rustls-pemfile = "2" clap = { version = "4", features = ["derive"] } @@ -112,9 +120,10 @@ env_logger = "0.11" base64 = "0.22" directories = "5" -[dev-dependencies] +[target.'cfg(target_os = "macos")'.dev-dependencies] tempfile = "3" reqwest = { version = "0.12", features = ["blocking"] } +x509-parser = "0.16" [lints.clippy] unwrap_used = "deny" @@ -150,13 +159,30 @@ The crate is a **library + thin bin** so that integration tests (Task 5) can imp ```rust //! Trusted Server developer CLI library. The `ts` binary is a thin wrapper; //! all logic lives here so integration tests can exercise it. -pub mod commands; + +// `ts dev proxy` — the crate's sole command — is macOS-only. The platform gate +// lives here at the crate root (not inside the proxy module) so that, combined +// with the macOS-scoped deps in Cargo.toml, unsupported targets compile nothing +// but this single clear error — no failed native dependency builds (spec §16). +#[cfg(not(target_os = "macos"))] +compile_error!( + "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ + networksetup). Cross-platform support is tracked as future work in the \ + design spec (§16)." +); + pub mod output; +#[cfg(target_os = "macos")] +pub mod commands; + +#[cfg(target_os = "macos")] use clap::Parser; +#[cfg(target_os = "macos")] use commands::dev::DevCommand; /// The `ts` command-line interface. +#[cfg(target_os = "macos")] #[derive(Debug, Parser)] #[command(name = "ts", version, about = "Trusted Server developer CLI")] pub struct Cli { @@ -164,6 +190,7 @@ pub struct Cli { command: TopCommand, } +#[cfg(target_os = "macos")] #[derive(Debug, clap::Subcommand)] enum TopCommand { /// Local development tools. @@ -171,6 +198,7 @@ enum TopCommand { Dev(DevCommand), } +#[cfg(target_os = "macos")] impl Cli { /// Runs the parsed CLI, returning a process exit code. #[must_use] @@ -190,13 +218,21 @@ impl Cli { - [ ] **Step 5: Write the thin binary `src/main.rs`** ```rust +#[cfg(target_os = "macos")] use clap::Parser as _; +#[cfg(target_os = "macos")] use trusted_server_cli::Cli; +#[cfg(target_os = "macos")] fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); std::process::exit(Cli::parse().run()); } + +// On unsupported targets the library's `compile_error!` is the real failure; +// this trivial entry point just keeps the binary target's shape valid. +#[cfg(not(target_os = "macos"))] +fn main() {} ``` - [ ] **Step 5b: Write `src/commands/mod.rs` and `src/commands/dev/mod.rs`** @@ -239,17 +275,6 @@ pub mod ca; pub mod config; pub mod rewrite; -// `ts dev proxy` is macOS-only for v1: CA trust (login keychain), Safari -// automation (`networksetup`), and browser launching all rely on macOS tooling. -// Fail the build with a clear message elsewhere rather than a confusing -// missing-symbol error on the platform-specific helpers (spec §16). -#[cfg(not(target_os = "macos"))] -compile_error!( - "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ - networksetup). Cross-platform support is tracked as future work in the \ - design spec (§16)." -); - use crate::output; /// Errors surfaced by `ts dev proxy`. @@ -1306,6 +1331,10 @@ Create `crates/trusted-server-cli/tests/proxy_e2e.rs`. It starts a local TLS "up //! Run with: cargo test --manifest-path crates/trusted-server-cli/Cargo.toml \ //! --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e +// The proxy under test is macOS-only (see `lib.rs`); skip this entire test crate +// on other targets so it does not reference the macOS-scoped dev-dependencies. +#![cfg(target_os = "macos")] + use std::sync::Arc; // Helper: spin a local HTTPS server that echoes the Host and X-Orig-Host it saw. diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index beeb1a3f1..fd0ecf1fc 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -256,6 +256,13 @@ declares its **own** `[lints.clippy]`. Mirror the workspace posture (deny user-facing output through a thin helper (with a local `#![allow(clippy::print_stdout)]` if that restriction lint is enabled). +Scope every dependency to macOS (`[target.'cfg(target_os = "macos")'.dependencies]`) +and place the platform `compile_error!` (§16) at the **crate root** (`lib.rs`), +gating the command modules behind `#[cfg(target_os = "macos")]`. Together these +ensure an accidental wasm build (the repo default) compiles nothing but the +single clear "macOS only" error — never a cascade of failed `tokio`/`ring`/ +`aws-lc-sys` builds for a target they don't support. + --- ## 7. Local Certificate Authority @@ -640,8 +647,9 @@ Steps 1–4 already deliver a usable tool; each step is independently shippable. - **WebSocket / non-HTTP upgrades** through the MITM tunnel. - **Response rewriting / fixture injection** (mock upstreams, latency). - **Multiple simultaneous upstreams per host** (A/B / weighted). -- **Windows/Linux support.** v1 is macOS-only — the crate is gated to - `target_os = "macos"` (`compile_error!` elsewhere) because CA trust, Safari - automation, and browser launching all rely on macOS tooling (login keychain, - `networksetup`). Cross-platform trust + Safari automation is future work. +- **Windows/Linux support.** v1 is macOS-only — deps are scoped to + `target_os = "macos"` and the crate root carries a `compile_error!` for other + targets (§6), because CA trust, Safari automation, and browser launching all + rely on macOS tooling (login keychain, `networksetup`). Cross-platform trust + + Safari automation is future work. - **Recording/replay** of proxied traffic. From e98f2410b54eb0476755f08dbf36f7fd3edc7b65 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:10:01 -0700 Subject: [PATCH 33/40] Support IP upstreams via an optional-valued --rewrite-host Let `--to` target a bare IP and `--rewrite-host` carry the hostname that endpoint expects. The flag now takes an optional value: - omitted -> Host = FROM (default; unchanged) - --rewrite-host -> Host = TO host (the prior bare-flag behavior) - --rewrite-host -> Host = and TLS SNI = Connection still dials `--to`, so pointing at an IP works: the proxy presents the explicit hostname for both SNI and Host while the socket goes to the IP. Replaces `Rule.preserve_host: bool` with a `HostMode { PreserveFrom, UseTo, Explicit }` enum threaded through `rewrite_for`; the explicit host is validated as a hostname (new `ConfigError::InvalidRewriteHost`). Spec, plan, and guide updated; adds tests for all three forms plus invalid-value rejection. --- .../src/commands/dev/proxy/browser.rs | 4 +- .../src/commands/dev/proxy/config.rs | 112 ++++++++++++-- .../src/commands/dev/proxy/mod.rs | 13 +- .../src/commands/dev/proxy/rewrite.rs | 90 +++++++++-- docs/guide/ts-dev-proxy.md | 45 +++++- .../plans/2026-06-22-ts-dev-proxy.md | 140 +++++++++++++----- .../specs/2026-06-22-ts-dev-proxy-design.md | 84 +++++++---- 7 files changed, 379 insertions(+), 109 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index fa1b38177..d7872c0e5 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -678,7 +678,7 @@ fn restore_auto_proxy( #[cfg(test)] mod tests { use super::*; - use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; + use crate::commands::dev::proxy::rewrite::{Authority, HostMode, Rule, RuleTable}; #[test] fn shell_quote_wraps_and_escapes() { @@ -739,7 +739,7 @@ mod tests { let rules = RuleTable(vec![Rule { from: "www.example-publisher.com".into(), to: Authority::parse("to.edgecompute.app", false).expect("should parse authority"), - preserve_host: true, + host_mode: HostMode::PreserveFrom, plaintext: false, }]); let pac = generate_pac( diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index dbea5cde1..940f315bc 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -7,7 +7,7 @@ use base64::Engine as _; use error_stack::{Report, ResultExt as _}; use super::ProxyArgs; -use super::rewrite::{Authority, Rule, RuleTable}; +use super::rewrite::{Authority, HostMode, Rule, RuleTable}; /// Errors from configuration resolution. #[derive(Debug, derive_more::Display)] @@ -21,6 +21,9 @@ pub enum ConfigError { /// The FROM host contained characters not valid in a hostname. #[display("invalid FROM host `{value}` (expected a hostname: letters, digits, '-', '.')")] InvalidFrom { value: String }, + /// The `--rewrite-host ` value was not a valid hostname. + #[display("invalid --rewrite-host `{value}` (expected a hostname: letters, digits, '-', '.')")] + InvalidRewriteHost { value: String }, /// `--listen` was not a valid socket address. #[display("invalid --listen address `{value}`")] Listen { value: String }, @@ -135,15 +138,32 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { .map_or_else(default_ca_dir, PathBuf::from) } +/// Resolves the `--rewrite-host` flag into a [`HostMode`] applied to every rule: +/// absent → preserve `FROM`; bare → use `TO`; with a value → that explicit host +/// (validated, lowercased) for both the `Host` header and the TLS SNI. +fn host_mode(args: &ProxyArgs) -> Result { + match &args.rewrite_host { + None => Ok(HostMode::PreserveFrom), + Some(None) => Ok(HostMode::UseTo), + Some(Some(host)) => { + let host = host.to_ascii_lowercase(); + if !is_valid_host(&host) { + return Err(ConfigError::InvalidRewriteHost { value: host }); + } + Ok(HostMode::Explicit(host)) + } + } +} + fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); - let preserve_host = !args.rewrite_host; + let mode = host_mode(args)?; for entry in &args.map { let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; - rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); } if let (Some(from), Some(to)) = (&args.from, &args.to) { - rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); } Ok(RuleTable(rules)) } @@ -162,7 +182,7 @@ fn is_valid_host(host: &str) -> bool { fn make_rule( from: &str, to: &str, - preserve_host: bool, + host_mode: HostMode, plaintext: bool, ) -> Result { let from = from.to_ascii_lowercase(); @@ -173,7 +193,7 @@ fn make_rule( Ok(Rule { from, to, - preserve_host, + host_mode, plaintext, }) } @@ -253,6 +273,35 @@ mod tests { W::parse_from(["ts"]).a } + fn parse_args(argv: &[&str]) -> crate::commands::dev::proxy::ProxyArgs { + use clap::Parser; + #[derive(clap::Parser)] + struct W { + #[command(flatten)] + a: crate::commands::dev::proxy::ProxyArgs, + } + W::parse_from(argv).a + } + + #[test] + fn clap_parses_the_three_rewrite_host_forms() { + assert_eq!( + parse_args(&["ts"]).rewrite_host, + None, + "absent --rewrite-host parses to None" + ); + assert_eq!(x + parse_args(&["ts", "--rewrite-host"]).rewrite_host, + Some(None), + "bare --rewrite-host parses to Some(None)" + ); + assert_eq!( + parse_args(&["ts", "--rewrite-host", "app.example.com"]).rewrite_host, + Some(Some("app.example.com".to_string())), + "--rewrite-host parses to Some(Some(host))" + ); + } + #[test] fn single_rule_from_to_defaults_to_preserve_host() { let mut args = base_args(); @@ -263,21 +312,60 @@ mod tests { .rules .first_match("www.example-publisher.com") .expect("rule present"); - assert!(rule.preserve_host, "default preserves FROM host"); + assert_eq!( + rule.host_mode, + HostMode::PreserveFrom, + "default preserves FROM host" + ); assert_eq!(rule.to.host(), "to.edgecompute.app"); } #[test] - fn rewrite_host_flag_clears_preserve_host() { + fn bare_rewrite_host_uses_to() { let mut args = base_args(); args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - args.rewrite_host = true; + // Bare `--rewrite-host` (present, no value) parses to `Some(None)`. + args.rewrite_host = Some(None); let cfg = resolve(&args).expect("should resolve"); - assert!( - !cfg.rules + assert_eq!( + cfg.rules .first_match("www.example-publisher.com") .expect("rule") - .preserve_host + .host_mode, + HostMode::UseTo, + "bare --rewrite-host sends Host: TO" + ); + } + + #[test] + fn rewrite_host_with_value_is_explicit_for_ip_to() { + let mut args = base_args(); + // TO is a bare IP; the explicit value supplies the Host header and SNI. + args.map = vec!["www.example-publisher.com=192.0.2.10".into()]; + args.rewrite_host = Some(Some("App.EdgeCompute.app".into())); + let cfg = resolve(&args).expect("should resolve"); + assert_eq!( + cfg.rules + .first_match("www.example-publisher.com") + .expect("rule") + .host_mode, + HostMode::Explicit("app.edgecompute.app".to_string()), + "explicit --rewrite-host is lowercased and stored verbatim" + ); + } + + #[test] + fn rewrite_host_with_invalid_value_is_rejected() { + let mut args = base_args(); + args.map = vec!["www.example-publisher.com=192.0.2.10".into()]; + args.rewrite_host = Some(Some("bad/host".into())); + let err = resolve(&args).expect_err("an invalid --rewrite-host should error"); + assert!( + matches!( + err.current_context(), + ConfigError::InvalidRewriteHost { .. } + ), + "should be InvalidRewriteHost for a non-hostname value" ); } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index ce0ceeae1..90ac34e2f 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -40,7 +40,9 @@ pub struct ProxyArgs { #[arg(short = 'f', long = "from", value_name = "HOST")] pub from: Option, - /// Shorthand single-rule TO (`HOST[:PORT]`; pairs with `--from`). + /// Shorthand single-rule TO (`HOST[:PORT]` or `IP[:PORT]`; pairs with + /// `--from`). When TO is a bare IP, pass `--rewrite-host ` so the TLS + /// SNI and `Host` header target the right vhost. #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] pub to: Option, @@ -56,9 +58,12 @@ pub struct ProxyArgs { #[arg(long, value_name = "LIST")] pub launch: Option, - /// Send `Host: ` upstream instead of the default ``. - #[arg(long)] - pub rewrite_host: bool, + /// Rewrite the upstream `Host` header (and TLS SNI). Omit to keep the + /// default `Host: `; bare `--rewrite-host` sends `Host: `; + /// `--rewrite-host ` sends `Host: ` and uses `` for SNI + /// (needed when `--to` is a bare IP address). + #[arg(long, value_name = "HOST", num_args = 0..=1)] + pub rewrite_host: Option>, /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). #[arg(long, value_name = "USER:PASS")] diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index 2273a90d6..4f9639e06 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -85,15 +85,29 @@ impl Authority { } } +/// How a rule derives the upstream `Host` header and TLS SNI (spec §8.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostMode { + /// Default: send `Host: FROM` (preserve the production host); SNI is the + /// `TO` host. + PreserveFrom, + /// Bare `--rewrite-host`: send `Host: TO`; SNI is the `TO` host. + UseTo, + /// `--rewrite-host `: send `Host: ` and present `` as the + /// TLS SNI, while still connecting to `TO`. Lets `TO` be a bare IP address + /// whose endpoint routes and serves certificates by hostname. + Explicit(String), +} + /// A single rewrite rule. #[derive(Debug, Clone)] pub struct Rule { /// Production hostname to match (stored lowercase, port-stripped). pub from: String, - /// Upstream target. + /// Upstream connection target (a hostname or a bare IP address). pub to: Authority, - /// When true (default), send `Host: FROM`; when false, send `Host: TO`. - pub preserve_host: bool, + /// How the upstream `Host` header and TLS SNI are derived. + pub host_mode: HostMode, /// Connect to the upstream over plaintext HTTP. pub plaintext: bool, } @@ -120,7 +134,8 @@ impl RuleTable { /// The header/SNI decisions for a matched rule. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RewriteOutcome { - /// SNI to present upstream (TO host only, no port). + /// SNI to present upstream — the `TO` host, or the explicit + /// `--rewrite-host` value; never carries a port. pub sni: String, /// Value for the upstream `Host` header. pub host_header: String, @@ -133,13 +148,15 @@ pub struct RewriteOutcome { /// Computes the rewrite outcome for a matched rule (spec §8.3). #[must_use] pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { - let host_header = if rule.preserve_host { - rule.from.clone() - } else { - rule.to.host_with_port() + // `Host` and SNI are independent of the connection target (the caller dials + // `rule.to`), so an explicit host overrides both while `TO` can stay an IP. + let (host_header, sni) = match &rule.host_mode { + HostMode::PreserveFrom => (rule.from.clone(), rule.to.host().to_string()), + HostMode::UseTo => (rule.to.host_with_port(), rule.to.host().to_string()), + HostMode::Explicit(host) => (host.clone(), host.clone()), }; RewriteOutcome { - sni: rule.to.host().to_string(), + sni, host_header, orig_host: rule.from.clone(), scheme_is_tls: !rule.plaintext, @@ -150,11 +167,11 @@ pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { mod tests { use super::*; - fn rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Rule { + fn rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Rule { Rule { from: from.to_string(), to: Authority::parse(to, plaintext).expect("should parse authority"), - preserve_host, + host_mode, plaintext, } } @@ -220,7 +237,7 @@ mod tests { let table = RuleTable(vec![rule( "www.example-publisher.com", "to.edgecompute.app", - true, + HostMode::PreserveFrom, false, )]); let m = table @@ -239,8 +256,18 @@ mod tests { #[test] fn first_match_wins() { let table = RuleTable(vec![ - rule("a.example.com", "first.edgecompute.app", true, false), - rule("a.example.com", "second.edgecompute.app", true, false), + rule( + "a.example.com", + "first.edgecompute.app", + HostMode::PreserveFrom, + false, + ), + rule( + "a.example.com", + "second.edgecompute.app", + HostMode::PreserveFrom, + false, + ), ]); assert_eq!( table @@ -257,7 +284,7 @@ mod tests { let r = rule( "www.example-publisher.com", "to.edgecompute.app:8443", - true, + HostMode::PreserveFrom, false, ); let out = rewrite_for(&r); @@ -278,7 +305,12 @@ mod tests { #[test] fn rewrite_host_uses_to_authority_with_port() { - let r = rule("www.example-publisher.com", "localhost:3000", false, true); + let r = rule( + "www.example-publisher.com", + "localhost:3000", + HostMode::UseTo, + true, + ); let out = rewrite_for(&r); assert_eq!(out.sni, "localhost", "SNI never carries a port"); assert_eq!( @@ -295,6 +327,32 @@ mod tests { ); } + #[test] + fn explicit_rewrite_host_overrides_host_and_sni_for_ip_upstream() { + // TO is a bare IP; an explicit --rewrite-host drives both the Host header + // and the SNI so the IP endpoint can route and present the right cert. + let r = rule( + "www.example-publisher.com", + "192.0.2.10", + HostMode::Explicit("app.edgecompute.app".to_string()), + false, + ); + let out = rewrite_for(&r); + assert_eq!( + out.sni, "app.edgecompute.app", + "SNI is the explicit host, not the IP" + ); + assert_eq!( + out.host_header, "app.edgecompute.app", + "Host header is the explicit host" + ); + assert_eq!( + out.orig_host, "www.example-publisher.com", + "X-Orig-Host stays FROM" + ); + assert!(out.scheme_is_tls, "TLS rule yields a TLS outcome"); + } + #[test] fn rejects_empty_or_missing_port() { let err = diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index a45fe0796..5b74b87f0 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -176,7 +176,8 @@ correctly: it anchors all HTML/URL rewriting to the inbound `Host`, so keeping This works well against a Trusted Server Compute upstream because Fastly routes by SNI (`= TO`) and passes `Host` through to the application unchanged. -If your upstream validates or routes on its own hostname, pass `--rewrite-host`: +If your upstream validates or routes on its own hostname, pass bare +`--rewrite-host`: ```bash ts dev proxy \ @@ -185,12 +186,39 @@ ts dev proxy \ --launch chrome ``` -With `--rewrite-host`, the proxy sends `Host: staging.example.net`. An -`X-Orig-Host: www.example-publisher.com` header is always sent informally. +With bare `--rewrite-host`, the proxy sends `Host: staging.example.net` (the +`TO` host). An `X-Orig-Host: www.example-publisher.com` header is always sent +informally. -**Port handling.** When `--rewrite-host` is active and `TO` carries a -non-default port (e.g. `localhost:3000`), the port is included in the `Host` -header but never in the SNI (a bare hostname; a port in SNI is invalid). +`--rewrite-host` controls both the upstream `Host` header **and** the TLS SNI: + +| Form | `Host` header | TLS SNI | +| ----------------------- | ------------- | --------- | +| _(omitted)_ | `FROM` | `TO` host | +| `--rewrite-host` | `TO` host | `TO` host | +| `--rewrite-host ` | `` | `` | + +**Targeting an IP upstream.** To reach a specific server or load balancer by +address, set `--to` to a bare IP and pass `--rewrite-host ` with the +hostname that endpoint expects. The proxy dials the IP but presents `` for +both SNI and `Host`: + +```bash +ts dev proxy \ + --from www.example-publisher.com \ + --to 192.0.2.10 \ + --rewrite-host app.edgecompute.app \ + --launch chrome +``` + +Without the explicit ``, the SNI would be the IP itself — which sends no +SNI extension at all, so a host-routed endpoint serves its default vhost. Add +`--insecure` if the IP serves a certificate that doesn't match ``. + +**Port handling.** With bare `--rewrite-host` and a non-default `TO` port (e.g. +`localhost:3000`), the port is included in the `Host` header but never in the +SNI (a bare hostname; a port in SNI is invalid). An explicit +`--rewrite-host ` is a bare hostname (no port). ## Non-loopback listen @@ -216,11 +244,12 @@ ts dev proxy [OPTIONS] [COMMAND] Options: --map Rewrite rule (repeatable) -f, --from Single-rule FROM (pairs with --to) - -t, --to Single-rule TO (pairs with --from) + -t, --to Single-rule TO (host or IP; pairs with --from) --listen Listen address [default: 127.0.0.1:18080] --allow-non-loopback Permit non-loopback --listen (disables blind tunnel) --launch Browsers to launch (chrome,firefox,safari or all) - --rewrite-host Send Host: instead of the default + --rewrite-host [] Rewrite upstream Host + SNI: bare = TO, = explicit + (omit to keep the default Host: ) --basic-auth Inject Basic auth (visible in ps — prefer --basic-auth-file) --basic-auth-file Read USER:PASS from a file --insecure Skip upstream TLS certificate verification diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index 04cb3d4e5..6842ad300 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -15,7 +15,7 @@ - No `unwrap()`/`panic!`/`println!`/`eprintln!` in non-test code: use `expect("should …")` only where truly infallible, `error-stack` `Report` for fallible paths, `log::*` for instrumentation, and a single binary-scoped output helper (`#![allow(clippy::print_stdout)]` only in that helper module) for user-facing stdout. - Errors: concrete enums with `derive_more::Display` + `impl core::error::Error`; `ensure!`/`bail!`; `change_context`/`attach`. Import `Error` from `core::error`. - Example/fictional data only in tests/docs (e.g. `www.example-publisher.com`, `*.edgecompute.app`, `example.com`). No real domains/credentials. -- Default `Host` upstream is **`FROM`** (preserve production host); `--rewrite-host` sends `Host = TO`. SNI is always `TO` **host only** (port stripped). +- Default `Host` upstream is **`FROM`** (preserve production host); bare `--rewrite-host` sends `Host = TO`; `--rewrite-host ` sends `Host = SNI = ` (so `TO` may be a bare IP). SNI is the `TO` host (or the explicit ``), **port stripped**. - Proxy binds **loopback only** unless `--allow-non-loopback`; off loopback, unmatched `CONNECT` is refused `403` (never blind-tunneled). - CA: CN `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION`; key file `0600`, dir `0700`; never committed; leaf SAN = host, validity ≤ 90 days; ALPN `http/1.1`. - Commit after every green step. Commit subjects: sentence case, imperative, no semantic prefixes, no AI bylines. @@ -307,7 +307,9 @@ pub struct ProxyArgs { #[arg(short = 'f', long = "from", value_name = "HOST")] pub from: Option, - /// Shorthand single-rule TO (`HOST[:PORT]`). + /// Shorthand single-rule TO (`HOST[:PORT]` or `IP[:PORT]`; pairs with + /// `--from`). When TO is a bare IP, pass `--rewrite-host ` so the TLS + /// SNI and `Host` header target the right vhost. #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] pub to: Option, @@ -323,9 +325,12 @@ pub struct ProxyArgs { #[arg(long, value_name = "LIST")] pub launch: Option, - /// Send `Host: ` upstream instead of the default ``. - #[arg(long)] - pub rewrite_host: bool, + /// Rewrite the upstream `Host` header (and TLS SNI). Omit to keep the + /// default `Host: `; bare `--rewrite-host` sends `Host: `; + /// `--rewrite-host ` sends `Host: ` and uses `` for SNI + /// (needed when `--to` is a bare IP address). + #[arg(long, value_name = "HOST", num_args = 0..=1)] + pub rewrite_host: Option>, /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). #[arg(long, value_name = "USER:PASS")] @@ -429,7 +434,8 @@ Pure logic, no I/O. Implements spec §8.1–§8.4. This is the most heavily unit - Produces: - `struct Authority { host: String, port: u16, default_port: u16 }` with `fn host(&self) -> &str`, `fn is_default_port(&self) -> bool` (port equals the scheme default it was parsed with), `fn host_with_port(&self) -> String` (host, plus `:port` only when non-default), `fn parse(raw: &str, plaintext: bool) -> Result`. - - `struct Rule { from: String, to: Authority, preserve_host: bool, plaintext: bool }`. + - `enum HostMode { PreserveFrom, UseTo, Explicit(String) }` — how a rule derives the upstream `Host` header and TLS SNI. + - `struct Rule { from: String, to: Authority, host_mode: HostMode, plaintext: bool }`. - `struct RuleTable(Vec)` with `fn first_match(&self, host: &str) -> Option<&Rule>`. - `struct RewriteOutcome { sni: String, host_header: String, orig_host: String, scheme_is_tls: bool }`. - `fn rewrite_for(rule: &Rule) -> RewriteOutcome`. @@ -442,11 +448,11 @@ Pure logic, no I/O. Implements spec §8.1–§8.4. This is the most heavily unit mod tests { use super::*; - fn rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Rule { + fn rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Rule { Rule { from: from.to_string(), to: Authority::parse(to, plaintext).expect("should parse authority"), - preserve_host, + host_mode, plaintext, } } @@ -490,7 +496,7 @@ mod tests { #[test] fn matching_is_case_insensitive_and_port_stripped() { - let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", true, false)]); + let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", HostMode::PreserveFrom, false)]); let m = table.first_match("WWW.Example-Publisher.COM:443").expect("should match"); assert_eq!(m.from, "www.example-publisher.com", "match ignores case and port"); assert!(table.first_match("other.example.com").is_none(), "unmatched host returns None"); @@ -499,15 +505,15 @@ mod tests { #[test] fn first_match_wins() { let table = RuleTable(vec![ - rule("a.example.com", "first.edgecompute.app", true, false), - rule("a.example.com", "second.edgecompute.app", true, false), + rule("a.example.com", "first.edgecompute.app", HostMode::PreserveFrom, false), + rule("a.example.com", "second.edgecompute.app", HostMode::PreserveFrom, false), ]); assert_eq!(table.first_match("a.example.com").expect("should match").to.host(), "first.edgecompute.app"); } #[test] fn rewrite_default_preserves_from_host_and_sets_sni_to_to() { - let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", true, false); + let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", HostMode::PreserveFrom, false); let out = rewrite_for(&r); assert_eq!(out.sni, "to.edgecompute.app", "SNI is TO host only, no port"); assert_eq!(out.host_header, "www.example-publisher.com", "default Host is FROM"); @@ -516,12 +522,22 @@ mod tests { #[test] fn rewrite_host_uses_to_authority_with_port() { - let r = rule("www.example-publisher.com", "localhost:3000", false, true); + let r = rule("www.example-publisher.com", "localhost:3000", HostMode::UseTo, true); let out = rewrite_for(&r); assert_eq!(out.sni, "localhost", "SNI never carries a port"); assert_eq!(out.host_header, "localhost:3000", "rewrite-host sends TO host:port"); assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); } + + #[test] + fn explicit_rewrite_host_overrides_host_and_sni_for_ip_upstream() { + // TO is a bare IP; an explicit --rewrite-host drives both Host and SNI. + let r = rule("www.example-publisher.com", "192.0.2.10", HostMode::Explicit("app.edgecompute.app".to_string()), false); + let out = rewrite_for(&r); + assert_eq!(out.sni, "app.edgecompute.app", "SNI is the explicit host, not the IP"); + assert_eq!(out.host_header, "app.edgecompute.app", "Host header is the explicit host"); + assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); + } } ``` @@ -608,15 +624,29 @@ impl Authority { } } +/// How a rule derives the upstream `Host` header and TLS SNI (spec §8.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostMode { + /// Default: send `Host: FROM` (preserve the production host); SNI is the + /// `TO` host. + PreserveFrom, + /// Bare `--rewrite-host`: send `Host: TO`; SNI is the `TO` host. + UseTo, + /// `--rewrite-host `: send `Host: ` and present `` as the + /// TLS SNI, while still connecting to `TO`. Lets `TO` be a bare IP address + /// whose endpoint routes and serves certificates by hostname. + Explicit(String), +} + /// A single rewrite rule. #[derive(Debug, Clone)] pub struct Rule { /// Production hostname to match (stored lowercase, port-stripped). pub from: String, - /// Upstream target. + /// Upstream connection target (a hostname or a bare IP address). pub to: Authority, - /// When true (default), send `Host: FROM`; when false, send `Host: TO`. - pub preserve_host: bool, + /// How the upstream `Host` header and TLS SNI are derived. + pub host_mode: HostMode, /// Connect to the upstream over plaintext HTTP. pub plaintext: bool, } @@ -641,7 +671,8 @@ impl RuleTable { /// The header/SNI decisions for a matched rule. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RewriteOutcome { - /// SNI to present upstream (TO host only, no port). + /// SNI to present upstream — the `TO` host, or the explicit + /// `--rewrite-host` value; never carries a port. pub sni: String, /// Value for the upstream `Host` header. pub host_header: String, @@ -654,13 +685,15 @@ pub struct RewriteOutcome { /// Computes the rewrite outcome for a matched rule (spec §8.3). #[must_use] pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { - let host_header = if rule.preserve_host { - rule.from.clone() - } else { - rule.to.host_with_port() + // `Host` and SNI are independent of the connection target (the caller dials + // `rule.to`), so an explicit host overrides both while `TO` can stay an IP. + let (host_header, sni) = match &rule.host_mode { + HostMode::PreserveFrom => (rule.from.clone(), rule.to.host().to_string()), + HostMode::UseTo => (rule.to.host_with_port(), rule.to.host().to_string()), + HostMode::Explicit(host) => (host.clone(), host.clone()), }; RewriteOutcome { - sni: rule.to.host().to_string(), + sni, host_header, orig_host: rule.from.clone(), scheme_is_tls: !rule.plaintext, @@ -729,17 +762,34 @@ mod tests { args.to = Some("to.edgecompute.app".into()); let cfg = resolve(&args).expect("should resolve"); let rule = cfg.rules.first_match("www.example-publisher.com").expect("rule present"); - assert!(rule.preserve_host, "default preserves FROM host"); + assert_eq!(rule.host_mode, HostMode::PreserveFrom, "default preserves FROM host"); assert_eq!(rule.to.host(), "to.edgecompute.app"); } #[test] - fn rewrite_host_flag_clears_preserve_host() { + fn bare_rewrite_host_uses_to() { let mut args = base_args(); args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - args.rewrite_host = true; + // Bare `--rewrite-host` (present, no value) parses to `Some(None)`. + args.rewrite_host = Some(None); let cfg = resolve(&args).expect("should resolve"); - assert!(!cfg.rules.first_match("www.example-publisher.com").expect("rule").preserve_host); + assert_eq!( + cfg.rules.first_match("www.example-publisher.com").expect("rule").host_mode, + HostMode::UseTo, + "bare --rewrite-host sends Host: TO", + ); + } + + #[test] + fn rewrite_host_with_value_is_explicit_for_ip_to() { + let mut args = base_args(); + args.map = vec!["www.example-publisher.com=192.0.2.10".into()]; + args.rewrite_host = Some(Some("app.edgecompute.app".into())); + let cfg = resolve(&args).expect("should resolve"); + assert_eq!( + cfg.rules.first_match("www.example-publisher.com").expect("rule").host_mode, + HostMode::Explicit("app.edgecompute.app".to_string()), + ); } #[test] @@ -791,7 +841,7 @@ use base64::Engine as _; use error_stack::{Report, ResultExt as _}; use super::ProxyArgs; -use super::rewrite::{Authority, Rule, RuleTable}; +use super::rewrite::{Authority, HostMode, Rule, RuleTable}; /// Errors from configuration resolution. #[derive(Debug, derive_more::Display)] @@ -805,6 +855,9 @@ pub enum ConfigError { /// A rule `FROM` value was not a bare hostname. #[display("invalid FROM host `{value}` (expected a hostname: letters, digits, '-', '.')")] InvalidFrom { value: String }, + /// The `--rewrite-host ` value was not a valid hostname. + #[display("invalid --rewrite-host `{value}` (expected a hostname: letters, digits, '-', '.')")] + InvalidRewriteHost { value: String }, /// `--listen` was not a valid socket address. #[display("invalid --listen address `{value}`")] Listen { value: String }, @@ -908,20 +961,37 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) } +/// Resolves the `--rewrite-host` flag into a [`HostMode`] applied to every rule: +/// absent → preserve `FROM`; bare → use `TO`; with a value → that explicit host +/// (validated, lowercased) for both the `Host` header and the TLS SNI. +fn host_mode(args: &ProxyArgs) -> Result { + match &args.rewrite_host { + None => Ok(HostMode::PreserveFrom), + Some(None) => Ok(HostMode::UseTo), + Some(Some(host)) => { + let host = host.to_ascii_lowercase(); + if !is_valid_host(&host) { + return Err(ConfigError::InvalidRewriteHost { value: host }); + } + Ok(HostMode::Explicit(host)) + } + } +} + fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); - let preserve_host = !args.rewrite_host; + let mode = host_mode(args)?; for entry in &args.map { let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; - rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); } if let (Some(from), Some(to)) = (&args.from, &args.to) { - rules.push(make_rule(from, to, preserve_host, args.upstream_plaintext)?); + rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); } Ok(RuleTable(rules)) } -fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Result { +fn make_rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Result { let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; let from = from.to_ascii_lowercase(); // FROM is interpolated into the generated PAC JavaScript and matched against @@ -930,7 +1000,7 @@ fn make_rule(from: &str, to: &str, preserve_host: bool, plaintext: bool) -> Resu if !is_valid_host(&from) { return Err(ConfigError::InvalidFrom { value: from }); } - Ok(Rule { from, to, preserve_host, plaintext }) + Ok(Rule { from, to, host_mode, plaintext }) } /// Returns whether `host` is a plausible bare hostname (letters, digits, `-`, @@ -1347,7 +1417,7 @@ async fn matched_host_is_rewritten_and_forwarded() { let upstream = start_echo_upstream().await; // Build a ResolvedConfig mapping FROM=www.example-publisher.com to the - // upstream addr, preserve_host = true (default), insecure = true. + // upstream addr, host_mode = PreserveFrom (default), insecure = true. let cfg = test_config(&upstream.addr); // CA + helpers come from the lib target (Task 1 added `src/lib.rs`): // use trusted_server_cli::commands::dev::proxy::{ca, config, server}; @@ -1568,14 +1638,14 @@ Implements spec §9 and §4.2/§7.3. #[cfg(test)] mod tests { use super::*; - use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; + use crate::commands::dev::proxy::rewrite::{Authority, HostMode, Rule, RuleTable}; #[test] fn pac_proxies_only_https_for_from_hosts() { let rules = RuleTable(vec![Rule { from: "www.example-publisher.com".into(), to: Authority::parse("to.edgecompute.app", false).expect("auth"), - preserve_host: true, + host_mode: HostMode::PreserveFrom, plaintext: false, }]); let pac = generate_pac(&rules, "127.0.0.1:18080".parse().expect("addr")); diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index fd0ecf1fc..d3753f4fe 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -93,17 +93,17 @@ satisfies **HSTS**, which an "ignored" cert does not. Resolved during brainstorming and design review (2026-06-22): -| Decision | Choice | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | -| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | -| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | -| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | -| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | -| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | -| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | -| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO` for upstreams that route/validate on their own host. `X-Orig-Host` is informational (§8.3). | -| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | +| Decision | Choice | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO`, or `--rewrite-host ` sends (and SNIs) an explicit host so `TO` can be a bare IP. `X-Orig-Host` is informational (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | --- @@ -119,11 +119,11 @@ ts dev proxy [OPTIONS] | ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | | `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | -| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `-t, --to` | `HOST[:PORT]` or `IP[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. May be a bare IP (then pass `--rewrite-host `). A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | | `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | | `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | | `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | -| `--rewrite-host` | flag | false | Send `Host: ` upstream instead of the default `` (see §8.3). | +| `--rewrite-host` | _(none)_ \| `HOST` | _unset_ (Host = `FROM`) | Rewrite the upstream `Host` header **and** TLS SNI. Bare → `Host = TO`; with a value → `Host = SNI = ` (needed when `--to` is a bare IP). Omit to keep `Host = FROM` (see §8.3). | | `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | | `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | | `--insecure` | flag | false | Skip **upstream** certificate verification. | @@ -325,18 +325,28 @@ struct CertAuthority { ```rust struct Rule { from: String, // matched case-insensitively, port-stripped - to: Authority, // host + optional port (default 443; 80 with --upstream-plaintext) - preserve_host: bool, + to: Authority, // host OR bare IP + optional port (default 443; 80 with --upstream-plaintext) + host_mode: HostMode, plaintext: bool, } +enum HostMode { // how the upstream `Host` header AND the TLS SNI are derived + PreserveFrom, // default: Host = FROM; SNI = TO host + UseTo, // bare --rewrite-host: Host = TO; SNI = TO host + Explicit(String), // --rewrite-host : Host = ; SNI = (TO may be a bare IP) +} struct RuleTable(Vec); // first match wins; unmatched => pass-through ``` -In v1, `preserve_host` (default **true**) and `plaintext` are set on every rule -from the global flags — `--rewrite-host` clears `preserve_host`, and -`--upstream-plaintext` sets `plaintext`; the per-rule fields exist so a future -per-`--map` override can be added without a struct change. `-f/--from` + -`-t/--to` is sugar for a single `--map FROM=TO`. +In v1, `host_mode` (default **`PreserveFrom`**) and `plaintext` are set on every +rule from the global flags — `--rewrite-host` selects `UseTo` (bare) or +`Explicit()` (with a value), and `--upstream-plaintext` sets `plaintext`; +the per-rule fields exist so a future per-`--map` override can be added without a +struct change. `-f/--from` + `-t/--to` is sugar for a single `--map FROM=TO`. + +The connection target is always `rule.to` (so `TO` may be a bare IP). Because the +SNI is derived independently, `Explicit()` lets a request reach an IP +upstream while still presenting a real hostname for TLS and routing — the only +way to drive a host-routed endpoint (Fastly, a load balancer) by IP. ### 8.2 Matching @@ -350,13 +360,14 @@ are instead refused with `403` (§11), never blind-tunneled. ### 8.3 Header rewriting on match -| Header | Action | Rationale | -| ----------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| upstream connection + **SNI** | `rule.to` **host only** (port stripped) | SNI is a bare hostname; a `:port` in SNI is invalid and breaks the handshake | -| `Host` | `rule.from` (default) or `rule.to` if `--rewrite-host` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | -| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | -| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | -| `Proxy-Connection` | removed | hop-by-hop hygiene | +| Header | Action | Rationale | +| ----------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| upstream **connection** | `rule.to` host/IP + port | dialed verbatim; may be a bare IP | +| **SNI** | `rule.to` host (default/bare), or the `--rewrite-host ` value; **port stripped** | SNI is a bare hostname; a `:port` (or a bare IP) in SNI is invalid/unroutable, so an IP `TO` needs an explicit `--rewrite-host` | +| `Host` | `rule.from` (default); `rule.to` with bare `--rewrite-host`; the `` value with `--rewrite-host ` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | +| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | +| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | +| `Proxy-Connection` | removed | hop-by-hop hygiene | **Why `Host = FROM` is the default (resolved).** The §1 goal — validate cookies, `Host`-sensitive logic, CMP/consent, and first-party context at the _real_ @@ -376,6 +387,14 @@ only one service (§2 ¶3), you cannot add the live production domain to a separ dev service. For those upstreams, pass `--rewrite-host` (sends `Host = TO`) or add the domain to the service. +**Targeting an IP upstream.** To reach a specific server/load balancer by address, +set `TO` to a bare IP (`--to 192.0.2.10`) and pass `--rewrite-host ` with the +logical hostname the endpoint expects. The proxy then dials the IP while presenting +`` as both the TLS SNI and the `Host` header — without it, SNI would be the IP +(rustls sends no SNI extension for IP literals, so the endpoint gets its default +vhost). Cert verification still applies; add `--insecure` if the IP serves a cert +that doesn't match ``. + `X-Orig-Host: FROM` is still sent for upstreams that opt to honor it, but it is **informational only**: TS core does not read it today and in fact _strips_ spoofable forwarded host headers (`X-Forwarded-Host`, etc.) as an anti-spoofing @@ -383,11 +402,12 @@ measure. Reconcile any future trusted-`X-Orig-Host` contract with the existing `publisher.origin_host_header_override` knob. **Validation:** an integration test must assert that, by default, rewritten HTML/RSC output stays on `FROM` (not `TO`). -**Port handling.** When the `Host` value is the upstream (`--rewrite-host`) and -`TO` carries a non-default port (e.g. `localhost:3000`, -`staging.example.com:8443`), the port **is** included in the `Host` header but -**never** in the SNI (a bare hostname). This mirrors the existing split in -`publisher.rs` (`origin_host_without_port` vs `origin_host_header`). +**Port handling.** With bare `--rewrite-host` (`Host = TO`) and a non-default `TO` +port (e.g. `localhost:3000`, `staging.example.com:8443`), the port **is** included +in the `Host` header but **never** in the SNI (a bare hostname). This mirrors the +existing split in `publisher.rs` (`origin_host_without_port` vs +`origin_host_header`). An explicit `--rewrite-host ` is a bare hostname (no +port) used verbatim for both `Host` and SNI. ### 8.4 URI normalization From 442e27ce51bbbde24e30a11448abf42b28b79d8a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:07:14 -0700 Subject: [PATCH 34/40] Drop the macOS-only compile_error gate; rely on macOS-scoped deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `compile_error!` fired on a plain `cargo build` (no `--target`), which the repo's `.cargo/config.toml` resolves to `wasm32-wasip1` — so it tripped even on macOS and blocked the natural build command. Remove the gate. The deps and command modules stay scoped to `target_os = "macos"`, so unsupported targets build an empty shell instead of dragging tokio/ring/aws-lc-sys through an unsupported build. Build natively with an explicit `--target` (proper no-`--target` defaulting is separate, edgezero-aligned work). Also fix a stray `x` that slipped into a config test assertion, which broke the lib-test build. Spec and plan updated to match. --- crates/trusted-server-cli/Cargo.toml | 10 +++---- .../src/commands/dev/proxy/config.rs | 2 +- crates/trusted-server-cli/src/lib.rs | 18 ++++--------- crates/trusted-server-cli/src/main.rs | 5 ++-- .../plans/2026-06-22-ts-dev-proxy.md | 27 ++++++++----------- .../specs/2026-06-22-ts-dev-proxy-design.md | 22 ++++++++------- 6 files changed, 37 insertions(+), 47 deletions(-) diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 1e638add4..0bb9cb7d8 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -16,11 +16,11 @@ path = "src/main.rs" # `ts dev proxy` (this crate's only command) is macOS-only — CA trust via the # login keychain, Safari automation via `networksetup`, and a native TLS / -# networking stack. Scoping every dependency to macOS means unsupported targets -# (notably the repo-default `wasm32-wasip1` from `.cargo/config.toml`) surface -# the `compile_error!` in `lib.rs` as a single clear message, instead of first -# failing to build `tokio`, `ring`, or `aws-lc-sys` for a target they do not -# support. +# networking stack. Scoping every dependency to macOS keeps unsupported targets +# (notably the repo-default `wasm32-wasip1` from `.cargo/config.toml`) from +# trying to build `tokio`, `ring`, or `aws-lc-sys` for a target they do not +# support; on those targets the crate is an empty shell. Build natively with an +# explicit `--target` (e.g. `aarch64-apple-darwin`). [target.'cfg(target_os = "macos")'.dependencies] tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } hyper = { version = "1", features = ["http1", "server", "client"] } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 940f315bc..8866a6a0c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -290,7 +290,7 @@ mod tests { None, "absent --rewrite-host parses to None" ); - assert_eq!(x + assert_eq!( parse_args(&["ts", "--rewrite-host"]).rewrite_host, Some(None), "bare --rewrite-host parses to Some(None)" diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 1a102753c..84e3e876c 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -1,21 +1,13 @@ //! Trusted Server developer CLI library. The `ts` binary is a thin wrapper; //! all logic lives here so integration tests can exercise it. -// `ts dev proxy` — the crate's sole command — is macOS-only: CA trust uses the -// login keychain, Safari is driven via `networksetup`, and browser launching -// uses macOS app conventions. The native networking/TLS dependencies are scoped -// to macOS in `Cargo.toml`, so on other targets (notably the repo-default -// `wasm32-wasip1`) this is the single, clear build error instead of a cascade of -// failed dependency builds. Cross-platform support is future work (design spec §16). -#[cfg(not(target_os = "macos"))] -compile_error!( - "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ - networksetup). Cross-platform support is tracked as future work in the \ - design spec (§16)." -); - pub mod output; +// `ts dev proxy` — the crate's sole command — is macOS-only (CA trust via the +// login keychain, Safari automation via `networksetup`, a native TLS/networking +// stack). Its dependencies are scoped to macOS in `Cargo.toml`, so the command +// module only exists there; on other targets the crate builds as an empty shell. +// Cross-platform support is future work (design spec §16). #[cfg(target_os = "macos")] pub mod commands; diff --git a/crates/trusted-server-cli/src/main.rs b/crates/trusted-server-cli/src/main.rs index 27df7031a..2858f029b 100644 --- a/crates/trusted-server-cli/src/main.rs +++ b/crates/trusted-server-cli/src/main.rs @@ -9,7 +9,8 @@ fn main() { std::process::exit(Cli::parse().run()); } -// On unsupported targets the library's `compile_error!` is the real failure; -// this trivial entry point just keeps the binary target's shape valid. +// `ts dev proxy` is macOS-only (its deps are macOS-scoped in `Cargo.toml`), so +// on other targets the crate is an empty shell; this trivial entry point just +// keeps the binary target's shape valid. #[cfg(not(target_os = "macos"))] fn main() {} diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index 6842ad300..6a54ec93d 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -97,9 +97,10 @@ path = "src/main.rs" # The proxy (this crate's only command) is macOS-only, so every dependency is # scoped to macOS. On other targets — notably the repo-default `wasm32-wasip1` -# from `.cargo/config.toml` — none of this native TLS/networking stack is built, -# so the `compile_error!` in `src/lib.rs` is the single, clear failure instead of -# a cascade of failed `tokio`/`ring`/`aws-lc-sys` builds. +# from `.cargo/config.toml` — none of this native TLS/networking stack is built +# and the crate is an empty shell, instead of a cascade of failed +# `tokio`/`ring`/`aws-lc-sys` builds. Build natively with an explicit `--target` +# (e.g. `aarch64-apple-darwin`). [target.'cfg(target_os = "macos")'.dependencies] tokio = { version = "1", features = ["net", "rt-multi-thread", "macros", "io-util", "signal"] } hyper = { version = "1", features = ["http1", "server", "client"] } @@ -160,19 +161,12 @@ The crate is a **library + thin bin** so that integration tests (Task 5) can imp //! Trusted Server developer CLI library. The `ts` binary is a thin wrapper; //! all logic lives here so integration tests can exercise it. -// `ts dev proxy` — the crate's sole command — is macOS-only. The platform gate -// lives here at the crate root (not inside the proxy module) so that, combined -// with the macOS-scoped deps in Cargo.toml, unsupported targets compile nothing -// but this single clear error — no failed native dependency builds (spec §16). -#[cfg(not(target_os = "macos"))] -compile_error!( - "`ts dev proxy` currently supports macOS only (keychain trust, Safari, \ - networksetup). Cross-platform support is tracked as future work in the \ - design spec (§16)." -); - pub mod output; +// `ts dev proxy` — the crate's sole command — is macOS-only (CA trust via the +// login keychain, Safari automation via `networksetup`, a native TLS/networking +// stack). Its deps are macOS-scoped in Cargo.toml, so the command module only +// exists there; on other targets the crate builds as an empty shell (spec §16). #[cfg(target_os = "macos")] pub mod commands; @@ -229,8 +223,9 @@ fn main() { std::process::exit(Cli::parse().run()); } -// On unsupported targets the library's `compile_error!` is the real failure; -// this trivial entry point just keeps the binary target's shape valid. +// `ts dev proxy` is macOS-only (its deps are macOS-scoped in Cargo.toml), so on +// other targets the crate is an empty shell; this trivial entry point just keeps +// the binary target's shape valid. #[cfg(not(target_os = "macos"))] fn main() {} ``` diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index d3753f4fe..6ee3c8e39 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -257,11 +257,13 @@ user-facing output through a thin helper (with a local `#![allow(clippy::print_stdout)]` if that restriction lint is enabled). Scope every dependency to macOS (`[target.'cfg(target_os = "macos")'.dependencies]`) -and place the platform `compile_error!` (§16) at the **crate root** (`lib.rs`), -gating the command modules behind `#[cfg(target_os = "macos")]`. Together these -ensure an accidental wasm build (the repo default) compiles nothing but the -single clear "macOS only" error — never a cascade of failed `tokio`/`ring`/ -`aws-lc-sys` builds for a target they don't support. +and gate the command modules behind `#[cfg(target_os = "macos")]`. On any other +target (notably the repo-default `wasm32-wasip1`) the crate builds as an empty +shell rather than dragging `tokio`/`ring`/`aws-lc-sys` through a build for a +target they don't support. Build the tool natively with an explicit `--target` +(e.g. `aarch64-apple-darwin`); a plain `cargo build` uses the wasm default and +produces only the empty shell. (Making a no-`--target` build resolve to the host +is target-config work tracked separately, aligned with edgezero conventions.) --- @@ -667,9 +669,9 @@ Steps 1–4 already deliver a usable tool; each step is independently shippable. - **WebSocket / non-HTTP upgrades** through the MITM tunnel. - **Response rewriting / fixture injection** (mock upstreams, latency). - **Multiple simultaneous upstreams per host** (A/B / weighted). -- **Windows/Linux support.** v1 is macOS-only — deps are scoped to - `target_os = "macos"` and the crate root carries a `compile_error!` for other - targets (§6), because CA trust, Safari automation, and browser launching all - rely on macOS tooling (login keychain, `networksetup`). Cross-platform trust + - Safari automation is future work. +- **Windows/Linux support.** v1 is macOS-only — deps and command modules are + scoped to `target_os = "macos"` (the crate is an empty shell elsewhere; §6), + because CA trust, Safari automation, and browser launching all rely on macOS + tooling (login keychain, `networksetup`). Cross-platform trust + Safari + automation is future work. - **Recording/replay** of proxied traffic. From 7e16997dffc9b8a3fd40064e8ba3e05e68050682 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:41:56 -0700 Subject: [PATCH 35/40] Add --resolve DNS pin and simplify --rewrite-host back to a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the overloaded `--rewrite-host ` (which set both Host and SNI to reach an IP upstream) with two orthogonal knobs: - `--resolve HOST:IP` (repeatable, curl-style) pins where a hostname's upstream connection dials, leaving the SNI/Host derivation untouched. This is the self-contained way to reach a server by IP while keeping `--to` a hostname so the TLS SNI and certificate stay valid — no /etc/hosts edit. - `--rewrite-host` is a plain bool again: send `Host: TO` instead of `Host: FROM`. The SNI is always the TO host. Drops the `HostMode` enum in favor of `Rule.rewrite_host: bool` named and passed straight from the flag (no `preserve_host` inversion). Adds an e2e proving a non-resolvable TO host still reaches the upstream via --resolve. Spec and guide updated; the guide now leads with the --resolve flow. --- .../src/commands/dev/proxy/browser.rs | 4 +- .../src/commands/dev/proxy/config.rs | 167 ++++++++++-------- .../src/commands/dev/proxy/mod.rs | 24 ++- .../src/commands/dev/proxy/rewrite.rs | 94 +++------- .../src/commands/dev/proxy/server.rs | 20 ++- crates/trusted-server-cli/tests/proxy_e2e.rs | 21 +++ .../trusted-server-cli/tests/support/mod.rs | 18 ++ docs/guide/ts-dev-proxy.md | 71 ++++---- .../specs/2026-06-22-ts-dev-proxy-design.md | 114 ++++++------ 9 files changed, 273 insertions(+), 260 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index d7872c0e5..3f821b29c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -678,7 +678,7 @@ fn restore_auto_proxy( #[cfg(test)] mod tests { use super::*; - use crate::commands::dev::proxy::rewrite::{Authority, HostMode, Rule, RuleTable}; + use crate::commands::dev::proxy::rewrite::{Authority, Rule, RuleTable}; #[test] fn shell_quote_wraps_and_escapes() { @@ -739,7 +739,7 @@ mod tests { let rules = RuleTable(vec![Rule { from: "www.example-publisher.com".into(), to: Authority::parse("to.edgecompute.app", false).expect("should parse authority"), - host_mode: HostMode::PreserveFrom, + rewrite_host: false, plaintext: false, }]); let pac = generate_pac( diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 8866a6a0c..c689f7137 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -1,5 +1,6 @@ //! Resolves `ProxyArgs` (+ defaults) into a concrete [`ResolvedConfig`]. +use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -7,7 +8,7 @@ use base64::Engine as _; use error_stack::{Report, ResultExt as _}; use super::ProxyArgs; -use super::rewrite::{Authority, HostMode, Rule, RuleTable}; +use super::rewrite::{Authority, Rule, RuleTable}; /// Errors from configuration resolution. #[derive(Debug, derive_more::Display)] @@ -21,9 +22,9 @@ pub enum ConfigError { /// The FROM host contained characters not valid in a hostname. #[display("invalid FROM host `{value}` (expected a hostname: letters, digits, '-', '.')")] InvalidFrom { value: String }, - /// The `--rewrite-host ` value was not a valid hostname. - #[display("invalid --rewrite-host `{value}` (expected a hostname: letters, digits, '-', '.')")] - InvalidRewriteHost { value: String }, + /// A `--resolve` value was not `HOST:IP` with a valid hostname and IP. + #[display("invalid --resolve `{value}` (expected HOST:IP, e.g. ts.example.com:192.0.2.10)")] + Resolve { value: String }, /// `--listen` was not a valid socket address. #[display("invalid --listen address `{value}`")] Listen { value: String }, @@ -111,6 +112,10 @@ pub struct ResolvedConfig { pub insecure: bool, pub basic_auth: Option, pub ca_dir: PathBuf, + /// DNS pins from `--resolve`: lowercase hostname → connection address. When + /// an upstream host is present here, the proxy dials this IP instead of + /// resolving the name, leaving the SNI/`Host` untouched. + pub resolve: HashMap, } /// Default CA directory (spec §7.1/§12): `$XDG_DATA_HOME/trusted-server/dev-proxy`, @@ -138,36 +143,54 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { .map_or_else(default_ca_dir, PathBuf::from) } -/// Resolves the `--rewrite-host` flag into a [`HostMode`] applied to every rule: -/// absent → preserve `FROM`; bare → use `TO`; with a value → that explicit host -/// (validated, lowercased) for both the `Host` header and the TLS SNI. -fn host_mode(args: &ProxyArgs) -> Result { - match &args.rewrite_host { - None => Ok(HostMode::PreserveFrom), - Some(None) => Ok(HostMode::UseTo), - Some(Some(host)) => { - let host = host.to_ascii_lowercase(); - if !is_valid_host(&host) { - return Err(ConfigError::InvalidRewriteHost { value: host }); - } - Ok(HostMode::Explicit(host)) - } - } -} - fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); - let mode = host_mode(args)?; + // `--rewrite-host` only chooses the `Host` header; the SNI always follows TO. for entry in &args.map { let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; - rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); + rules.push(make_rule( + from, + to, + args.rewrite_host, + args.upstream_plaintext, + )?); } if let (Some(from), Some(to)) = (&args.from, &args.to) { - rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); + rules.push(make_rule( + from, + to, + args.rewrite_host, + args.upstream_plaintext, + )?); } Ok(RuleTable(rules)) } +/// Parses `--resolve HOST:IP` entries into a lowercase-host → address map. +/// +/// Splits on the first `:` so the IP (including IPv6, which contains `:`) is the +/// remainder. The host is validated as a hostname and the address as an +/// [`IpAddr`]. +fn build_resolve(args: &ProxyArgs) -> Result, ConfigError> { + let mut map = HashMap::new(); + for entry in &args.resolve { + let (host, ip) = entry.split_once(':').ok_or_else(|| ConfigError::Resolve { + value: entry.clone(), + })?; + let host = host.to_ascii_lowercase(); + let ip: IpAddr = ip.parse().map_err(|_| ConfigError::Resolve { + value: entry.clone(), + })?; + if !is_valid_host(&host) { + return Err(ConfigError::Resolve { + value: entry.clone(), + }); + } + map.insert(host, ip); + } + Ok(map) +} + /// Whether `host` is a syntactically valid hostname — ASCII letters, digits, /// `-`, and `.` only — so it is safe to embed verbatim in the generated PAC /// JavaScript, the browser URL, and the upstream `Host` header. @@ -182,7 +205,7 @@ fn is_valid_host(host: &str) -> bool { fn make_rule( from: &str, to: &str, - host_mode: HostMode, + rewrite_host: bool, plaintext: bool, ) -> Result { let from = from.to_ascii_lowercase(); @@ -193,7 +216,7 @@ fn make_rule( Ok(Rule { from, to, - host_mode, + rewrite_host, plaintext, }) } @@ -202,8 +225,9 @@ fn make_rule( /// /// # Errors /// -/// Returns [`ConfigError`] on malformed rules, an invalid/forbidden listen -/// address, malformed credentials, or an unknown browser. +/// Returns [`ConfigError`] on malformed rules, a malformed `--resolve` entry, an +/// invalid/forbidden listen address, malformed credentials, or an unknown +/// browser. pub fn resolve(args: &ProxyArgs) -> Result> { let rules = build_rules(args).map_err(Report::from)?; if rules.0.is_empty() { @@ -233,6 +257,7 @@ pub fn resolve(args: &ProxyArgs) -> Result> let basic_auth = resolve_basic_auth(args).map_err(Report::from)?; let ca_dir = ca_dir(args); + let resolve = build_resolve(args).map_err(Report::from)?; Ok(ResolvedConfig { rules, @@ -242,6 +267,7 @@ pub fn resolve(args: &ProxyArgs) -> Result> insecure: args.insecure, basic_auth, ca_dir, + resolve, }) } @@ -284,26 +310,19 @@ mod tests { } #[test] - fn clap_parses_the_three_rewrite_host_forms() { - assert_eq!( - parse_args(&["ts"]).rewrite_host, - None, - "absent --rewrite-host parses to None" + fn clap_parses_rewrite_host_as_a_bool() { + assert!( + !parse_args(&["ts"]).rewrite_host, + "absent --rewrite-host is false" ); - assert_eq!( + assert!( parse_args(&["ts", "--rewrite-host"]).rewrite_host, - Some(None), - "bare --rewrite-host parses to Some(None)" - ); - assert_eq!( - parse_args(&["ts", "--rewrite-host", "app.example.com"]).rewrite_host, - Some(Some("app.example.com".to_string())), - "--rewrite-host parses to Some(Some(host))" + "present --rewrite-host is true" ); } #[test] - fn single_rule_from_to_defaults_to_preserve_host() { + fn single_rule_from_to_keeps_from_host_by_default() { let mut args = base_args(); args.from = Some("www.example-publisher.com".into()); args.to = Some("to.edgecompute.app".into()); @@ -312,60 +331,62 @@ mod tests { .rules .first_match("www.example-publisher.com") .expect("rule present"); - assert_eq!( - rule.host_mode, - HostMode::PreserveFrom, - "default preserves FROM host" - ); + assert!(!rule.rewrite_host, "default preserves FROM host"); assert_eq!(rule.to.host(), "to.edgecompute.app"); } #[test] - fn bare_rewrite_host_uses_to() { + fn rewrite_host_uses_to() { let mut args = base_args(); args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - // Bare `--rewrite-host` (present, no value) parses to `Some(None)`. - args.rewrite_host = Some(None); + args.rewrite_host = true; let cfg = resolve(&args).expect("should resolve"); - assert_eq!( + assert!( cfg.rules .first_match("www.example-publisher.com") .expect("rule") - .host_mode, - HostMode::UseTo, - "bare --rewrite-host sends Host: TO" + .rewrite_host, + "--rewrite-host sends Host: TO" ); } #[test] - fn rewrite_host_with_value_is_explicit_for_ip_to() { + fn resolve_pins_host_to_ip() { let mut args = base_args(); - // TO is a bare IP; the explicit value supplies the Host header and SNI. - args.map = vec!["www.example-publisher.com=192.0.2.10".into()]; - args.rewrite_host = Some(Some("App.EdgeCompute.app".into())); + args.map = vec!["www.example-publisher.com=ts.edgecompute.app".into()]; + // Mixed case to confirm the host key is lowercased. + args.resolve = vec!["TS.EdgeCompute.app:192.0.2.10".into()]; let cfg = resolve(&args).expect("should resolve"); assert_eq!( - cfg.rules - .first_match("www.example-publisher.com") - .expect("rule") - .host_mode, - HostMode::Explicit("app.edgecompute.app".to_string()), - "explicit --rewrite-host is lowercased and stored verbatim" + cfg.resolve.get("ts.edgecompute.app"), + Some(&"192.0.2.10".parse().expect("should parse ipv4")), + "--resolve pins the lowercased host to the address" + ); + } + + #[test] + fn resolve_accepts_ipv6_target() { + let mut args = base_args(); + args.map = vec!["a.example.com=b.edgecompute.app".into()]; + // Split-on-first-colon must keep the colon-bearing IPv6 address intact. + args.resolve = vec!["b.edgecompute.app:::1".into()]; + let cfg = resolve(&args).expect("should resolve"); + assert_eq!( + cfg.resolve.get("b.edgecompute.app"), + Some(&"::1".parse().expect("should parse ipv6")), + "IPv6 --resolve target is parsed whole" ); } #[test] - fn rewrite_host_with_invalid_value_is_rejected() { + fn resolve_rejects_malformed_value() { let mut args = base_args(); - args.map = vec!["www.example-publisher.com=192.0.2.10".into()]; - args.rewrite_host = Some(Some("bad/host".into())); - let err = resolve(&args).expect_err("an invalid --rewrite-host should error"); + args.map = vec!["a.example.com=b.edgecompute.app".into()]; + args.resolve = vec!["b.edgecompute.app:not-an-ip".into()]; + let err = resolve(&args).expect_err("a non-IP --resolve target should error"); assert!( - matches!( - err.current_context(), - ConfigError::InvalidRewriteHost { .. } - ), - "should be InvalidRewriteHost for a non-hostname value" + matches!(err.current_context(), ConfigError::Resolve { .. }), + "should be a Resolve error for a non-IP target" ); } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 90ac34e2f..c6d813956 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -40,9 +40,9 @@ pub struct ProxyArgs { #[arg(short = 'f', long = "from", value_name = "HOST")] pub from: Option, - /// Shorthand single-rule TO (`HOST[:PORT]` or `IP[:PORT]`; pairs with - /// `--from`). When TO is a bare IP, pass `--rewrite-host ` so the TLS - /// SNI and `Host` header target the right vhost. + /// Shorthand single-rule TO (`HOST[:PORT]`; pairs with `--from`). Keep this a + /// hostname so the TLS SNI and certificate stay valid; to reach a specific + /// server by address, pin it with `--resolve` instead of using a bare IP. #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] pub to: Option, @@ -58,12 +58,18 @@ pub struct ProxyArgs { #[arg(long, value_name = "LIST")] pub launch: Option, - /// Rewrite the upstream `Host` header (and TLS SNI). Omit to keep the - /// default `Host: `; bare `--rewrite-host` sends `Host: `; - /// `--rewrite-host ` sends `Host: ` and uses `` for SNI - /// (needed when `--to` is a bare IP address). - #[arg(long, value_name = "HOST", num_args = 0..=1)] - pub rewrite_host: Option>, + /// Send `Host: ` upstream instead of the default ``. The TLS SNI is + /// always the `--to` host; to reach a specific server by address, pin it with + /// `--resolve` rather than changing the host here. + #[arg(long)] + pub rewrite_host: bool, + + /// Pin a host's upstream connection to an address instead of using DNS + /// (repeatable; like curl's `--resolve`). Keeps `--to` a hostname — so SNI + /// and the certificate stay valid — while the socket dials the given IP. + /// Format: `HOST:IP` (e.g. `ts.example.com:192.0.2.10`). + #[arg(long = "resolve", value_name = "HOST:IP")] + pub resolve: Vec, /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). #[arg(long, value_name = "USER:PASS")] diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index 4f9639e06..b964a65d9 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -85,29 +85,17 @@ impl Authority { } } -/// How a rule derives the upstream `Host` header and TLS SNI (spec §8.3). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum HostMode { - /// Default: send `Host: FROM` (preserve the production host); SNI is the - /// `TO` host. - PreserveFrom, - /// Bare `--rewrite-host`: send `Host: TO`; SNI is the `TO` host. - UseTo, - /// `--rewrite-host `: send `Host: ` and present `` as the - /// TLS SNI, while still connecting to `TO`. Lets `TO` be a bare IP address - /// whose endpoint routes and serves certificates by hostname. - Explicit(String), -} - /// A single rewrite rule. #[derive(Debug, Clone)] pub struct Rule { /// Production hostname to match (stored lowercase, port-stripped). pub from: String, - /// Upstream connection target (a hostname or a bare IP address). + /// Upstream target — kept a hostname so the SNI/certificate stay valid; the + /// actual connection address may be pinned via `--resolve`. pub to: Authority, - /// How the upstream `Host` header and TLS SNI are derived. - pub host_mode: HostMode, + /// `--rewrite-host`: when true, send `Host: TO`; otherwise (default) send + /// `Host: FROM`. The TLS SNI is always the `TO` host either way. + pub rewrite_host: bool, /// Connect to the upstream over plaintext HTTP. pub plaintext: bool, } @@ -134,8 +122,7 @@ impl RuleTable { /// The header/SNI decisions for a matched rule. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RewriteOutcome { - /// SNI to present upstream — the `TO` host, or the explicit - /// `--rewrite-host` value; never carries a port. + /// SNI to present upstream — always the `TO` host; never carries a port. pub sni: String, /// Value for the upstream `Host` header. pub host_header: String, @@ -148,15 +135,15 @@ pub struct RewriteOutcome { /// Computes the rewrite outcome for a matched rule (spec §8.3). #[must_use] pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { - // `Host` and SNI are independent of the connection target (the caller dials - // `rule.to`), so an explicit host overrides both while `TO` can stay an IP. - let (host_header, sni) = match &rule.host_mode { - HostMode::PreserveFrom => (rule.from.clone(), rule.to.host().to_string()), - HostMode::UseTo => (rule.to.host_with_port(), rule.to.host().to_string()), - HostMode::Explicit(host) => (host.clone(), host.clone()), + // SNI is always the TO host (the connection address may be pinned separately + // via `--resolve`); only the `Host` header depends on `rewrite_host`. + let host_header = if rule.rewrite_host { + rule.to.host_with_port() + } else { + rule.from.clone() }; RewriteOutcome { - sni, + sni: rule.to.host().to_string(), host_header, orig_host: rule.from.clone(), scheme_is_tls: !rule.plaintext, @@ -167,11 +154,11 @@ pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { mod tests { use super::*; - fn rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Rule { + fn rule(from: &str, to: &str, rewrite_host: bool, plaintext: bool) -> Rule { Rule { from: from.to_string(), to: Authority::parse(to, plaintext).expect("should parse authority"), - host_mode, + rewrite_host, plaintext, } } @@ -237,7 +224,7 @@ mod tests { let table = RuleTable(vec![rule( "www.example-publisher.com", "to.edgecompute.app", - HostMode::PreserveFrom, + false, false, )]); let m = table @@ -256,18 +243,8 @@ mod tests { #[test] fn first_match_wins() { let table = RuleTable(vec![ - rule( - "a.example.com", - "first.edgecompute.app", - HostMode::PreserveFrom, - false, - ), - rule( - "a.example.com", - "second.edgecompute.app", - HostMode::PreserveFrom, - false, - ), + rule("a.example.com", "first.edgecompute.app", false, false), + rule("a.example.com", "second.edgecompute.app", false, false), ]); assert_eq!( table @@ -284,7 +261,7 @@ mod tests { let r = rule( "www.example-publisher.com", "to.edgecompute.app:8443", - HostMode::PreserveFrom, + false, false, ); let out = rewrite_for(&r); @@ -305,12 +282,7 @@ mod tests { #[test] fn rewrite_host_uses_to_authority_with_port() { - let r = rule( - "www.example-publisher.com", - "localhost:3000", - HostMode::UseTo, - true, - ); + let r = rule("www.example-publisher.com", "localhost:3000", true, true); let out = rewrite_for(&r); assert_eq!(out.sni, "localhost", "SNI never carries a port"); assert_eq!( @@ -327,32 +299,6 @@ mod tests { ); } - #[test] - fn explicit_rewrite_host_overrides_host_and_sni_for_ip_upstream() { - // TO is a bare IP; an explicit --rewrite-host drives both the Host header - // and the SNI so the IP endpoint can route and present the right cert. - let r = rule( - "www.example-publisher.com", - "192.0.2.10", - HostMode::Explicit("app.edgecompute.app".to_string()), - false, - ); - let out = rewrite_for(&r); - assert_eq!( - out.sni, "app.edgecompute.app", - "SNI is the explicit host, not the IP" - ); - assert_eq!( - out.host_header, "app.edgecompute.app", - "Host header is the explicit host" - ); - assert_eq!( - out.orig_host, "www.example-publisher.com", - "X-Orig-Host stays FROM" - ); - assert!(out.scheme_is_tls, "TLS rule yields a TLS outcome"); - } - #[test] fn rejects_empty_or_missing_port() { let err = diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 539abcb35..52b2b1067 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -7,6 +7,7 @@ //! or refused (`403`) off loopback. An origin-form `GET /proxy.pac` is served //! locally. +use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; @@ -315,7 +316,8 @@ async fn mitm( let rules = cfg.rules.clone(); let basic_auth = cfg.basic_auth.clone(); let insecure = cfg.insecure; - async move { forward_request(req, &host, &rules, basic_auth.as_ref(), insecure).await } + let resolve = cfg.resolve.clone(); + async move { forward_request(req, &host, &rules, basic_auth.as_ref(), insecure, &resolve).await } }); // serve_connection drives keep-alive: many sequential requests per tunnel. @@ -346,6 +348,7 @@ async fn forward_request( rules: &super::rewrite::RuleTable, basic_auth: Option<&super::config::BasicAuth>, insecure: bool, + resolve: &HashMap, ) -> Result>, Report> { if req.headers().contains_key(hyper::header::UPGRADE) { log::info!("closing tunnel for {connect_host}: Upgrade (WebSocket) is out of scope"); @@ -377,6 +380,7 @@ async fn forward_request( insecure, &upstream_host, upstream_port, + resolve, ) .await { @@ -412,6 +416,7 @@ async fn proxy_to_upstream( insecure: bool, upstream_host: &str, upstream_port: u16, + resolve: &HashMap, ) -> Result>, Report> { log::debug!( "{} {} -> {}:{} (Host={}, X-Orig-Host={})", @@ -425,9 +430,16 @@ async fn proxy_to_upstream( rewrite_headers(req.headers_mut(), outcome, basic_auth); - let tcp = TcpStream::connect((upstream_host, upstream_port)) - .await - .change_context(ProxyError::Server)?; + // Dial the `--resolve` pin when the upstream host has one; the SNI/`Host` + // (set above) stay the hostname, so the certificate still validates. + let tcp = match resolve.get(upstream_host) { + Some(ip) => { + log::debug!("--resolve {upstream_host} -> {ip}"); + TcpStream::connect((*ip, upstream_port)).await + } + None => TcpStream::connect((upstream_host, upstream_port)).await, + } + .change_context(ProxyError::Server)?; let response = if outcome.scheme_is_tls { let connector = TlsConnector::from(client_config(insecure)); diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 7564f9cab..e04261dd9 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -36,6 +36,27 @@ async fn matched_host_is_rewritten_and_forwarded() { ); } +#[tokio::test] +async fn resolve_pins_connection_to_address() { + let upstream = support::start_echo_upstream().await; + // The TO host is `pinned.invalid` (never DNS-resolvable); `--resolve` sends + // the connection to the real upstream, so a 200 proves the pin is honored. + let cfg = support::test_config_with_resolve(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let response = support::drive_request_through_proxy(cfg, ca).await; + + assert_eq!( + response.status, 200, + "a non-resolvable TO host still reaches the upstream via --resolve" + ); + assert_eq!( + response.seen_host, + support::FROM_HOST, + "Host stays FROM (no --rewrite-host)" + ); +} + #[tokio::test] async fn unmatched_host_is_blind_tunneled_on_loopback() { let upstream = support::start_echo_upstream().await; diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index 7e43aa732..27e7593c1 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -54,6 +54,24 @@ pub fn test_config(addr: &SocketAddr) -> config::ResolvedConfig { resolve(&["ts", "--map", &map, "--listen", "127.0.0.1:0", "--insecure"]) } +/// Builds a config whose TO host is a **non-resolvable** name (`pinned.invalid`) +/// pinned to the upstream `addr` via `--resolve`. The request only reaches the +/// upstream if the pin is honored — DNS for `.invalid` never resolves. +pub fn test_config_with_resolve(addr: &SocketAddr) -> config::ResolvedConfig { + let map = format!("{FROM_HOST}=pinned.invalid:{}", addr.port()); + let pin = format!("pinned.invalid:{}", addr.ip()); + resolve(&[ + "ts", + "--map", + &map, + "--resolve", + &pin, + "--listen", + "127.0.0.1:0", + "--insecure", + ]) +} + /// A config with no rewrite rules (every CONNECT is unmatched), on loopback. pub fn test_config_without_rules() -> config::ResolvedConfig { // resolve() rejects an empty rule table, so map an unrelated host the tests diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 5b74b87f0..e7a944dc2 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -176,49 +176,42 @@ correctly: it anchors all HTML/URL rewriting to the inbound `Host`, so keeping This works well against a Trusted Server Compute upstream because Fastly routes by SNI (`= TO`) and passes `Host` through to the application unchanged. -If your upstream validates or routes on its own hostname, pass bare -`--rewrite-host`: - -```bash -ts dev proxy \ - --map www.example-publisher.com=staging.example.net \ - --rewrite-host \ - --launch chrome -``` - -With bare `--rewrite-host`, the proxy sends `Host: staging.example.net` (the -`TO` host). An `X-Orig-Host: www.example-publisher.com` header is always sent -informally. - -`--rewrite-host` controls both the upstream `Host` header **and** the TLS SNI: - -| Form | `Host` header | TLS SNI | -| ----------------------- | ------------- | --------- | -| _(omitted)_ | `FROM` | `TO` host | -| `--rewrite-host` | `TO` host | `TO` host | -| `--rewrite-host ` | `` | `` | - -**Targeting an IP upstream.** To reach a specific server or load balancer by -address, set `--to` to a bare IP and pass `--rewrite-host ` with the -hostname that endpoint expects. The proxy dials the IP but presents `` for -both SNI and `Host`: +**Targeting a specific server by IP.** To point at a particular server or load +balancer — for example when the `TO` hostname isn't in DNS yet — keep `--to` a +hostname (so the TLS SNI and certificate stay valid) and pin its connection +address with `--resolve HOST:IP` (like curl's `--resolve`, repeatable): ```bash ts dev proxy \ --from www.example-publisher.com \ - --to 192.0.2.10 \ - --rewrite-host app.edgecompute.app \ + --to ts.example-publisher.com \ + --resolve ts.example-publisher.com:192.0.2.10 \ --launch chrome ``` -Without the explicit ``, the SNI would be the IP itself — which sends no -SNI extension at all, so a host-routed endpoint serves its default vhost. Add -`--insecure` if the IP serves a certificate that doesn't match ``. - -**Port handling.** With bare `--rewrite-host` and a non-default `TO` port (e.g. -`localhost:3000`), the port is included in the `Host` header but never in the -SNI (a bare hostname; a port in SNI is invalid). An explicit -`--rewrite-host ` is a bare hostname (no port). +The proxy dials `192.0.2.10` while the SNI and `Host` stay +`ts.example-publisher.com` (SNI) and `www.example-publisher.com` (`Host = FROM`, +the default) — so TS still rewrites first-party URLs onto the production domain. +This keeps the tool self-contained — no `/etc/hosts` edit. (Pointing `--to` at a +bare IP instead would make the SNI an IP, which sends no SNI extension at all, so +a host-routed endpoint serves its default vhost.) Add `--insecure` if the +endpoint serves a certificate that doesn't match the hostname. + +**Sending `Host: TO` instead.** Only if your upstream is **not** a Trusted Server +and routes/validates on its _own_ hostname, pass `--rewrite-host` to send +`Host: `. Avoid it for TS upstreams: TS anchors first-party URL rewriting to +the inbound `Host`, so `Host = TO` rewrites links onto the `TO` host. The TLS SNI +is always the `TO` host either way: + +| Form | `Host` header | TLS SNI | +| ---------------- | ------------- | --------- | +| _(omitted)_ | `FROM` | `TO` host | +| `--rewrite-host` | `TO` host | `TO` host | + +An `X-Orig-Host: ` header is always sent informally. **Port handling:** with +`--rewrite-host` and a non-default `TO` port (e.g. `localhost:3000`), the port is +included in the `Host` header but never in the SNI (a bare hostname; a port in SNI +is invalid). ## Non-loopback listen @@ -244,12 +237,12 @@ ts dev proxy [OPTIONS] [COMMAND] Options: --map Rewrite rule (repeatable) -f, --from Single-rule FROM (pairs with --to) - -t, --to Single-rule TO (host or IP; pairs with --from) + -t, --to Single-rule TO (hostname; pairs with --from) + --resolve Pin HOST's connection to IP (curl-style, repeatable) --listen Listen address [default: 127.0.0.1:18080] --allow-non-loopback Permit non-loopback --listen (disables blind tunnel) --launch Browsers to launch (chrome,firefox,safari or all) - --rewrite-host [] Rewrite upstream Host + SNI: bare = TO, = explicit - (omit to keep the default Host: ) + --rewrite-host Send Host: instead of the default --basic-auth Inject Basic auth (visible in ps — prefer --basic-auth-file) --basic-auth-file Read USER:PASS from a file --insecure Skip upstream TLS certificate verification diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 6ee3c8e39..4d07cf9bc 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -93,17 +93,17 @@ satisfies **HSTS**, which an "ignored" cert does not. Resolved during brainstorming and design review (2026-06-22): -| Decision | Choice | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | -| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | -| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | -| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | -| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | -| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | -| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | -| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO`, or `--rewrite-host ` sends (and SNIs) an explicit host so `TO` can be a bare IP. `X-Orig-Host` is informational (§8.3). | -| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | +| Decision | Choice | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO`. The SNI is always the `TO` host; to reach a server by IP, keep `TO` a hostname and pin it with `--resolve`. `X-Orig-Host` is informational (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | --- @@ -115,20 +115,21 @@ ts dev proxy [OPTIONS] ### 4.1 Options -| Flag | Value | Default | Description | -| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | -| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | -| `-t, --to` | `HOST[:PORT]` or `IP[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. May be a bare IP (then pass `--rewrite-host `). A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | -| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | -| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | -| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | -| `--rewrite-host` | _(none)_ \| `HOST` | _unset_ (Host = `FROM`) | Rewrite the upstream `Host` header **and** TLS SNI. Bare → `Host = TO`; with a value → `Host = SNI = ` (needed when `--to` is a bare IP). Omit to keep `Host = FROM` (see §8.3). | -| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | -| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | -| `--insecure` | flag | false | Skip **upstream** certificate verification. | -| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | -| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | +| Flag | Value | Default | Description | +| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | +| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | +| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. Keep it a hostname so the SNI/cert stay valid; pin a connection address with `--resolve`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | +| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | +| `--rewrite-host` | flag | false (Host = `FROM`) | Send `Host = TO` instead of the default `Host = FROM`. The TLS SNI is always the `TO` host (see §8.3). | +| `--resolve` | `HOST:IP` (repeatable) | — | Pin `HOST`'s upstream connection to `IP` (curl-style), so `TO` stays a hostname (valid SNI/cert) while the socket dials a chosen server. Keeps the tool self-contained — no `/etc/hosts` (see §8.3). | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | +| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | +| `--insecure` | flag | false | Skip **upstream** certificate verification. | +| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | +| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | ### 4.2 Companion subcommands @@ -327,28 +328,24 @@ struct CertAuthority { ```rust struct Rule { from: String, // matched case-insensitively, port-stripped - to: Authority, // host OR bare IP + optional port (default 443; 80 with --upstream-plaintext) - host_mode: HostMode, + to: Authority, // hostname + optional port (default 443; 80 with --upstream-plaintext) + rewrite_host: bool,// false (default): Host = FROM; true (--rewrite-host): Host = TO plaintext: bool, } -enum HostMode { // how the upstream `Host` header AND the TLS SNI are derived - PreserveFrom, // default: Host = FROM; SNI = TO host - UseTo, // bare --rewrite-host: Host = TO; SNI = TO host - Explicit(String), // --rewrite-host : Host = ; SNI = (TO may be a bare IP) -} struct RuleTable(Vec); // first match wins; unmatched => pass-through ``` -In v1, `host_mode` (default **`PreserveFrom`**) and `plaintext` are set on every -rule from the global flags — `--rewrite-host` selects `UseTo` (bare) or -`Explicit()` (with a value), and `--upstream-plaintext` sets `plaintext`; -the per-rule fields exist so a future per-`--map` override can be added without a -struct change. `-f/--from` + `-t/--to` is sugar for a single `--map FROM=TO`. +In v1, `rewrite_host` (default **false**) and `plaintext` are set on every rule +from the global flags (`--rewrite-host`, `--upstream-plaintext`); the per-rule +fields exist so a future per-`--map` override can be added without a struct +change. `-f/--from` + `-t/--to` is sugar for a single `--map FROM=TO`. -The connection target is always `rule.to` (so `TO` may be a bare IP). Because the -SNI is derived independently, `Explicit()` lets a request reach an IP -upstream while still presenting a real hostname for TLS and routing — the only -way to drive a host-routed endpoint (Fastly, a load balancer) by IP. +The TLS **SNI is always the `TO` host** (port-stripped), independent of +`rewrite_host` — so `TO` should stay a hostname for the certificate to validate. +The **connection address** is decoupled from the SNI: `--resolve HOST:IP` pins +where `TO`'s socket connects (curl-style), letting a request reach a specific +server/load balancer by IP while still presenting the real hostname for TLS and +routing. See §8.3 / §8.5. ### 8.2 Matching @@ -362,14 +359,14 @@ are instead refused with `403` (§11), never blind-tunneled. ### 8.3 Header rewriting on match -| Header | Action | Rationale | -| ----------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| upstream **connection** | `rule.to` host/IP + port | dialed verbatim; may be a bare IP | -| **SNI** | `rule.to` host (default/bare), or the `--rewrite-host ` value; **port stripped** | SNI is a bare hostname; a `:port` (or a bare IP) in SNI is invalid/unroutable, so an IP `TO` needs an explicit `--rewrite-host` | -| `Host` | `rule.from` (default); `rule.to` with bare `--rewrite-host`; the `` value with `--rewrite-host ` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | -| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | -| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | -| `Proxy-Connection` | removed | hop-by-hop hygiene | +| Header | Action | Rationale | +| ----------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| upstream **connection** | the `--resolve` pin for `rule.to` host if present, else `rule.to` host via DNS; + `rule.to` port | lets `TO` stay a hostname (valid SNI/cert) while the socket targets a chosen IP | +| **SNI** | `rule.to` host; **port stripped** | always the `TO` hostname; a `:port` or bare IP in SNI is invalid/unroutable, so keep `TO` a hostname | +| `Host` | `rule.from` (default); `rule.to` with `--rewrite-host` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | +| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | +| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | +| `Proxy-Connection` | removed | hop-by-hop hygiene | **Why `Host = FROM` is the default (resolved).** The §1 goal — validate cookies, `Host`-sensitive logic, CMP/consent, and first-party context at the _real_ @@ -389,13 +386,13 @@ only one service (§2 ¶3), you cannot add the live production domain to a separ dev service. For those upstreams, pass `--rewrite-host` (sends `Host = TO`) or add the domain to the service. -**Targeting an IP upstream.** To reach a specific server/load balancer by address, -set `TO` to a bare IP (`--to 192.0.2.10`) and pass `--rewrite-host ` with the -logical hostname the endpoint expects. The proxy then dials the IP while presenting -`` as both the TLS SNI and the `Host` header — without it, SNI would be the IP -(rustls sends no SNI extension for IP literals, so the endpoint gets its default -vhost). Cert verification still applies; add `--insecure` if the IP serves a cert -that doesn't match ``. +**Targeting a specific server by IP.** Keep `TO` a hostname (so the SNI and +certificate stay valid) and pin its connection address with `--resolve HOST:IP` +(curl-style, repeatable). The proxy dials the IP while the SNI and `Host` stay the +hostname — avoiding a bare-IP SNI (rustls sends no SNI extension for IP literals, +so a host-routed endpoint would serve its default vhost). Cert verification still +applies; add `--insecure` if the endpoint serves a cert that doesn't match. This +keeps the tool self-contained — no `/etc/hosts` edit. `X-Orig-Host: FROM` is still sent for upstreams that opt to honor it, but it is **informational only**: TS core does not read it today and in fact _strips_ @@ -404,12 +401,11 @@ measure. Reconcile any future trusted-`X-Orig-Host` contract with the existing `publisher.origin_host_header_override` knob. **Validation:** an integration test must assert that, by default, rewritten HTML/RSC output stays on `FROM` (not `TO`). -**Port handling.** With bare `--rewrite-host` (`Host = TO`) and a non-default `TO` +**Port handling.** With `--rewrite-host` (`Host = TO`) and a non-default `TO` port (e.g. `localhost:3000`, `staging.example.com:8443`), the port **is** included in the `Host` header but **never** in the SNI (a bare hostname). This mirrors the existing split in `publisher.rs` (`origin_host_without_port` vs -`origin_host_header`). An explicit `--rewrite-host ` is a bare hostname (no -port) used verbatim for both `Host` and SNI. +`origin_host_header`). ### 8.4 URI normalization From 73dea6921712d4315fb22cc9bdfb96a29d33ad49 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:48:10 -0700 Subject: [PATCH 36/40] Sync dev-proxy plan with --resolve + rewrite_host bool design --- .../plans/2026-06-22-ts-dev-proxy.md | 186 ++++++++---------- 1 file changed, 87 insertions(+), 99 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index 6a54ec93d..a341f333a 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -15,7 +15,7 @@ - No `unwrap()`/`panic!`/`println!`/`eprintln!` in non-test code: use `expect("should …")` only where truly infallible, `error-stack` `Report` for fallible paths, `log::*` for instrumentation, and a single binary-scoped output helper (`#![allow(clippy::print_stdout)]` only in that helper module) for user-facing stdout. - Errors: concrete enums with `derive_more::Display` + `impl core::error::Error`; `ensure!`/`bail!`; `change_context`/`attach`. Import `Error` from `core::error`. - Example/fictional data only in tests/docs (e.g. `www.example-publisher.com`, `*.edgecompute.app`, `example.com`). No real domains/credentials. -- Default `Host` upstream is **`FROM`** (preserve production host); bare `--rewrite-host` sends `Host = TO`; `--rewrite-host ` sends `Host = SNI = ` (so `TO` may be a bare IP). SNI is the `TO` host (or the explicit ``), **port stripped**. +- Default `Host` upstream is **`FROM`** (preserve production host); `--rewrite-host` sends `Host = TO`. SNI is always the `TO` host, **port stripped**. To reach a server by IP, keep `TO` a hostname and pin its connection with `--resolve HOST:IP`. - Proxy binds **loopback only** unless `--allow-non-loopback`; off loopback, unmatched `CONNECT` is refused `403` (never blind-tunneled). - CA: CN `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION`; key file `0600`, dir `0700`; never committed; leaf SAN = host, validity ≤ 90 days; ALPN `http/1.1`. - Commit after every green step. Commit subjects: sentence case, imperative, no semantic prefixes, no AI bylines. @@ -302,9 +302,9 @@ pub struct ProxyArgs { #[arg(short = 'f', long = "from", value_name = "HOST")] pub from: Option, - /// Shorthand single-rule TO (`HOST[:PORT]` or `IP[:PORT]`; pairs with - /// `--from`). When TO is a bare IP, pass `--rewrite-host ` so the TLS - /// SNI and `Host` header target the right vhost. + /// Shorthand single-rule TO (`HOST[:PORT]`; pairs with `--from`). Keep it a + /// hostname so the TLS SNI/certificate stay valid; pin a connection address + /// with `--resolve` to reach a specific server by IP. #[arg(short = 't', long = "to", value_name = "HOST[:PORT]")] pub to: Option, @@ -320,12 +320,17 @@ pub struct ProxyArgs { #[arg(long, value_name = "LIST")] pub launch: Option, - /// Rewrite the upstream `Host` header (and TLS SNI). Omit to keep the - /// default `Host: `; bare `--rewrite-host` sends `Host: `; - /// `--rewrite-host ` sends `Host: ` and uses `` for SNI - /// (needed when `--to` is a bare IP address). - #[arg(long, value_name = "HOST", num_args = 0..=1)] - pub rewrite_host: Option>, + /// Send `Host: ` upstream instead of the default ``. The TLS SNI is + /// always the `--to` host; to reach a server by address, pin it with + /// `--resolve`. + #[arg(long)] + pub rewrite_host: bool, + + /// Pin a host's upstream connection to an address instead of DNS (repeatable; + /// like curl's `--resolve`). Keeps `--to` a hostname while the socket dials + /// the given IP. Format: `HOST:IP`. + #[arg(long = "resolve", value_name = "HOST:IP")] + pub resolve: Vec, /// Inject `Authorization: Basic …` (convenience only — visible in `ps`). #[arg(long, value_name = "USER:PASS")] @@ -429,8 +434,7 @@ Pure logic, no I/O. Implements spec §8.1–§8.4. This is the most heavily unit - Produces: - `struct Authority { host: String, port: u16, default_port: u16 }` with `fn host(&self) -> &str`, `fn is_default_port(&self) -> bool` (port equals the scheme default it was parsed with), `fn host_with_port(&self) -> String` (host, plus `:port` only when non-default), `fn parse(raw: &str, plaintext: bool) -> Result`. - - `enum HostMode { PreserveFrom, UseTo, Explicit(String) }` — how a rule derives the upstream `Host` header and TLS SNI. - - `struct Rule { from: String, to: Authority, host_mode: HostMode, plaintext: bool }`. + - `struct Rule { from: String, to: Authority, rewrite_host: bool, plaintext: bool }` — `rewrite_host` false (default) sends `Host: FROM`, true sends `Host: TO`; SNI is always the `TO` host. - `struct RuleTable(Vec)` with `fn first_match(&self, host: &str) -> Option<&Rule>`. - `struct RewriteOutcome { sni: String, host_header: String, orig_host: String, scheme_is_tls: bool }`. - `fn rewrite_for(rule: &Rule) -> RewriteOutcome`. @@ -443,11 +447,11 @@ Pure logic, no I/O. Implements spec §8.1–§8.4. This is the most heavily unit mod tests { use super::*; - fn rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Rule { + fn rule(from: &str, to: &str, rewrite_host: bool, plaintext: bool) -> Rule { Rule { from: from.to_string(), to: Authority::parse(to, plaintext).expect("should parse authority"), - host_mode, + rewrite_host, plaintext, } } @@ -491,7 +495,7 @@ mod tests { #[test] fn matching_is_case_insensitive_and_port_stripped() { - let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", HostMode::PreserveFrom, false)]); + let table = RuleTable(vec![rule("www.example-publisher.com", "to.edgecompute.app", false, false)]); let m = table.first_match("WWW.Example-Publisher.COM:443").expect("should match"); assert_eq!(m.from, "www.example-publisher.com", "match ignores case and port"); assert!(table.first_match("other.example.com").is_none(), "unmatched host returns None"); @@ -500,15 +504,15 @@ mod tests { #[test] fn first_match_wins() { let table = RuleTable(vec![ - rule("a.example.com", "first.edgecompute.app", HostMode::PreserveFrom, false), - rule("a.example.com", "second.edgecompute.app", HostMode::PreserveFrom, false), + rule("a.example.com", "first.edgecompute.app", false, false), + rule("a.example.com", "second.edgecompute.app", false, false), ]); assert_eq!(table.first_match("a.example.com").expect("should match").to.host(), "first.edgecompute.app"); } #[test] fn rewrite_default_preserves_from_host_and_sets_sni_to_to() { - let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", HostMode::PreserveFrom, false); + let r = rule("www.example-publisher.com", "to.edgecompute.app:8443", false, false); let out = rewrite_for(&r); assert_eq!(out.sni, "to.edgecompute.app", "SNI is TO host only, no port"); assert_eq!(out.host_header, "www.example-publisher.com", "default Host is FROM"); @@ -517,22 +521,12 @@ mod tests { #[test] fn rewrite_host_uses_to_authority_with_port() { - let r = rule("www.example-publisher.com", "localhost:3000", HostMode::UseTo, true); + let r = rule("www.example-publisher.com", "localhost:3000", true, true); let out = rewrite_for(&r); assert_eq!(out.sni, "localhost", "SNI never carries a port"); assert_eq!(out.host_header, "localhost:3000", "rewrite-host sends TO host:port"); assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); } - - #[test] - fn explicit_rewrite_host_overrides_host_and_sni_for_ip_upstream() { - // TO is a bare IP; an explicit --rewrite-host drives both Host and SNI. - let r = rule("www.example-publisher.com", "192.0.2.10", HostMode::Explicit("app.edgecompute.app".to_string()), false); - let out = rewrite_for(&r); - assert_eq!(out.sni, "app.edgecompute.app", "SNI is the explicit host, not the IP"); - assert_eq!(out.host_header, "app.edgecompute.app", "Host header is the explicit host"); - assert_eq!(out.orig_host, "www.example-publisher.com", "X-Orig-Host stays FROM"); - } } ``` @@ -619,29 +613,17 @@ impl Authority { } } -/// How a rule derives the upstream `Host` header and TLS SNI (spec §8.3). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum HostMode { - /// Default: send `Host: FROM` (preserve the production host); SNI is the - /// `TO` host. - PreserveFrom, - /// Bare `--rewrite-host`: send `Host: TO`; SNI is the `TO` host. - UseTo, - /// `--rewrite-host `: send `Host: ` and present `` as the - /// TLS SNI, while still connecting to `TO`. Lets `TO` be a bare IP address - /// whose endpoint routes and serves certificates by hostname. - Explicit(String), -} - /// A single rewrite rule. #[derive(Debug, Clone)] pub struct Rule { /// Production hostname to match (stored lowercase, port-stripped). pub from: String, - /// Upstream connection target (a hostname or a bare IP address). + /// Upstream target — kept a hostname so the SNI/certificate stay valid; the + /// actual connection address may be pinned via `--resolve`. pub to: Authority, - /// How the upstream `Host` header and TLS SNI are derived. - pub host_mode: HostMode, + /// `--rewrite-host`: when true, send `Host: TO`; otherwise (default) send + /// `Host: FROM`. The TLS SNI is always the `TO` host either way. + pub rewrite_host: bool, /// Connect to the upstream over plaintext HTTP. pub plaintext: bool, } @@ -666,8 +648,7 @@ impl RuleTable { /// The header/SNI decisions for a matched rule. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RewriteOutcome { - /// SNI to present upstream — the `TO` host, or the explicit - /// `--rewrite-host` value; never carries a port. + /// SNI to present upstream — always the `TO` host; never carries a port. pub sni: String, /// Value for the upstream `Host` header. pub host_header: String, @@ -680,15 +661,15 @@ pub struct RewriteOutcome { /// Computes the rewrite outcome for a matched rule (spec §8.3). #[must_use] pub fn rewrite_for(rule: &Rule) -> RewriteOutcome { - // `Host` and SNI are independent of the connection target (the caller dials - // `rule.to`), so an explicit host overrides both while `TO` can stay an IP. - let (host_header, sni) = match &rule.host_mode { - HostMode::PreserveFrom => (rule.from.clone(), rule.to.host().to_string()), - HostMode::UseTo => (rule.to.host_with_port(), rule.to.host().to_string()), - HostMode::Explicit(host) => (host.clone(), host.clone()), + // SNI is always the TO host (the connection address may be pinned separately + // via `--resolve`); only the `Host` header depends on `rewrite_host`. + let host_header = if rule.rewrite_host { + rule.to.host_with_port() + } else { + rule.from.clone() }; RewriteOutcome { - sni, + sni: rule.to.host().to_string(), host_header, orig_host: rule.from.clone(), scheme_is_tls: !rule.plaintext, @@ -728,7 +709,7 @@ Turns `ProxyArgs` into a `ResolvedConfig` holding a `RuleTable` and effective se - Consumes: `ProxyArgs` (Task 1), `Rule`/`RuleTable`/`Authority`/`RuleError` (Task 2). - Produces: - - `struct ResolvedConfig { rules: RuleTable, listen: SocketAddr, allow_non_loopback: bool, launch: Vec, insecure: bool, basic_auth: Option, ca_dir: PathBuf }`. + - `struct ResolvedConfig { rules: RuleTable, listen: SocketAddr, allow_non_loopback: bool, launch: Vec, insecure: bool, basic_auth: Option, ca_dir: PathBuf, resolve: HashMap }`. - `struct BasicAuth { user: String, pass: String }` with `fn header_value(&self) -> String` (returns `Basic base64(user:pass)`). - `enum Browser { Chrome, Firefox, Safari }` with `fn parse_list(raw: &str) -> Result, ConfigError>`. - `fn resolve(args: &ProxyArgs) -> error_stack::Result`. @@ -751,39 +732,38 @@ mod tests { } #[test] - fn single_rule_from_to_defaults_to_preserve_host() { + fn single_rule_from_to_keeps_from_host_by_default() { let mut args = base_args(); args.from = Some("www.example-publisher.com".into()); args.to = Some("to.edgecompute.app".into()); let cfg = resolve(&args).expect("should resolve"); let rule = cfg.rules.first_match("www.example-publisher.com").expect("rule present"); - assert_eq!(rule.host_mode, HostMode::PreserveFrom, "default preserves FROM host"); + assert!(!rule.rewrite_host, "default preserves FROM host"); assert_eq!(rule.to.host(), "to.edgecompute.app"); } #[test] - fn bare_rewrite_host_uses_to() { + fn rewrite_host_uses_to() { let mut args = base_args(); args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - // Bare `--rewrite-host` (present, no value) parses to `Some(None)`. - args.rewrite_host = Some(None); + args.rewrite_host = true; let cfg = resolve(&args).expect("should resolve"); - assert_eq!( - cfg.rules.first_match("www.example-publisher.com").expect("rule").host_mode, - HostMode::UseTo, - "bare --rewrite-host sends Host: TO", + assert!( + cfg.rules.first_match("www.example-publisher.com").expect("rule").rewrite_host, + "--rewrite-host sends Host: TO", ); } #[test] - fn rewrite_host_with_value_is_explicit_for_ip_to() { + fn resolve_pins_host_to_ip() { let mut args = base_args(); - args.map = vec!["www.example-publisher.com=192.0.2.10".into()]; - args.rewrite_host = Some(Some("app.edgecompute.app".into())); + args.map = vec!["www.example-publisher.com=ts.edgecompute.app".into()]; + args.resolve = vec!["ts.edgecompute.app:192.0.2.10".into()]; let cfg = resolve(&args).expect("should resolve"); assert_eq!( - cfg.rules.first_match("www.example-publisher.com").expect("rule").host_mode, - HostMode::Explicit("app.edgecompute.app".to_string()), + cfg.resolve.get("ts.edgecompute.app"), + Some(&"192.0.2.10".parse().expect("ipv4")), + "--resolve pins the lowercased host to the address", ); } @@ -829,6 +809,7 @@ Expected: FAIL to compile. ```rust //! Resolves `ProxyArgs` (+ env, defaults) into a concrete [`ResolvedConfig`]. +use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -836,7 +817,7 @@ use base64::Engine as _; use error_stack::{Report, ResultExt as _}; use super::ProxyArgs; -use super::rewrite::{Authority, HostMode, Rule, RuleTable}; +use super::rewrite::{Authority, Rule, RuleTable}; /// Errors from configuration resolution. #[derive(Debug, derive_more::Display)] @@ -850,9 +831,9 @@ pub enum ConfigError { /// A rule `FROM` value was not a bare hostname. #[display("invalid FROM host `{value}` (expected a hostname: letters, digits, '-', '.')")] InvalidFrom { value: String }, - /// The `--rewrite-host ` value was not a valid hostname. - #[display("invalid --rewrite-host `{value}` (expected a hostname: letters, digits, '-', '.')")] - InvalidRewriteHost { value: String }, + /// A `--resolve` value was not `HOST:IP` with a valid hostname and IP. + #[display("invalid --resolve `{value}` (expected HOST:IP, e.g. ts.example.com:192.0.2.10)")] + Resolve { value: String }, /// `--listen` was not a valid socket address. #[display("invalid --listen address `{value}`")] Listen { value: String }, @@ -931,6 +912,8 @@ pub struct ResolvedConfig { pub insecure: bool, pub basic_auth: Option, pub ca_dir: PathBuf, + /// `--resolve` DNS pins: lowercase hostname → connection address. + pub resolve: HashMap, } /// Default CA directory (spec §7.1/§12): `$XDG_DATA_HOME/trusted-server/dev-proxy`, @@ -956,37 +939,40 @@ pub fn ca_dir(args: &ProxyArgs) -> PathBuf { args.ca_dir.as_ref().map_or_else(default_ca_dir, PathBuf::from) } -/// Resolves the `--rewrite-host` flag into a [`HostMode`] applied to every rule: -/// absent → preserve `FROM`; bare → use `TO`; with a value → that explicit host -/// (validated, lowercased) for both the `Host` header and the TLS SNI. -fn host_mode(args: &ProxyArgs) -> Result { - match &args.rewrite_host { - None => Ok(HostMode::PreserveFrom), - Some(None) => Ok(HostMode::UseTo), - Some(Some(host)) => { - let host = host.to_ascii_lowercase(); - if !is_valid_host(&host) { - return Err(ConfigError::InvalidRewriteHost { value: host }); - } - Ok(HostMode::Explicit(host)) - } - } -} - fn build_rules(args: &ProxyArgs) -> Result { let mut rules = Vec::new(); - let mode = host_mode(args)?; + // `--rewrite-host` only chooses the `Host` header; the SNI always follows TO. for entry in &args.map { let (from, to) = entry.split_once('=').ok_or(ConfigError::Rule)?; - rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); + rules.push(make_rule(from, to, args.rewrite_host, args.upstream_plaintext)?); } if let (Some(from), Some(to)) = (&args.from, &args.to) { - rules.push(make_rule(from, to, mode.clone(), args.upstream_plaintext)?); + rules.push(make_rule(from, to, args.rewrite_host, args.upstream_plaintext)?); } Ok(RuleTable(rules)) } -fn make_rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Result { +/// Parses `--resolve HOST:IP` entries into a lowercase-host → address map. +/// Splits on the first `:` so the (possibly IPv6) address keeps its colons. +fn build_resolve(args: &ProxyArgs) -> Result, ConfigError> { + let mut map = HashMap::new(); + for entry in &args.resolve { + let (host, ip) = entry.split_once(':').ok_or_else(|| ConfigError::Resolve { + value: entry.clone(), + })?; + let host = host.to_ascii_lowercase(); + let ip: IpAddr = ip.parse().map_err(|_| ConfigError::Resolve { + value: entry.clone(), + })?; + if !is_valid_host(&host) { + return Err(ConfigError::Resolve { value: entry.clone() }); + } + map.insert(host, ip); + } + Ok(map) +} + +fn make_rule(from: &str, to: &str, rewrite_host: bool, plaintext: bool) -> Result { let to = Authority::parse(to, plaintext).map_err(|_| ConfigError::Rule)?; let from = from.to_ascii_lowercase(); // FROM is interpolated into the generated PAC JavaScript and matched against @@ -995,7 +981,7 @@ fn make_rule(from: &str, to: &str, host_mode: HostMode, plaintext: bool) -> Resu if !is_valid_host(&from) { return Err(ConfigError::InvalidFrom { value: from }); } - Ok(Rule { from, to, host_mode, plaintext }) + Ok(Rule { from, to, rewrite_host, plaintext }) } /// Returns whether `host` is a plausible bare hostname (letters, digits, `-`, @@ -1036,6 +1022,7 @@ pub fn resolve(args: &ProxyArgs) -> error_stack::Result error_stack::Result Date: Wed, 24 Jun 2026 14:01:02 -0700 Subject: [PATCH 37/40] Apply --resolve to every upstream connection, log pins at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DNS pin only covered the matched-rule MITM upstream, so a CONNECT to a host that isn't a configured --from (a directly-hit host, a sub-resource) still went through the blind tunnel via real DNS and ignored the pin. Route all three connection paths (MITM, blind tunnel, plain-HTTP forward) through a shared `connect_upstream` helper that honors the pin case-insensitively. Also log each pin at startup (`--resolve pin: HOST -> IP`) so it's visible at the default info level — the per-connection summary shows the hostname (SNI/cert use it) even though the socket dials the pinned IP. --- .../src/commands/dev/proxy/server.rs | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 52b2b1067..37d34536c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -57,6 +57,9 @@ pub async fn serve_on( ) -> Result<(), Report> { let is_loopback = is_loopback(cfg.listen.ip()); log::info!("listening on {}", cfg.listen); + for (host, ip) in &cfg.resolve { + log::info!("--resolve pin: {host} -> {ip}"); + } loop { let (client, peer) = match listener.accept().await { Ok(pair) => pair, @@ -161,7 +164,7 @@ async fn handle_connection( } // Stray absolute-form plain HTTP. if is_loopback { - blind_forward_http(client, &head).await + blind_forward_http(client, &head, &cfg.resolve).await } else { respond_status_line(&mut client, StatusCode::FORBIDDEN).await } @@ -196,7 +199,26 @@ async fn handle_connect( } // No match on loopback: connect upstream FIRST, then reply 200 (else 502). - blind_tunnel(client, &host, port).await + blind_tunnel(client, &host, port, &cfg.resolve).await +} + +/// Opens an upstream TCP connection, honoring a `--resolve` pin for `host`. +/// +/// When `host` (matched case-insensitively) has a pin, the socket dials that IP +/// instead of resolving the name via DNS. The TLS SNI / `Host` set by the caller +/// are unaffected, so the certificate still validates against the hostname. +async fn connect_upstream( + host: &str, + port: u16, + resolve: &HashMap, +) -> std::io::Result { + if !resolve.is_empty() + && let Some(ip) = resolve.get(&host.to_ascii_lowercase()) + { + log::debug!("--resolve {host}:{port} -> {ip}:{port}"); + return TcpStream::connect((*ip, port)).await; + } + TcpStream::connect((host, port)).await } /// Connects to the upstream first; on success replies `200` then pipes bytes @@ -205,8 +227,9 @@ async fn blind_tunnel( mut client: TcpStream, host: &str, port: u16, + resolve: &HashMap, ) -> Result<(), Report> { - let mut upstream = match TcpStream::connect((host, port)).await { + let mut upstream = match connect_upstream(host, port, resolve).await { Ok(stream) => stream, Err(err) => { log::warn!("blind tunnel to {host}:{port} failed: {err}"); @@ -267,6 +290,7 @@ async fn serve_pac(client: &mut TcpStream, pac: &str) -> Result<(), Report, ) -> Result<(), Report> { let Ok(uri) = head.target.parse::() else { return respond_status_line(&mut client, StatusCode::BAD_REQUEST).await; @@ -275,7 +299,7 @@ async fn blind_forward_http( return respond_status_line(&mut client, StatusCode::BAD_REQUEST).await; }; let port = uri.port_u16().unwrap_or(80); - let mut upstream = match TcpStream::connect((host, port)).await { + let mut upstream = match connect_upstream(host, port, resolve).await { Ok(stream) => stream, Err(err) => { log::warn!("plain-HTTP forward to {host}:{port} failed: {err}"); @@ -432,14 +456,9 @@ async fn proxy_to_upstream( // Dial the `--resolve` pin when the upstream host has one; the SNI/`Host` // (set above) stay the hostname, so the certificate still validates. - let tcp = match resolve.get(upstream_host) { - Some(ip) => { - log::debug!("--resolve {upstream_host} -> {ip}"); - TcpStream::connect((*ip, upstream_port)).await - } - None => TcpStream::connect((upstream_host, upstream_port)).await, - } - .change_context(ProxyError::Server)?; + let tcp = connect_upstream(upstream_host, upstream_port, resolve) + .await + .change_context(ProxyError::Server)?; let response = if outcome.scheme_is_tls { let connector = TlsConnector::from(client_config(insecure)); From 9be877de96f36658c41d9574c0f0d40f397a316a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:19:47 -0700 Subject: [PATCH 38/40] Send X-Forwarded-Host: FROM so first-party URLs stay on the production host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trusted Server derives request_host from X-Forwarded-Host (then Host) and anchors all first-party URL rewriting to it. The proxy now always sends X-Forwarded-Host: FROM — standard forward-proxy behavior — so TS emits production-host URLs (tsjs, GPT, DataDome, …) even when --rewrite-host sends Host: TO for an upstream that routes/validates on its own hostname. This decouples routing (Host) from the displayed first-party host: use --rewrite-host freely for host-validating upstreams without skewing the rewritten URLs onto TO. Adds an e2e asserting Host=TO + X-Forwarded-Host=FROM under --rewrite-host. Spec and guide updated (the earlier "avoid --rewrite-host for TS" guidance no longer applies). --- .../src/commands/dev/proxy/server.rs | 12 ++- crates/trusted-server-cli/tests/proxy_e2e.rs | 28 ++++++ .../trusted-server-cli/tests/support/mod.rs | 31 +++++-- docs/guide/ts-dev-proxy.md | 58 ++++++------ .../specs/2026-06-22-ts-dev-proxy-design.md | 88 ++++++++++--------- 5 files changed, 137 insertions(+), 80 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 37d34536c..a33d5ec5a 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -30,6 +30,7 @@ use super::config::ResolvedConfig; use super::rewrite::rewrite_for; const X_ORIG_HOST: &str = "x-orig-host"; +const X_FORWARDED_HOST: &str = "x-forwarded-host"; /// Binds the listen socket. Separate from [`serve_on`] so the caller can open /// the port (queueing connections) before launching browsers (spec §9, Task 6). @@ -498,9 +499,9 @@ where .change_context(ProxyError::Server) } -/// Applies the rewrite outcome: upstream `Host`, `X-Orig-Host`, and (only when -/// absent) the injected `Authorization`. The request URI is left origin-form, -/// which is what an HTTP/1.1 upstream expects. +/// Applies the rewrite outcome: upstream `Host`, `X-Forwarded-Host`/`X-Orig-Host` +/// (both `FROM`), and (only when absent) the injected `Authorization`. The request +/// URI is left origin-form, which is what an HTTP/1.1 upstream expects. fn rewrite_headers( headers: &mut hyper::HeaderMap, outcome: &super::rewrite::RewriteOutcome, @@ -509,7 +510,12 @@ fn rewrite_headers( if let Ok(value) = HeaderValue::from_str(&outcome.host_header) { headers.insert(hyper::header::HOST, value); } + // Tell the upstream the original first-party host (always `FROM`). Trusted + // Server anchors its URL rewriting to `X-Forwarded-Host` (then `Host`), so + // this keeps emitted first-party URLs on the production host even when + // `--rewrite-host` sends `Host: TO` for routing/validation (spec §8.3). if let Ok(value) = HeaderValue::from_str(&outcome.orig_host) { + headers.insert(HeaderName::from_static(X_FORWARDED_HOST), value.clone()); headers.insert(HeaderName::from_static(X_ORIG_HOST), value); } if let Some(auth) = basic_auth diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index e04261dd9..bd1bbe00d 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -34,6 +34,34 @@ async fn matched_host_is_rewritten_and_forwarded() { support::FROM_HOST, "X-Orig-Host is FROM" ); + assert_eq!( + response.seen_forwarded_host, + support::FROM_HOST, + "X-Forwarded-Host is FROM" + ); +} + +#[tokio::test] +async fn rewrite_host_keeps_forwarded_host_on_from() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_rewrite_host(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let response = support::drive_request_through_proxy(cfg, ca).await; + + assert_eq!(response.status, 200, "response streamed back"); + assert_eq!( + response.seen_host, + upstream.addr.to_string(), + "--rewrite-host sends Host: TO" + ); + // The point: TS anchors URL rewriting to X-Forwarded-Host, so it stays FROM + // even though Host is TO — keeping emitted first-party URLs on the prod host. + assert_eq!( + response.seen_forwarded_host, + support::FROM_HOST, + "X-Forwarded-Host stays FROM even with --rewrite-host" + ); } #[tokio::test] diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index 27e7593c1..a0f66094e 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -22,6 +22,7 @@ pub struct ProxiedResponse { pub status: u16, pub seen_host: String, pub seen_orig_host: String, + pub seen_forwarded_host: String, pub path: String, } @@ -54,6 +55,21 @@ pub fn test_config(addr: &SocketAddr) -> config::ResolvedConfig { resolve(&["ts", "--map", &map, "--listen", "127.0.0.1:0", "--insecure"]) } +/// Like [`test_config`] but with `--rewrite-host`, so the upstream sees +/// `Host: ` while `X-Forwarded-Host` stays `FROM`. +pub fn test_config_rewrite_host(addr: &SocketAddr) -> config::ResolvedConfig { + let map = format!("{FROM_HOST}={}", addr); + resolve(&[ + "ts", + "--map", + &map, + "--rewrite-host", + "--listen", + "127.0.0.1:0", + "--insecure", + ]) +} + /// Builds a config whose TO host is a **non-resolvable** name (`pinned.invalid`) /// pinned to the upstream `addr` via `--resolve`. The request only reaches the /// upstream if the pin is honored — DNS for `.invalid` never resolves. @@ -199,12 +215,13 @@ where .to_string(); let host = header_value(&head, "host").unwrap_or_default(); let orig_host = header_value(&head, "x-orig-host").unwrap_or_default(); + let fwd_host = header_value(&head, "x-forwarded-host").unwrap_or_default(); let has_auth = header_value(&head, "authorization").is_some(); let (status_line, body) = if gated && !has_auth { ("HTTP/1.1 401 Unauthorized", String::new()) } else { - let body = format!("host={host};orig={orig_host};path={path}"); + let body = format!("host={host};orig={orig_host};fwd={fwd_host};path={path}"); ("HTTP/1.1 200 OK", body) }; let response = format!( @@ -497,30 +514,34 @@ where body.extend_from_slice(&chunk[..n]); } let body = String::from_utf8_lossy(&body[..content_length.min(body.len())]).to_string(); - let (seen_host, seen_orig_host, path) = parse_echo(&body); + let (seen_host, seen_orig_host, seen_forwarded_host, path) = parse_echo(&body); ProxiedResponse { status, seen_host, seen_orig_host, + seen_forwarded_host, path, } } -/// Parses `host=..;orig=..;path=..` echoed by the upstream. -fn parse_echo(body: &str) -> (String, String, String) { +/// Parses `host=..;orig=..;fwd=..;path=..` echoed by the upstream. +fn parse_echo(body: &str) -> (String, String, String, String) { let mut host = String::new(); let mut orig = String::new(); + let mut fwd = String::new(); let mut path = String::new(); for field in body.split(';') { if let Some(v) = field.strip_prefix("host=") { host = v.to_string(); } else if let Some(v) = field.strip_prefix("orig=") { orig = v.to_string(); + } else if let Some(v) = field.strip_prefix("fwd=") { + fwd = v.to_string(); } else if let Some(v) = field.strip_prefix("path=") { path = v.to_string(); } } - (host, orig, path) + (host, orig, fwd, path) } /// CONNECTs through the proxy to an UNMATCHED authority, completes the TLS diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index e7a944dc2..992375fae 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -168,13 +168,14 @@ trust the new CA. ## Host header behavior -By default the proxy sends `Host: ` (the production hostname) to the -upstream. This is required for Trusted Server core to rewrite first-party URLs -correctly: it anchors all HTML/URL rewriting to the inbound `Host`, so keeping -`Host = FROM` ensures rewritten links stay on the production domain. +The proxy always sends `X-Forwarded-Host: ` (the production hostname) — the +standard "original host" header for a forward proxy. Trusted Server core anchors +all HTML/URL rewriting to it (it prefers `X-Forwarded-Host`, then `Host`), so +**first-party URLs always stay on the production domain regardless of the `Host` +header**. That decouples routing (`Host`) from the first-party host. -This works well against a Trusted Server Compute upstream because Fastly routes -by SNI (`= TO`) and passes `Host` through to the application unchanged. +By default `Host: ` too, which works against a Trusted Server Compute +upstream because Fastly routes by SNI (`= TO`) and passes `Host` through unchanged. **Targeting a specific server by IP.** To point at a particular server or load balancer — for example when the `TO` hostname isn't in DNS yet — keep `--to` a @@ -189,29 +190,28 @@ ts dev proxy \ --launch chrome ``` -The proxy dials `192.0.2.10` while the SNI and `Host` stay -`ts.example-publisher.com` (SNI) and `www.example-publisher.com` (`Host = FROM`, -the default) — so TS still rewrites first-party URLs onto the production domain. -This keeps the tool self-contained — no `/etc/hosts` edit. (Pointing `--to` at a -bare IP instead would make the SNI an IP, which sends no SNI extension at all, so -a host-routed endpoint serves its default vhost.) Add `--insecure` if the -endpoint serves a certificate that doesn't match the hostname. - -**Sending `Host: TO` instead.** Only if your upstream is **not** a Trusted Server -and routes/validates on its _own_ hostname, pass `--rewrite-host` to send -`Host: `. Avoid it for TS upstreams: TS anchors first-party URL rewriting to -the inbound `Host`, so `Host = TO` rewrites links onto the `TO` host. The TLS SNI -is always the `TO` host either way: - -| Form | `Host` header | TLS SNI | -| ---------------- | ------------- | --------- | -| _(omitted)_ | `FROM` | `TO` host | -| `--rewrite-host` | `TO` host | `TO` host | - -An `X-Orig-Host: ` header is always sent informally. **Port handling:** with -`--rewrite-host` and a non-default `TO` port (e.g. `localhost:3000`), the port is -included in the `Host` header but never in the SNI (a bare hostname; a port in SNI -is invalid). +The proxy dials `192.0.2.10` while the SNI stays `ts.example-publisher.com` and +`X-Forwarded-Host` stays `www.example-publisher.com` — so TS rewrites first-party +URLs onto the production domain. This keeps the tool self-contained — no +`/etc/hosts` edit. (Pointing `--to` at a bare IP instead would make the SNI an IP, +which sends no SNI extension at all, so a host-routed endpoint serves its default +vhost.) Add `--insecure` if the endpoint serves a certificate that doesn't match +the hostname. + +**Sending `Host: TO`.** If your upstream routes or validates on its _own_ +hostname (e.g. a Fastly Deliver service that rejects an unconfigured `Host`), pass +`--rewrite-host` to send `Host: `. First-party URLs **still stay on `FROM`** +because `X-Forwarded-Host` anchors them — so this is safe with Trusted Server +upstreams too. The TLS SNI is always the `TO` host either way: + +| Form | `Host` header | `X-Forwarded-Host` | TLS SNI | +| ---------------- | ------------- | ------------------ | --------- | +| _(omitted)_ | `FROM` | `FROM` | `TO` host | +| `--rewrite-host` | `TO` host | `FROM` | `TO` host | + +**Port handling:** with `--rewrite-host` and a non-default `TO` port (e.g. +`localhost:3000`), the port is included in the `Host` header but never in the SNI +(a bare hostname; a port in SNI is invalid). ## Non-loopback listen diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 4d07cf9bc..679008ba2 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -93,17 +93,17 @@ satisfies **HSTS**, which an "ignored" cert does not. Resolved during brainstorming and design review (2026-06-22): -| Decision | Choice | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | -| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | -| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | -| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | -| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | -| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | -| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | -| Default `Host` | `Host = FROM` (preserve the production host) — required because TS core anchors URL rewriting to the inbound `Host`. `--rewrite-host` sends `Host = TO`. The SNI is always the `TO` host; to reach a server by IP, keep `TO` a hostname and pin it with `--resolve`. `X-Orig-Host` is informational (§8.3). | -| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | +| Decision | Choice | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Host / first-party | First-party host is anchored to `FROM` via an always-sent `X-Forwarded-Host: FROM` (TS prefers it over `Host` for URL rewriting). `Host = FROM` by default; `--rewrite-host` sends `Host = TO` for host-validating upstreams without moving first-party URLs off `FROM`. SNI is always the `TO` host; reach a server by IP with `--resolve` (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | --- @@ -359,32 +359,36 @@ are instead refused with `403` (§11), never blind-tunneled. ### 8.3 Header rewriting on match -| Header | Action | Rationale | -| ----------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| upstream **connection** | the `--resolve` pin for `rule.to` host if present, else `rule.to` host via DNS; + `rule.to` port | lets `TO` stay a hostname (valid SNI/cert) while the socket targets a chosen IP | -| **SNI** | `rule.to` host; **port stripped** | always the `TO` hostname; a `:port` or bare IP in SNI is invalid/unroutable, so keep `TO` a hostname | -| `Host` | `rule.from` (default); `rule.to` with `--rewrite-host` | TS core anchors URL rewriting to the inbound `Host`; preserving `FROM` keeps rewritten URLs on the production domain (see caveats) | -| `X-Orig-Host` | `rule.from` | informational record of the real first-party host (see caveat) | -| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | -| `Proxy-Connection` | removed | hop-by-hop hygiene | - -**Why `Host = FROM` is the default (resolved).** The §1 goal — validate cookies, -`Host`-sensitive logic, CMP/consent, and first-party context at the _real_ -domain — requires the upstream to see `Host = FROM`. Trusted Server core derives -`request_host` from the inbound `Host` (`RequestInfo::from_request` in -`http_util.rs`) and anchors all HTML/RSC URL rewriting to it -(`request_url = "{scheme}://{request_host}"` and `rewrite_bare_host_at_boundaries` -in `publisher.rs` / `rsc_flight.rs`). With `Host = TO` the app would rewrite every -first-party URL onto the Compute/staging host — wrong for the primary use case — -so the default preserves `FROM`. - -This works against a TS **Compute** upstream because Fastly routes by SNI -(`= TO`, a domain provisioned on that service) and passes `Host` through to the -program unchecked. A Fastly **Deliver** / host-validating upstream may reject an -unconfigured `Host` ("unknown domain"); and because a domain can be active on -only one service (§2 ¶3), you cannot add the live production domain to a separate -dev service. For those upstreams, pass `--rewrite-host` (sends `Host = TO`) or add -the domain to the service. +| Header | Action | Rationale | +| ----------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| upstream **connection** | the `--resolve` pin for `rule.to` host if present, else `rule.to` host via DNS; + `rule.to` port | lets `TO` stay a hostname (valid SNI/cert) while the socket targets a chosen IP | +| **SNI** | `rule.to` host; **port stripped** | always the `TO` hostname; a `:port` or bare IP in SNI is invalid/unroutable, so keep `TO` a hostname | +| `Host` | `rule.from` (default); `rule.to` with `--rewrite-host` | the upstream's routing/validation host | +| `X-Forwarded-Host` | `rule.from` (always) | the original first-party host; TS core prefers it over `Host` for `request_host`, so first-party URLs stay on `FROM` even with `--rewrite-host` | +| `X-Orig-Host` | `rule.from` | informational duplicate of the original first-party host | +| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | +| `Proxy-Connection` | removed | hop-by-hop hygiene | + +**First-party host is anchored to `FROM` via `X-Forwarded-Host`.** The §1 goal — +validate cookies, `Host`-sensitive logic, CMP/consent, and first-party context at +the _real_ domain — requires TS to treat `FROM` as the first-party host. Trusted +Server core derives `request_host` from `Forwarded` → `X-Forwarded-Host` → `Host` +(`extract_request_host` in `http_util.rs`) and anchors all HTML/RSC URL rewriting +to it (`request_url = "{scheme}://{request_host}"` and +`rewrite_bare_host_at_boundaries` in `publisher.rs` / `rsc_flight.rs`). The proxy +therefore **always sends `X-Forwarded-Host: FROM`** — standard forward-proxy +behavior — so `request_host = FROM` regardless of the routing `Host`. (Note: +`sanitize_forwarded_headers` would strip this at a hardened edge, but it is not +wired into the current request flow, so TS honors the inbound value.) + +This decouples routing from the first-party host: `Host` is free to be whatever +the upstream needs. The default `Host = FROM` works against a TS **Compute** +upstream (Fastly routes by SNI `= TO` and passes `Host` through). A Fastly +**Deliver** / host-validating upstream may reject an unconfigured `Host` +("unknown domain"); and because a domain can be active on only one service +(§2 ¶3), you cannot add the live production domain to a separate dev service. For +those, pass `--rewrite-host` (sends `Host = TO`) — first-party URLs still stay on +`FROM` because `X-Forwarded-Host` anchors them. **Targeting a specific server by IP.** Keep `TO` a hostname (so the SNI and certificate stay valid) and pin its connection address with `--resolve HOST:IP` @@ -394,12 +398,10 @@ so a host-routed endpoint would serve its default vhost). Cert verification stil applies; add `--insecure` if the endpoint serves a cert that doesn't match. This keeps the tool self-contained — no `/etc/hosts` edit. -`X-Orig-Host: FROM` is still sent for upstreams that opt to honor it, but it is -**informational only**: TS core does not read it today and in fact _strips_ -spoofable forwarded host headers (`X-Forwarded-Host`, etc.) as an anti-spoofing -measure. Reconcile any future trusted-`X-Orig-Host` contract with the existing -`publisher.origin_host_header_override` knob. **Validation:** an integration test -must assert that, by default, rewritten HTML/RSC output stays on `FROM` (not `TO`). +`X-Orig-Host: FROM` is also sent as an informational duplicate (TS core does not +read it today). The functional header is `X-Forwarded-Host`. **Validation:** an +integration test asserts that with `--rewrite-host` the upstream sees `Host = TO` +while `X-Forwarded-Host = FROM` (`rewrite_host_keeps_forwarded_host_on_from`). **Port handling.** With `--rewrite-host` (`Host = TO`) and a non-default `TO` port (e.g. `localhost:3000`, `staging.example.com:8443`), the port **is** included From c9bebdddac1da23bd1a336b177625a43b8c8766b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:28:06 -0700 Subject: [PATCH 39/40] Address self-review nits in the dev proxy - rewrite: match the port-stripped host with eq_ignore_ascii_case instead of allocating a lowercased copy per rule (FROM is already stored lowercase). - config: warn when a --resolve HOST matches no rule's TO host (a likely typo whose pin would otherwise silently never apply); still succeeds. Add a test. - config: document why is_valid_host rejects underscores. - browser: correct the Linux chrome_command comment (it doesn't fall back to chromium; the arm is unreached on the macOS-only build anyway). --- .../src/commands/dev/proxy/browser.rs | 2 +- .../src/commands/dev/proxy/config.rs | 29 +++++++++++++++++++ .../src/commands/dev/proxy/rewrite.rs | 11 +++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 3f821b29c..3ee4fcfa8 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -282,7 +282,7 @@ fn chrome_command() -> Command { } #[cfg(target_os = "linux")] { - // Try google-chrome first, then chromium-browser, then chromium. + // Linux: the `google-chrome` launcher (unreached on the macOS-only build). Command::new("google-chrome") } #[cfg(not(any(target_os = "macos", target_os = "linux")))] diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index c689f7137..dc008c8c1 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -194,6 +194,10 @@ fn build_resolve(args: &ProxyArgs) -> Result, ConfigErro /// Whether `host` is a syntactically valid hostname — ASCII letters, digits, /// `-`, and `.` only — so it is safe to embed verbatim in the generated PAC /// JavaScript, the browser URL, and the upstream `Host` header. +/// +/// Underscores are intentionally rejected: they are not valid in DNS hostnames +/// and excluding them keeps the allowed set strictly safe for the contexts +/// above. A publisher host that needs `_` is out of scope for this dev tool. fn is_valid_host(host: &str) -> bool { !host.is_empty() && host.len() <= 253 @@ -259,6 +263,17 @@ pub fn resolve(args: &ProxyArgs) -> Result> let ca_dir = ca_dir(args); let resolve = build_resolve(args).map_err(Report::from)?; + // A `--resolve HOST:IP` whose HOST matches no rule's TO host is almost + // certainly a typo: the pin would silently never apply. Warn rather than + // error, so a deliberate pin for a host reached indirectly still works. + for host in resolve.keys() { + if !rules.0.iter().any(|rule| rule.to.host() == host) { + log::warn!( + "--resolve {host}:… does not match any rule's TO host; the pin will not be used" + ); + } + } + Ok(ResolvedConfig { rules, listen, @@ -378,6 +393,20 @@ mod tests { ); } + #[test] + fn resolve_host_not_matching_any_rule_warns_but_succeeds() { + let mut args = base_args(); + args.map = vec!["a.example.com=b.edgecompute.app".into()]; + // A pin for a host that is no rule's TO is a likely typo: it should warn + // (not error) and still be recorded. + args.resolve = vec!["typo.edgecompute.app:192.0.2.10".into()]; + let cfg = resolve(&args).expect("an unmatched --resolve host should warn, not error"); + assert!( + cfg.resolve.contains_key("typo.edgecompute.app"), + "the pin is recorded even when it matches no rule's TO host" + ); + } + #[test] fn resolve_rejects_malformed_value() { let mut args = base_args(); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index b964a65d9..5d2efd19e 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -109,13 +109,10 @@ impl RuleTable { /// ignoring any `:port`. #[must_use] pub fn first_match(&self, host: &str) -> Option<&Rule> { - let needle = host - .rsplit_once(':') - .map_or(host, |(h, _)| h) - .to_ascii_lowercase(); - self.0 - .iter() - .find(|r| r.from.to_ascii_lowercase() == needle) + // `from` is stored lowercase (see `Rule::from`); compare + // case-insensitively against the port-stripped host without allocating. + let needle = host.rsplit_once(':').map_or(host, |(h, _)| h); + self.0.iter().find(|r| r.from.eq_ignore_ascii_case(needle)) } } From c91abf3cd8a5fb2950690b094dbe7b965f49577d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:58:39 -0700 Subject: [PATCH 40/40] Strip inbound Forwarded and reconcile X-Forwarded-Host docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review findings on the dev proxy (the trusted-host design for --rewrite-host through the real adapter is tracked separately): - server: strip any inbound `Forwarded` before stamping `X-Forwarded-Host`. Trusted Server resolves the request host from `Forwarded` → `X-Forwarded-Host` → `Host`, so a client-supplied `Forwarded` would otherwise outrank the FROM host the proxy injects. Add a unit test. - docs: reconcile stale `X-Orig-Host`-only guidance in the spec/plan — the functional first-party-host header is `X-Forwarded-Host` (TS reads it for request_host); `X-Orig-Host` is an informational duplicate. Note the inbound-`Forwarded` strip in the spec/plan and the `RewriteOutcome` field doc. --- .../src/commands/dev/proxy/rewrite.rs | 3 +- .../src/commands/dev/proxy/server.rs | 43 ++++++++++++++++--- .../plans/2026-06-22-ts-dev-proxy.md | 19 +++++--- .../specs/2026-06-22-ts-dev-proxy-design.md | 21 +++++---- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index 5d2efd19e..2feeafa20 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -123,7 +123,8 @@ pub struct RewriteOutcome { pub sni: String, /// Value for the upstream `Host` header. pub host_header: String, - /// Value for the `X-Orig-Host` header (always FROM). + /// The original first-party host (always FROM); sent upstream as + /// `X-Forwarded-Host` (functional) and `X-Orig-Host` (informational). pub orig_host: String, /// Whether the upstream leg is TLS (`!plaintext`). pub scheme_is_tls: bool, diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index a33d5ec5a..1974a5f74 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -500,8 +500,9 @@ where } /// Applies the rewrite outcome: upstream `Host`, `X-Forwarded-Host`/`X-Orig-Host` -/// (both `FROM`), and (only when absent) the injected `Authorization`. The request -/// URI is left origin-form, which is what an HTTP/1.1 upstream expects. +/// (both `FROM`, after stripping any higher-priority inbound `Forwarded`), and +/// (only when absent) the injected `Authorization`. The request URI is left +/// origin-form, which is what an HTTP/1.1 upstream expects. fn rewrite_headers( headers: &mut hyper::HeaderMap, outcome: &super::rewrite::RewriteOutcome, @@ -511,9 +512,13 @@ fn rewrite_headers( headers.insert(hyper::header::HOST, value); } // Tell the upstream the original first-party host (always `FROM`). Trusted - // Server anchors its URL rewriting to `X-Forwarded-Host` (then `Host`), so - // this keeps emitted first-party URLs on the production host even when - // `--rewrite-host` sends `Host: TO` for routing/validation (spec §8.3). + // Server resolves the request host from `Forwarded` → `X-Forwarded-Host` → + // `Host`, so a client-supplied `Forwarded` would outrank the value we inject. + // Remove it first so the `X-Forwarded-Host` we stamp is the one core reads, + // keeping emitted first-party URLs on the production host even when + // `--rewrite-host` sends `Host: TO` for routing/validation (spec §8.3). The + // `insert`s below already overwrite any inbound `X-Forwarded-Host`/`X-Orig-Host`. + headers.remove("forwarded"); if let Ok(value) = HeaderValue::from_str(&outcome.orig_host) { headers.insert(HeaderName::from_static(X_FORWARDED_HOST), value.clone()); headers.insert(HeaderName::from_static(X_ORIG_HOST), value); @@ -669,4 +674,32 @@ mod tests { "Host is still rewritten alongside the strip" ); } + + #[test] + fn rewrite_headers_strips_inbound_forwarded_so_injected_host_wins() { + // Trusted Server resolves the request host from `Forwarded` BEFORE + // `X-Forwarded-Host`. A client-supplied `Forwarded` must therefore be + // dropped, or it would outrank the FROM host the proxy injects. + let outcome = RewriteOutcome { + sni: "to.edgecompute.app".to_string(), + host_header: "to.edgecompute.app".to_string(), + orig_host: "www.example-publisher.com".to_string(), + scheme_is_tls: true, + }; + let mut headers = hyper::HeaderMap::new(); + headers.insert( + HeaderName::from_static("forwarded"), + HeaderValue::from_static("host=evil.example.com"), + ); + rewrite_headers(&mut headers, &outcome, None); + assert!( + !headers.contains_key("forwarded"), + "inbound Forwarded must be stripped so it cannot outrank X-Forwarded-Host" + ); + assert_eq!( + headers.get(X_FORWARDED_HOST).and_then(|v| v.to_str().ok()), + Some("www.example-publisher.com"), + "X-Forwarded-Host is the injected FROM host" + ); + } } diff --git a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md index a341f333a..0d972c52a 100644 --- a/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md +++ b/docs/superpowers/plans/2026-06-22-ts-dev-proxy.md @@ -1390,9 +1390,12 @@ Create `crates/trusted-server-cli/tests/proxy_e2e.rs`. It starts a local TLS "up use std::sync::Arc; -// Helper: spin a local HTTPS server that echoes the Host and X-Orig-Host it saw. -// (Implementation uses tokio + tokio-rustls + a rcgen self-signed cert for -// "upstream.localhost"; see fixtures below.) +// Helper: spin a local HTTPS server that echoes the Host, X-Forwarded-Host, and +// X-Orig-Host it saw. (Implementation uses tokio + tokio-rustls + a rcgen +// self-signed cert for "upstream.localhost"; see fixtures below.) +// +// Note: the functional first-party-host header is X-Forwarded-Host (TS core +// reads it for request_host); X-Orig-Host is an informational duplicate. #[tokio::test] async fn matched_host_is_rewritten_and_forwarded() { @@ -1410,8 +1413,9 @@ async fn matched_host_is_rewritten_and_forwarded() { // trusts the dev CA; SNI/Host are set by the proxy. let response = drive_request_through_proxy(cfg, ca).await; - // Assert: upstream saw Host = FROM and X-Orig-Host = FROM. + // Assert: upstream saw Host = FROM and X-Forwarded-Host = FROM (X-Orig-Host too). assert_eq!(response.seen_host, "www.example-publisher.com", "Host preserved as FROM"); + assert_eq!(response.seen_forwarded_host, "www.example-publisher.com", "X-Forwarded-Host is FROM"); assert_eq!(response.seen_orig_host, "www.example-publisher.com", "X-Orig-Host is FROM"); assert_eq!(response.status, 200, "response streamed back"); } @@ -1461,7 +1465,7 @@ async fn keep_alive_serves_multiple_sequential_requests() { } ``` -> The test-support module `tests/support/mod.rs` provides: `dev_ca()` (a `CertAuthority` in a `tempfile::tempdir()`); `start_echo_upstream()` (HTTPS server, self-signed CN `upstream.localhost`, echoes the `Host`/`X-Orig-Host`/path it saw); `start_gated_upstream()` (returns `401` unless `Authorization` present); `test_config(addr)` / `test_config_without_rules()`; `drive_request_through_proxy(cfg, ca)`; `connect_through_proxy_capturing_cert(cfg, ca, addr, sni)` (returns the leaf the client saw); and `drive_sequential_requests(cfg, ca, paths)` (multiple requests over one keep-alive tunnel). Build them on `tokio` + `tokio-rustls` + rcgen + a `hyper` client that CONNECTs through `cfg.listen`. They import the crate under test as `use trusted_server_cli::commands::dev::proxy::{ca, config, server};` — possible only because Task 1 made the crate a lib + bin. +> The test-support module `tests/support/mod.rs` provides: `dev_ca()` (a `CertAuthority` in a `tempfile::tempdir()`); `start_echo_upstream()` (HTTPS server, self-signed CN `upstream.localhost`, echoes the `Host`/`X-Forwarded-Host`/`X-Orig-Host`/path it saw); `start_gated_upstream()` (returns `401` unless `Authorization` present); `test_config(addr)` / `test_config_without_rules()`; `drive_request_through_proxy(cfg, ca)`; `connect_through_proxy_capturing_cert(cfg, ca, addr, sni)` (returns the leaf the client saw); and `drive_sequential_requests(cfg, ca, paths)` (multiple requests over one keep-alive tunnel). Build them on `tokio` + `tokio-rustls` + rcgen + a `hyper` client that CONNECTs through `cfg.listen`. They import the crate under test as `use trusted_server_cli::commands::dev::proxy::{ca, config, server};` — possible only because Task 1 made the crate a lib + bin. - [ ] **Step 2: Run the test to verify it fails** @@ -1554,7 +1558,8 @@ async fn handle_connection( // connect_authority(): Some(host) if method == CONNECT. // handle_connect(): match rules; on match reply 200 + MITM via ca.server_config; // on no-match loopback connect-first-then-200 blind tunnel; non-loopback -> 403. -// For each MITM request: let out = rewrite_for(rule); set Host/X-Orig-Host/SNI; +// For each MITM request: let out = rewrite_for(rule); strip inbound Forwarded; +// set Host/X-Forwarded-Host(+X-Orig-Host)/SNI; // inject cfg.basic_auth if Authorization absent; open upstream (TLS unless // plaintext; skip verify if cfg.insecure); stream response; close on Upgrade. // Redact Authorization/Cookie in any logging. @@ -1586,7 +1591,7 @@ pub fn run(args: ProxyArgs) -> error_stack::Result<(), ProxyError> { - [ ] **Step 5: Run the integration test to verify it passes** Run: `cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --test proxy_e2e` -Expected: PASS (4 tests) — matched host rewritten with `Host=FROM` + `X-Orig-Host`; unmatched host blind-tunneled (upstream cert, not dev CA); basic-auth clears `401`; keep-alive serves two sequential requests over one tunnel. +Expected: PASS (4 tests) — matched host rewritten with `Host=FROM` + `X-Forwarded-Host=FROM` (+`X-Orig-Host`); unmatched host blind-tunneled (upstream cert, not dev CA); basic-auth clears `401`; keep-alive serves two sequential requests over one tunnel. - [ ] **Step 6: Lint and commit** diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 679008ba2..13785c98b 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -174,7 +174,7 @@ sequenceDiagram P-->>B: 200 (tunnel established) P->>P: TLS-accept with leaf cert for www.pub.com
(signed by local CA) B->>P: GET / — Host: www.pub.com (over MITM TLS) - P->>P: match rule www.pub.com → TO
SNI→TO; keep Host: FROM (default); add X-Orig-Host; inject auth + P->>P: match rule www.pub.com → TO
SNI→TO; keep Host: FROM (default); add X-Forwarded-Host (+X-Orig-Host); inject auth P->>U: GET / — Host: www.pub.com (FROM), SNI=TO (over TLS) Note over P,U: SNI=TO → valid cert + Fastly routing; Host=FROM by default (--rewrite-host sends Host=TO) U-->>P: response @@ -196,7 +196,8 @@ sequenceDiagram 2. On the MITM path, read decrypted HTTP/1.1 requests **in a loop** — one keep-alive tunnel carries many sequential requests. 3. For each request: rewrite upstream target + SNI to `TO`, set `Host` (§8.3), - add `X-Orig-Host: `, inject auth if configured. An `Upgrade:` + strip any inbound `Forwarded`, add `X-Forwarded-Host: ` (and an + informational `X-Orig-Host: `), inject auth if configured. An `Upgrade:` (WebSocket) request is out of scope in v1 (§16): log a clear note and close rather than corrupting the stream. 4. Proxy opens a TLS (or plaintext) connection to `TO`, forwards the request, @@ -585,7 +586,7 @@ overrides either. Every setting is a CLI flag (§4). | CA CN | `Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION` | | Leaf validity | ≤ 90 days | | ALPN (both legs) | `http/1.1` | -| Injected real-host header | `X-Orig-Host` | +| Injected real-host header | `X-Forwarded-Host` (functional; TS reads it for `request_host`) + `X-Orig-Host` (informational duplicate) | | Upstream port (default) | `443` (`80` with `--upstream-plaintext`) | --- @@ -614,8 +615,9 @@ request. **Unit (`rewrite.rs`):** host matching (case-insensitivity, port stripping, first-match-wins, no-match pass-through); header outcomes (default `Host=FROM` + -`X-Orig-Host`; `--rewrite-host` sends `Host=TO`; non-default `TO` port in `Host` -but not SNI; auth injected only when absent); URI normalization. +`X-Forwarded-Host=FROM`; `--rewrite-host` sends `Host=TO` while `X-Forwarded-Host` +stays `FROM`; inbound `Forwarded` stripped; non-default `TO` port in `Host` but +not SNI; auth injected only when absent); URI normalization. **Unit (`ca.rs`):** CA is generated on first run and reloaded from `--ca-dir` on the next run (key file mode `0600`); minted leaf carries the requested SAN, @@ -624,8 +626,8 @@ chains to the CA, and is cached (second call returns the same `Arc`). **Integration (`crates/integration-tests`, native):** local HTTPS upstream with a known self-signed cert; run the proxy with `--insecure`; client configured to use the proxy and trust the dev CA; assert address-host preserved, request -reaches upstream with rewritten `Host`/SNI + `X-Orig-Host`, response streamed -back. Cover `--basic-auth` clearing a `401`; unmatched-host **blind tunnel** +reaches upstream with rewritten `Host`/SNI + `X-Forwarded-Host` (+`X-Orig-Host`), +response streamed back. Cover `--basic-auth` clearing a `401`; unmatched-host **blind tunnel** (bytes piped, no leaf minted, dev CA never presented); and **multiple sequential requests over one keep-alive tunnel**. @@ -647,8 +649,9 @@ padlock and the production hostname in the address bar. 4. **Proxy server.** CONNECT upgrade, MITM TLS via minted leaf, upstream forward (TLS + `--insecure` + `--upstream-plaintext`). End-to-end against a real upstream. -5. **Header/auth polish.** `--basic-auth`/`--basic-auth-file`, `X-Orig-Host`, - `--rewrite-host` (default preserves `Host = FROM`), secret redaction in logs. +5. **Header/auth polish.** `--basic-auth`/`--basic-auth-file`, `X-Forwarded-Host` + (+informational `X-Orig-Host`), inbound-`Forwarded` strip, `--rewrite-host` + (default preserves `Host = FROM`), secret redaction in logs. 6. **Browser orchestration.** `--launch` list (no default — unset runs proxy only): Chrome + Firefox profiles, Safari PAC via `networksetup` with restore-on-exit; PAC generation; `ts dev proxy ca {path,install,uninstall,regenerate}`.