diff --git a/Cargo.lock b/Cargo.lock index ca154349..974d46d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,7 @@ dependencies = [ "qcow2-rs", "rand 0.9.2", "rayon", + "rcgen", "reflink-copy", "regex", "reqwest", @@ -431,6 +432,7 @@ dependencies = [ "tempfile", "term_size", "thiserror 1.0.69", + "time", "tokio", "tokio-stream", "tokio-util", @@ -3378,6 +3380,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5450,6 +5465,15 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/boxlite-shared/proto/boxlite/v1/service.proto b/boxlite-shared/proto/boxlite/v1/service.proto index 4dafb19e..af65a1b0 100644 --- a/boxlite-shared/proto/boxlite/v1/service.proto +++ b/boxlite-shared/proto/boxlite/v1/service.proto @@ -205,6 +205,14 @@ message ContainerInitRequest { RootfsInit rootfs = 3; // Bind mounts from guest VM paths into container namespace repeated BindMount mounts = 4; + // Additional CA certificates to install in the container trust store. + // Used for MITM secret substitution — the container trusts the proxy CA. + repeated CACert ca_certs = 5; +} + +// A CA certificate to add to the container's trust store. +message CACert { + string pem = 1; // PEM-encoded X.509 certificate } // Bind mount from guest volume to container path diff --git a/boxlite/Cargo.toml b/boxlite/Cargo.toml index 2d6daf75..bb2e0e25 100644 --- a/boxlite/Cargo.toml +++ b/boxlite/Cargo.toml @@ -87,6 +87,8 @@ hex = "0.4.3" signal-hook = "0.3" reflink-copy = "0.1" nanoid = "0.4" +rcgen = "0.13" +time = "0.3" # REST backend (optional) reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], optional = true, default-features = false } diff --git a/boxlite/deps/libgvproxy-sys/build.rs b/boxlite/deps/libgvproxy-sys/build.rs index 5aa2d242..f84ee2d9 100644 --- a/boxlite/deps/libgvproxy-sys/build.rs +++ b/boxlite/deps/libgvproxy-sys/build.rs @@ -50,11 +50,23 @@ fn build_gvproxy(source_dir: &Path, output_path: &Path) { } fn main() { - // Rebuild if Go sources change - println!("cargo:rerun-if-changed=gvproxy-bridge/main.go"); - println!("cargo:rerun-if-changed=gvproxy-bridge/stats.go"); - println!("cargo:rerun-if-changed=gvproxy-bridge/dns_filter.go"); - println!("cargo:rerun-if-changed=gvproxy-bridge/go.mod"); + // Rebuild when any Go source file changes. + // cargo:rerun-if-changed on a directory only detects file additions/removals, + // not content changes. Walk the directory and watch each .go file individually. + let bridge_dir = Path::new("gvproxy-bridge"); + if bridge_dir.is_dir() { + for entry in fs::read_dir(bridge_dir).expect("Failed to read gvproxy-bridge directory") { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + if path + .extension() + .is_some_and(|ext| ext == "go" || ext == "mod" || ext == "sum") + { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + } + println!("cargo:rerun-if-changed=gvproxy-bridge"); // also watch for new files println!("cargo:rerun-if-env-changed=BOXLITE_DEPS_STUB"); // Auto-detect crates.io download: Cargo injects .cargo_vcs_info.json into diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_network.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_network.go index f2f4cb8d..c5e3b2af 100644 --- a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_network.go +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_network.go @@ -31,6 +31,8 @@ func OverrideTCPHandler( config *types.Configuration, ec2MetadataAccess bool, filter *TCPFilter, + ca *BoxCA, + secretMatcher *SecretHostMatcher, ) error { // Access private stack field via reflect v := reflect.ValueOf(vn).Elem() @@ -51,7 +53,7 @@ func OverrideTCPHandler( // Replace TCP handler with our filtered version var natLock sync.Mutex - tcpFwd := TCPWithFilter(s, nat, &natLock, ec2MetadataAccess, filter) + tcpFwd := TCPWithFilter(s, nat, &natLock, ec2MetadataAccess, filter, ca, secretMatcher) s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpFwd.HandlePacket) logrus.Info("allowNet TCP: handler overridden with SNI-inspecting forwarder") diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp.go index 3eaaf899..86bd821d 100644 --- a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp.go +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp.go @@ -30,18 +30,31 @@ import ( // TCPWithFilter creates a TCP forwarder that checks the filter before allowing // outbound connections. For port 443/80 with hostname rules, it inspects // TLS SNI / HTTP Host headers to match against the allowlist. +// linkLocalSubnet is 169.254.0.0/16, parsed once at init (not per-packet). +var linkLocalSubnet tcpip.Subnet + +func init() { + _, linkLocalNet, err := net.ParseCIDR("169.254.0.0/16") + if err != nil { + panic("failed to parse link-local CIDR: " + err.Error()) + } + var subnetErr error + linkLocalSubnet, subnetErr = tcpip.NewSubnet( + tcpip.AddrFromSlice(linkLocalNet.IP), + tcpip.MaskFromBytes(linkLocalNet.Mask), + ) + if subnetErr != nil { + panic("failed to create link-local subnet: " + subnetErr.Error()) + } +} + func TCPWithFilter(s *stack.Stack, nat map[tcpip.Address]tcpip.Address, - natLock *sync.Mutex, ec2MetadataAccess bool, filter *TCPFilter) *tcp.Forwarder { + natLock *sync.Mutex, ec2MetadataAccess bool, filter *TCPFilter, + ca *BoxCA, secretMatcher *SecretHostMatcher) *tcp.Forwarder { return tcp.NewForwarder(s, 0, 10, func(r *tcp.ForwarderRequest) { localAddress := r.ID().LocalAddress - // Block link-local (169.254.0.0/16) unless EC2 metadata access enabled - _, linkLocalNet, _ := net.ParseCIDR("169.254.0.0/16") - linkLocalSubnet, _ := tcpip.NewSubnet( - tcpip.AddrFromSlice(linkLocalNet.IP), - tcpip.MaskFromBytes(linkLocalNet.Mask), - ) if !ec2MetadataAccess && linkLocalSubnet.Contains(localAddress) { r.Complete(true) return @@ -59,12 +72,24 @@ func TCPWithFilter(s *stack.Stack, nat map[tcpip.Address]tcpip.Address, destPort := r.ID().LocalPort destAddr := fmt.Sprintf("%s:%d", localAddress, destPort) - // No filter: standard upstream flow + // Secrets-only mode (no allowlist filter): MITM secret hosts, allow everything else if filter == nil { + if secretMatcher != nil && destPort == 443 { + inspectAndForward(r, destAddr, destPort, nil, ca, secretMatcher) + return + } standardForward(r, destAddr) return } + // Port 443 with secrets: MUST inspect SNI even if IP matches allowlist, + // because we need to know the hostname to decide MITM vs passthrough. + // The IP match alone can't tell us if it's a secret host. + if secretMatcher != nil && destPort == 443 { + inspectAndForward(r, destAddr, destPort, filter, ca, secretMatcher) + return + } + // IP/CIDR match: standard upstream flow (allowed) if filter.MatchesIP(destIP) { standardForward(r, destAddr) @@ -73,7 +98,7 @@ func TCPWithFilter(s *stack.Stack, nat map[tcpip.Address]tcpip.Address, // Port 443/80 with hostname rules: inspect SNI/Host if filter.HasHostnameRules() && (destPort == 443 || destPort == 80) { - inspectAndForward(r, destAddr, destPort, filter) + inspectAndForward(r, destAddr, destPort, filter, ca, secretMatcher) return } @@ -119,7 +144,7 @@ func standardForward(r *tcp.ForwarderRequest, destAddr string) { // inspectAndForward: Accept → Peek SNI/Host → check allowlist → Dial → relay. // The flow is reversed from upstream because we need to read from the guest // before deciding whether to connect to the upstream server. -func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16, filter *TCPFilter) { +func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16, filter *TCPFilter, ca *BoxCA, secretMatcher *SecretHostMatcher) { // Step 1: Accept TCP from guest first (reversed from upstream) var wq waiter.Queue ep, tcpErr := r.CreateEndpoint(&wq) @@ -143,8 +168,20 @@ func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16 hostname = peekHTTPHost(br) } - // Step 3: Check allowlist - if hostname == "" || !filter.MatchesHostname(hostname) { + // Step 3: Check for MITM secret substitution (HTTPS only, takes priority over allowlist) + if destPort == 443 && secretMatcher != nil && hostname != "" && secretMatcher.Matches(hostname) { + secrets := secretMatcher.SecretsForHost(hostname) + logrus.WithFields(logrus.Fields{ + "hostname": hostname, + "num_secrets": len(secrets), + }).Debug("MITM: intercepting for secret substitution") + bufferedGuest := &bufferedConn{Conn: guestConn, reader: br} + mitmAndForward(bufferedGuest, hostname, destAddr, ca, secrets) + return + } + + // Step 4: Check allowlist (skip if no allowlist — secrets-only mode allows all traffic) + if filter != nil && (hostname == "" || !filter.MatchesHostname(hostname)) { logrus.WithFields(logrus.Fields{ "dst": destAddr, "hostname": hostname, @@ -158,7 +195,7 @@ func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16 "hostname": hostname, }).Debug("allowNet TCP: allowed by hostname") - // Step 4: Dial upstream + // Step 5: Dial upstream outbound, err := net.Dial("tcp", destAddr) if err != nil { logrus.WithField("error", err).Trace("allowNet TCP: upstream dial failed") diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp_test.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp_test.go new file mode 100644 index 00000000..86dbb403 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp_test.go @@ -0,0 +1,264 @@ +package main + +// Tests for the MITM wiring in the TCP forwarding layer. +// Since TCPWithFilter/inspectAndForward require a gVisor stack, +// these tests verify the routing decisions at the network level: +// - Secret hosts on port 443 → MITM'd (TLS intercepted, secrets substituted) +// - Non-secret hosts on port 443 → relayed unchanged +// - Secret hosts on port 80 → NOT MITM'd (HTTPS only) + +import ( + "bufio" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" +) + +// TestMitmRouting_SecretHostGetsMitmd verifies that when a TLS connection +// targets a secret host, mitmAndForward is called and secrets are substituted. +func TestMitmRouting_SecretHostGetsMitmd(t *testing.T) { + ca := newTestCA(t) + + secrets := []SecretConfig{{ + Name: "api_key", + Hosts: []string{"api.openai.com"}, + Placeholder: "", + Value: "sk-real-key-123", + }} + + // Start an upstream HTTPS server that echoes the Authorization header + upstreamAddr, cleanup := startTLSEchoServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "auth=%s", r.Header.Get("Authorization")) + }) + defer cleanup() + + // Simulate: guest TLS → mitmAndForward → upstream + guestConn, proxyConn := net.Pipe() + go mitmAndForward(proxyConn, "api.openai.com", upstreamAddr, ca, secrets, &tls.Config{InsecureSkipVerify: true}) + + // Client does TLS handshake with the MITM proxy + caPool, _ := ca.CACertPool() + tlsConn := tls.Client(guestConn, &tls.Config{ + ServerName: "api.openai.com", + RootCAs: caPool, + }) + defer tlsConn.Close() + + // Send an HTTP request with a secret placeholder + req, _ := http.NewRequest("GET", "https://api.openai.com/v1/models", nil) + req.Header.Set("Authorization", "Bearer ") + if err := req.Write(tlsConn); err != nil { + t.Fatal("write request:", err) + } + + // Read response + resp, err := http.ReadResponse(bufio.NewReader(tlsConn), req) + if err != nil { + t.Fatal("read response:", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + got := string(body) + + // Verify: the upstream received the REAL secret, not the placeholder + if !strings.Contains(got, "sk-real-key-123") { + t.Errorf("expected substituted value in upstream, got: %s", got) + } + if strings.Contains(got, "BOXLITE_SECRET") { + t.Errorf("placeholder should not reach upstream, got: %s", got) + } +} + +// TestMitmRouting_NonSecretHostPassthrough verifies that TLS connections to +// non-secret hosts are NOT MITM'd — they pass through unchanged. +func TestMitmRouting_NonSecretHostPassthrough(t *testing.T) { + secrets := []SecretConfig{{ + Name: "api_key", + Hosts: []string{"api.openai.com"}, + Placeholder: "", + Value: "sk-real-key-123", + }} + matcher := NewSecretHostMatcher(secrets) + + // "github.com" is NOT in the secret hosts + if matcher.Matches("github.com") { + t.Fatal("github.com should not match secret hosts") + } + + // "api.openai.com" IS in the secret hosts + if !matcher.Matches("api.openai.com") { + t.Fatal("api.openai.com should match secret hosts") + } +} + +// TestMitmRouting_SecretHostPort80_NoMitm verifies that secret hosts on +// port 80 (HTTP) are NOT MITM'd — only HTTPS (port 443) triggers MITM. +func TestMitmRouting_SecretHostPort80_NoMitm(t *testing.T) { + secrets := []SecretConfig{{ + Name: "api_key", + Hosts: []string{"api.openai.com"}, + Placeholder: "", + Value: "sk-real-key-123", + }} + matcher := NewSecretHostMatcher(secrets) + + // The routing logic in TCPWithFilter checks: + // if secretMatcher != nil && destPort == 443 + // Port 80 should NOT trigger MITM even for secret hosts. + // We verify the matcher itself works but the port check is in TCPWithFilter. + if !matcher.Matches("api.openai.com") { + t.Fatal("matcher should match api.openai.com") + } + // The port 80 check is enforced by TCPWithFilter routing logic, + // which only calls inspectAndForward for port 443 when no allowlist. + // This is a design verification — port 80 MITM is intentionally excluded. +} + +// TestMitmRouting_AllowlistAndSecrets_MitmPriority verifies that when a host +// appears in BOTH the allowlist and secret hosts, MITM takes priority. +func TestMitmRouting_AllowlistAndSecrets_MitmPriority(t *testing.T) { + ca := newTestCA(t) + + secrets := []SecretConfig{{ + Name: "key", + Hosts: []string{"api.example.com"}, + Placeholder: "", + Value: "real-value", + }} + matcher := NewSecretHostMatcher(secrets) + + // Also create an allowlist filter that includes the same host + filter := NewTCPFilter([]string{"api.example.com"}, "192.168.127.1", "192.168.127.2") + + // Both should match + if !matcher.Matches("api.example.com") { + t.Fatal("secret matcher should match") + } + if filter != nil && !filter.MatchesHostname("api.example.com") { + t.Fatal("TCP filter should match") + } + + // Verify MITM works for this host (proves MITM path is reachable) + upstreamAddr, cleanup := startTLSEchoServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "auth=%s", r.Header.Get("Authorization")) + }) + defer cleanup() + + guestConn, proxyConn := net.Pipe() + go mitmAndForward(proxyConn, "api.example.com", upstreamAddr, ca, secrets, &tls.Config{InsecureSkipVerify: true}) + + caPool, _ := ca.CACertPool() + tlsConn := tls.Client(guestConn, &tls.Config{ + ServerName: "api.example.com", + RootCAs: caPool, + }) + defer tlsConn.Close() + + req, _ := http.NewRequest("GET", "https://api.example.com/test", nil) + req.Header.Set("Authorization", "Bearer ") + req.Write(tlsConn) //nolint:errcheck + + resp, err := http.ReadResponse(bufio.NewReader(tlsConn), req) + if err != nil { + t.Fatal("read response:", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "real-value") { + t.Errorf("MITM should substitute even when host is also in allowlist, got: %s", body) + } +} + +// TestMitmRouting_SecretsOnly_NoAllowlist verifies that when secrets are +// configured but no allowlist, non-secret traffic flows freely. +func TestMitmRouting_SecretsOnly_NoAllowlist(t *testing.T) { + secrets := []SecretConfig{{ + Name: "key", + Hosts: []string{"api.openai.com"}, + Placeholder: "", + Value: "real-value", + }} + matcher := NewSecretHostMatcher(secrets) + + // In secrets-only mode (filter == nil), non-secret hosts should pass through. + // This was a bug (fixed): filter==nil was blocking all non-secret traffic. + if matcher.Matches("github.com") { + t.Error("github.com should not be a secret host") + } + if !matcher.Matches("api.openai.com") { + t.Error("api.openai.com should be a secret host") + } + + // Verify SecretsForHost returns the right secrets + hostSecrets := matcher.SecretsForHost("api.openai.com") + if len(hostSecrets) != 1 { + t.Errorf("expected 1 secret for api.openai.com, got %d", len(hostSecrets)) + } + if len(hostSecrets) > 0 && hostSecrets[0].Value != "real-value" { + t.Errorf("expected secret value 'real-value', got %q", hostSecrets[0].Value) + } +} + +// TestMitmRouting_CACertPEM verifies that BoxCA produces valid PEM +// that can be used for trust injection. +func TestMitmRouting_CACertPEM(t *testing.T) { + ca := newTestCA(t) + + pem := ca.CACertPEM() + if len(pem) == 0 { + t.Fatal("CACertPEM should not be empty") + } + + // Verify it's valid PEM + if !strings.Contains(string(pem), "BEGIN CERTIFICATE") { + t.Error("PEM should contain BEGIN CERTIFICATE header") + } + if !strings.Contains(string(pem), "END CERTIFICATE") { + t.Error("PEM should contain END CERTIFICATE footer") + } + + // Verify CACertPool works + pool, err := ca.CACertPool() + if err != nil { + t.Fatal("CACertPool:", err) + } + if pool == nil { + t.Fatal("CACertPool should not be nil") + } +} + +// startTLSEchoServer starts a local HTTPS server for testing. +// Returns the address and a cleanup function. +func startTLSEchoServer(t *testing.T, handler http.HandlerFunc) (addr string, cleanup func()) { + t.Helper() + + // Create a self-signed cert for the test server + ca := newTestCA(t) + cert, err := ca.GenerateHostCert("127.0.0.1") + if err != nil { + t.Fatal("GenerateHostCert:", err) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal("listen:", err) + } + + tlsListener := tls.NewListener(listener, &tls.Config{ + Certificates: []tls.Certificate{*cert}, + }) + + srv := &http.Server{ + Handler: handler, + } + + go srv.Serve(tlsListener) //nolint:errcheck + + return tlsListener.Addr().String(), func() { srv.Close() } +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/go.mod b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/go.mod index ce05cb2c..a3cee0d8 100644 --- a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/go.mod +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/go.mod @@ -7,6 +7,8 @@ toolchain go1.24.6 require ( github.com/containers/gvisor-tap-vsock v0.8.7 github.com/sirupsen/logrus v1.9.3 + golang.org/x/net v0.45.0 + golang.org/x/sync v0.17.0 gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f ) @@ -25,9 +27,8 @@ require ( github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.45.0 // indirect - golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.37.0 // indirect ) diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/main.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/main.go index 7b34ba50..5856b33b 100644 --- a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/main.go +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/main.go @@ -186,7 +186,10 @@ type GvproxyConfig struct { DNSSearchDomains []string `json:"dns_search_domains"` Debug bool `json:"debug"` CaptureFile *string `json:"capture_file,omitempty"` - AllowNet []string `json:"allow_net,omitempty"` + AllowNet []string `json:"allow_net,omitempty"` + Secrets []SecretConfig `json:"secrets,omitempty"` + CACertPEM string `json:"ca_cert_pem,omitempty"` + CAKeyPEM string `json:"ca_key_pem,omitempty"` } // GvproxyInstance tracks a running gvisor-tap-vsock instance @@ -197,8 +200,10 @@ type GvproxyInstance struct { Cancel context.CancelFunc conn net.Conn // For macOS UnixDgram (VFKit) listener net.Listener // For Linux UnixStream (Qemu) - vn *virtualnetwork.VirtualNetwork // Virtual network for stats collection - vnMu sync.RWMutex // Protects vn field + vn *virtualnetwork.VirtualNetwork // Virtual network for stats collection + vnMu sync.RWMutex // Protects vn field + ca *BoxCA // Ephemeral MITM CA (nil if no secrets) + secretMatcher *SecretHostMatcher // Hostname→secrets lookup (nil if no secrets) } var ( @@ -333,6 +338,19 @@ func gvproxy_create(configJSON *C.char) C.longlong { listener: listener, } + // Parse MITM CA from config (generated by Rust) when secrets are configured + if config.CACertPEM != "" && config.CAKeyPEM != "" { + ca, err := NewBoxCAFromPEM([]byte(config.CACertPEM), []byte(config.CAKeyPEM)) + if err != nil { + logrus.WithError(err).Error("MITM: failed to parse CA from config") + cancel() + return -1 + } + instance.ca = ca + instance.secretMatcher = NewSecretHostMatcher(config.Secrets) + logrus.WithField("num_secrets", len(config.Secrets)).Info("MITM: loaded CA from Rust config") + } + instancesMu.Lock() instances[id] = instance instancesMu.Unlock() @@ -371,13 +389,14 @@ func gvproxy_create(configJSON *C.char) C.longlong { return } - // Override TCP handler with AllowNet filter (SNI/Host inspection) - if len(config.AllowNet) > 0 { - tcpFilter := NewTCPFilter(config.AllowNet, config.GatewayIP, config.GuestIP) - if tcpFilter != nil { - if err := OverrideTCPHandler(vn, tapConfig, tapConfig.Ec2MetadataAccess, tcpFilter); err != nil { - logrus.WithError(err).Error("allowNet TCP: failed to override handler") - } + // Override TCP handler with AllowNet filter and/or MITM secret substitution + if len(config.AllowNet) > 0 || instance.secretMatcher != nil { + var tcpFilter *TCPFilter + if len(config.AllowNet) > 0 { + tcpFilter = NewTCPFilter(config.AllowNet, config.GatewayIP, config.GuestIP) + } + if err := OverrideTCPHandler(vn, tapConfig, tapConfig.Ec2MetadataAccess, tcpFilter, instance.ca, instance.secretMatcher); err != nil { + logrus.WithError(err).Error("TCP: failed to override handler") } } diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm.go new file mode 100644 index 00000000..1bc48890 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm.go @@ -0,0 +1,259 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// BoxCA is an ephemeral ECDSA P-256 certificate authority for MITM. +type BoxCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + certPEM []byte + certCache sync.Map // hostname -> *tls.Certificate +} + +// NewBoxCAFromPEM reconstructs a BoxCA from PEM-encoded cert and key. +// The cert/key are generated by the Rust side and passed via GvproxyConfig JSON. +func NewBoxCAFromPEM(certPEM, keyPEM []byte) (*BoxCA, error) { + block, _ := pem.Decode(certPEM) + if block == nil || block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("failed to decode CA certificate PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, fmt.Errorf("failed to decode CA key PEM") + } + + // rcgen uses PKCS8 format ("PRIVATE KEY"), try that first + rawKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + // Fallback to SEC1 format ("EC PRIVATE KEY") + rawKey, err = x509.ParseECPrivateKey(keyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse CA key (tried PKCS8 and SEC1): %w", err) + } + } + + ecKey, ok := rawKey.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("CA key is not ECDSA (got %T)", rawKey) + } + + return &BoxCA{ + cert: cert, + key: ecKey, + certPEM: certPEM, + }, nil +} + +// CACertPEM returns the CA certificate in PEM format. +func (ca *BoxCA) CACertPEM() []byte { + return ca.certPEM +} + +// CACertPool returns an x509.CertPool containing this CA's certificate. +func (ca *BoxCA) CACertPool() (*x509.CertPool, error) { + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + return pool, nil +} + +// GenerateHostCert generates a TLS certificate for the given hostname, signed by this CA. +// Results are cached per-hostname. +const maxCertCacheSize = 10000 + +func (ca *BoxCA) GenerateHostCert(hostname string) (*tls.Certificate, error) { + if cached, ok := ca.certCache.Load(hostname); ok { + tlsCert := cached.(*tls.Certificate) + // Check TTL: regenerate if cert has expired + if tlsCert.Leaf != nil && time.Now().Before(tlsCert.Leaf.NotAfter) { + return tlsCert, nil + } + // Expired — fall through to regenerate + ca.certCache.Delete(hostname) + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + NotBefore: now.Add(-1 * time.Minute), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(hostname); ip != nil { + template.IPAddresses = []net.IP{ip} + } else { + template.DNSNames = []string{hostname} + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + if err != nil { + return nil, err + } + + leaf, _ := x509.ParseCertificate(certDER) + tlsCert := &tls.Certificate{ + Certificate: [][]byte{certDER, ca.cert.Raw}, + PrivateKey: key, + Leaf: leaf, // stored for TTL check + } + + // Evict expired entries and enforce max size. + // sync.Map doesn't have a size, so we count during eviction. + cacheSize := 0 + ca.certCache.Range(func(key, val any) bool { + if cert, ok := val.(*tls.Certificate); ok && cert.Leaf != nil && now.After(cert.Leaf.NotAfter) { + ca.certCache.Delete(key) // expired + } else { + cacheSize++ + } + return true + }) + if cacheSize >= maxCertCacheSize { + // Over limit even after TTL eviction — clear oldest half + evicted := 0 + ca.certCache.Range(func(key, _ any) bool { + ca.certCache.Delete(key) + evicted++ + return evicted < cacheSize/2 + }) + } + + actual, loaded := ca.certCache.LoadOrStore(hostname, tlsCert) + if loaded { + return actual.(*tls.Certificate), nil + } + return tlsCert, nil +} + +// substituteHeaders replaces secret placeholders in request headers and URL query. +func substituteHeaders(req *http.Request, secrets []SecretConfig) { + if len(secrets) == 0 { + return + } + + pairs := make([]string, 0, len(secrets)*2) + for _, s := range secrets { + pairs = append(pairs, s.Placeholder, s.Value) + } + r := strings.NewReplacer(pairs...) + + for key, vals := range req.Header { + for i, v := range vals { + req.Header[key][i] = r.Replace(v) + } + } + + if req.URL != nil && req.URL.RawQuery != "" { + req.URL.RawQuery = r.Replace(req.URL.RawQuery) + } +} + +// resolveUpstreamTLS returns the TLS config for upstream connections. +// Uses the first override if provided, otherwise creates a default config with ServerName. +func resolveUpstreamTLS(hostname string, overrides ...*tls.Config) *tls.Config { + if len(overrides) > 0 && overrides[0] != nil { + return overrides[0] + } + return &tls.Config{ServerName: hostname} +} + +// SecretHostMatcher provides O(1) lookup for whether a hostname has secrets. +type SecretHostMatcher struct { + exactHosts map[string]bool + wildcardSuffixes []string + secrets []SecretConfig +} + +// NewSecretHostMatcher builds a matcher from secret configs. +func NewSecretHostMatcher(secrets []SecretConfig) *SecretHostMatcher { + m := &SecretHostMatcher{ + exactHosts: make(map[string]bool), + secrets: secrets, + } + + for _, s := range secrets { + for _, host := range s.Hosts { + h := strings.ToLower(host) + if strings.HasPrefix(h, "*.") { + suffix := h[1:] // e.g., ".openai.com" + m.wildcardSuffixes = append(m.wildcardSuffixes, suffix) + } else { + m.exactHosts[h] = true + } + } + } + + return m +} + +// matchesWildcard checks if hostname matches a wildcard suffix (e.g., ".foo.com"). +// Only single-level subdomains match: "bar.foo.com" matches but "sub.bar.foo.com" doesn't. +func matchesWildcard(hostname, suffix string) bool { + return strings.HasSuffix(hostname, suffix) && + !strings.Contains(hostname[:len(hostname)-len(suffix)], ".") +} + +// Matches returns true if hostname has associated secrets. +func (m *SecretHostMatcher) Matches(hostname string) bool { + h := strings.ToLower(hostname) + if m.exactHosts[h] { + return true + } + for _, suffix := range m.wildcardSuffixes { + if matchesWildcard(h, suffix) { + return true + } + } + return false +} + +// SecretsForHost returns all secrets whose Hosts list includes hostname. +func (m *SecretHostMatcher) SecretsForHost(hostname string) []SecretConfig { + h := strings.ToLower(hostname) + var result []SecretConfig + for _, s := range m.secrets { + for _, host := range s.Hosts { + host = strings.ToLower(host) + if host == h { + result = append(result, s) + break + } + if strings.HasPrefix(host, "*.") && matchesWildcard(h, host[1:]) { + result = append(result, s) + break + } + } + } + return result +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_proxy.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_proxy.go new file mode 100644 index 00000000..17b91555 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_proxy.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "net/http/httputil" + "time" + + logrus "github.com/sirupsen/logrus" + "golang.org/x/net/http2" +) + +const upstreamDialTimeout = 30 * time.Second + +// mitmAndForward handles a MITM'd connection: TLS termination, reverse proxy, secret substitution. +// upstreamTLSConfig overrides the TLS config for upstream connections (nil = system defaults). +func mitmAndForward(guestConn net.Conn, hostname string, destAddr string, ca *BoxCA, secrets []SecretConfig, upstreamTLSConfig ...*tls.Config) { + cert, err := ca.GenerateHostCert(hostname) + if err != nil { + logrus.WithError(err).WithField("hostname", hostname).Error("MITM: cert generation failed") + guestConn.Close() + return + } + + tlsGuest := tls.Server(guestConn, &tls.Config{ + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return cert, nil + }, + NextProtos: []string{"h2", "http/1.1"}, + }) + + upstreamTransport := &http.Transport{ + ForceAttemptHTTP2: true, + TLSClientConfig: resolveUpstreamTLS(hostname, upstreamTLSConfig...), + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: upstreamDialTimeout}).DialContext(ctx, network, destAddr) + }, + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = "https" + req.URL.Host = hostname + req.Host = hostname // HTTP/1.1 Host header must match + // Headers substituted here; body substituted in secretTransport.RoundTrip + substituteHeaders(req, secrets) + }, + Transport: &secretTransport{ + inner: upstreamTransport, + secrets: secrets, + }, + FlushInterval: -1, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logrus.WithFields(logrus.Fields{ + "hostname": hostname, + "path": r.URL.Path, + "error": err, + }).Warn("MITM: upstream error") + w.WriteHeader(http.StatusBadGateway) + }, + } + + if err := tlsGuest.Handshake(); err != nil { + logrus.WithError(err).WithField("hostname", hostname).Debug("MITM: TLS handshake failed") + guestConn.Close() + return + } + + if tlsGuest.ConnectionState().NegotiatedProtocol == "h2" { + h2srv := &http2.Server{} + h2srv.ServeConn(tlsGuest, &http2.ServeConnOpts{Handler: proxy}) + } else { + // HTTP/1.1: use http.Server with a proper shutdown mechanism. + // After the single connection closes, shut down the server to avoid + // leaking a goroutine blocked in Accept(). + listener := newSingleConnListener(tlsGuest) + srv := &http.Server{Handler: proxy} + srv.Serve(listener) //nolint:errcheck + // Serve returns when the connection closes — shut down to release resources + srv.Close() + } +} + +// singleConnListener serves exactly one pre-accepted connection as a net.Listener. +type singleConnListener struct { + ch chan net.Conn + addr net.Addr + closed chan struct{} +} + +func newSingleConnListener(conn net.Conn) *singleConnListener { + l := &singleConnListener{ + ch: make(chan net.Conn, 1), + addr: conn.LocalAddr(), + closed: make(chan struct{}), + } + l.ch <- conn + return l +} + +func (l *singleConnListener) Accept() (net.Conn, error) { + select { + case conn := <-l.ch: + return conn, nil + case <-l.closed: + return nil, net.ErrClosed + } +} + +func (l *singleConnListener) Close() error { + select { + case <-l.closed: + default: + close(l.closed) + } + return nil +} + +func (l *singleConnListener) Addr() net.Addr { return l.addr } + +// secretTransport wraps http.RoundTripper to inject streaming body replacement. +type secretTransport struct { + inner http.RoundTripper + secrets []SecretConfig +} + +func (t *secretTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil && len(t.secrets) > 0 { + req.Body = newStreamingReplacer(req.Body, t.secrets) + req.ContentLength = -1 + req.Header.Del("Content-Length") + } + return t.inner.RoundTrip(req) +} + diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_proxy_test.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_proxy_test.go new file mode 100644 index 00000000..d21a3b98 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_proxy_test.go @@ -0,0 +1,691 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/net/http2" + "golang.org/x/sync/errgroup" +) + +// startTestUpstream starts a local HTTPS server that uses the provided handler. +// Returns the server, its address (host:port), and a cleanup function. +func startTestUpstream(t *testing.T, handler http.HandlerFunc) (addr string, cleanup func()) { + t.Helper() + srv := httptest.NewTLSServer(handler) + // Extract host:port from the server URL (strip https://) + addr = strings.TrimPrefix(srv.URL, "https://") + return addr, srv.Close +} + +// dialThroughMITM creates an in-memory pipe, starts mitmAndForward on the server side, +// and returns an http.Client configured to speak through the MITM proxy with the box CA trusted. +func dialThroughMITM(t *testing.T, ca *BoxCA, hostname, destAddr string, secrets []SecretConfig) *http.Client { + t.Helper() + return dialThroughMITMWithProto(t, ca, hostname, destAddr, secrets, "") +} + +// dialThroughMITMH2 creates a client that forces HTTP/2 via h2.Transport. +func dialThroughMITMH2(t *testing.T, ca *BoxCA, hostname, destAddr string, secrets []SecretConfig) *http.Client { + t.Helper() + return dialThroughMITMWithProto(t, ca, hostname, destAddr, secrets, "h2") +} + +func dialThroughMITMWithProto(t *testing.T, ca *BoxCA, hostname, destAddr string, secrets []SecretConfig, forceProto string) *http.Client { + t.Helper() + + caPool, _ := ca.CACertPool() + + guest, proxy := net.Pipe() + go mitmAndForward(proxy, hostname, destAddr, ca, secrets, &tls.Config{InsecureSkipVerify: true}) + + nextProtos := []string{"http/1.1"} + if forceProto == "h2" { + nextProtos = []string{"h2", "http/1.1"} + } + + tlsCfg := &tls.Config{ + ServerName: hostname, + RootCAs: caPool, + NextProtos: nextProtos, + InsecureSkipVerify: caPool == nil, + } + + if forceProto == "h2" { + // For HTTP/2: do TLS handshake once, then use http2.Transport + // which natively multiplexes on a single connection. + tlsConn := tls.Client(guest, tlsCfg) + h2Transport := &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) { + return tlsConn, nil + }, + TLSClientConfig: tlsCfg, + } + return &http.Client{ + Transport: h2Transport, + Timeout: 10 * time.Second, + } + } + + // For HTTP/1.1: single TLS connection over the pipe. + // We do TLS once; the transport reuses it for keep-alive. + var h1Once sync.Once + var h1Conn net.Conn + var h1Err error + transport := &http.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + h1Once.Do(func() { + h1Conn = tls.Client(guest, tlsCfg) + // Don't handshake here — let transport do it + }) + if h1Err != nil { + return nil, h1Err + } + return h1Conn, nil + }, + } + + return &http.Client{ + Transport: transport, + Timeout: 10 * time.Second, + } +} + +func testSecrets() []SecretConfig { + return []SecretConfig{ + { + Name: "k", + Hosts: []string{"api.example.com"}, + Placeholder: "", + Value: "real-value", + }, + } +} + +// --- HTTP/1.1 Tests --- + +func TestMitmProxy_HTTP1_BasicRequest(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + // Upstream echoes the Authorization header + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + fmt.Fprintf(w, `{"authorization":%q}`, auth) + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/test", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer ") + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal("failed to read response:", err) + } + + got := string(body) + if !strings.Contains(got, "real-value") { + t.Errorf("expected upstream to receive substituted value, got: %s", got) + } + if strings.Contains(got, "BOXLITE_SECRET") { + t.Errorf("placeholder was not substituted in upstream request: %s", got) + } +} + +func TestMitmProxy_HTTP1_PostWithBody(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + // Upstream echoes request body + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + w.Write(b) + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + bodyStr := `{"key":""}` + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/data", strings.NewReader(bodyStr)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal("failed to read response:", err) + } + + got := string(body) + expected := `{"key":"real-value"}` + if got != expected { + t.Errorf("expected body %q, got %q", expected, got) + } +} + +func TestMitmProxy_HTTP1_KeepAlive(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + var requestCount int + var mu sync.Mutex + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestCount++ + n := requestCount + mu.Unlock() + auth := r.Header.Get("Authorization") + fmt.Fprintf(w, `{"n":%d,"auth":%q}`, n, auth) + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + for i := 0; i < 5; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/test", nil) + if err != nil { + cancel() + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer ") + + resp, err := client.Do(req) + if err != nil { + cancel() + t.Fatalf("request %d failed: %v", i, err) + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + cancel() + + got := string(body) + if !strings.Contains(got, "real-value") { + t.Errorf("request %d: expected substitution, got: %s", i, got) + } + } + + mu.Lock() + if requestCount != 5 { + t.Errorf("expected 5 requests at upstream, got %d", requestCount) + } + mu.Unlock() +} + +// --- HTTP/2 Tests --- + +func TestMitmProxy_HTTP2_BasicRequest(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + proto := r.Proto + auth := r.Header.Get("Authorization") + fmt.Fprintf(w, `{"proto":%q,"auth":%q}`, proto, auth) + }) + defer cleanup() + + client := dialThroughMITMH2(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/test", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer ") + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal("failed to read response:", err) + } + + got := string(body) + if !strings.Contains(got, "real-value") { + t.Errorf("expected substitution, got: %s", got) + } +} + +func TestMitmProxy_HTTP2_MultiplexedStreams(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + fmt.Fprintf(w, `{"auth":%q}`, auth) + }) + defer cleanup() + + client := dialThroughMITMH2(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + g, ctx := errgroup.WithContext(ctx) + + for i := 0; i < 10; i++ { + i := i + g.Go(func() error { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/test", nil) + if err != nil { + return fmt.Errorf("request %d: create failed: %w", i, err) + } + req.Header.Set("Authorization", "Bearer ") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request %d: failed: %w", i, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("request %d: read failed: %w", i, err) + } + + got := string(body) + if !strings.Contains(got, "real-value") { + return fmt.Errorf("request %d: expected substitution, got: %s", i, got) + } + return nil + }) + } + + if err := g.Wait(); err != nil { + t.Fatal(err) + } +} + +// --- Streaming Tests --- + +func TestMitmProxy_ChunkedRequestBody(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + w.Write(b) + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Chunked body with placeholder split across chunks won't happen here, + // but the placeholder is embedded in a chunk. + body := strings.NewReader("chunk1--chunk2") + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/data", body) + if err != nil { + t.Fatal(err) + } + // Don't set Content-Length to force chunked encoding + req.ContentLength = -1 + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal("failed to read response:", err) + } + + expected := "chunk1-real-value-chunk2" + if string(got) != expected { + t.Errorf("expected %q, got %q", expected, string(got)) + } +} + +func TestMitmProxy_StreamingRequestBody(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + received := make(chan string, 1) + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + received <- string(b) + w.WriteHeader(http.StatusOK) + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + pr, pw := io.Pipe() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/stream", pr) + if err != nil { + t.Fatal(err) + } + req.ContentLength = -1 // streaming + + // Write data in background + go func() { + pw.Write([]byte("prefix-")) + time.Sleep(50 * time.Millisecond) + pw.Write([]byte("")) + time.Sleep(50 * time.Millisecond) + pw.Write([]byte("-suffix")) + pw.Close() + }() + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + select { + case got := <-received: + expected := "prefix-real-value-suffix" + if got != expected { + t.Errorf("expected %q, got %q", expected, got) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for upstream to receive body") + } +} + +func TestMitmProxy_LargeResponseStreaming(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + const responseSize = 10 * 1024 * 1024 // 10MB + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + // Write 10MB in 64KB chunks + chunk := make([]byte, 64*1024) + for i := range chunk { + chunk[i] = byte(i % 256) + } + written := 0 + for written < responseSize { + n := responseSize - written + if n > len(chunk) { + n = len(chunk) + } + w.Write(chunk[:n]) + written += n + } + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/large", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + // Read all without storing in memory (stream through) + n, err := io.Copy(io.Discard, resp.Body) + if err != nil { + t.Fatal("failed to read large response:", err) + } + + if n != int64(responseSize) { + t.Errorf("expected %d bytes, got %d", responseSize, n) + } +} + +// --- Content-Length Tests --- + +func TestMitmProxy_ContentLengthAdjustment(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + // Echo the body and the Content-Length the upstream saw + fmt.Fprintf(w, "body=%s;cl=%d", string(b), r.ContentLength) + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Body with placeholder - after substitution length changes + bodyStr := `{"token":""}` + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/data", strings.NewReader(bodyStr)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + got, _ := io.ReadAll(resp.Body) + gotStr := string(got) + + // Upstream should have received the substituted body completely + if !strings.Contains(gotStr, `body={"token":"real-value"}`) { + t.Errorf("expected substituted body at upstream, got: %s", gotStr) + } +} + +// --- Error Handling Tests --- + +func TestMitmProxy_UpstreamError(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + // Use a port that is definitely closed + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + closedAddr := ln.Addr().String() + ln.Close() // Close immediately so nothing is listening + + client := dialThroughMITM(t, ca, "api.example.com", closedAddr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/test", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := client.Do(req) + if err != nil { + // Connection error is acceptable + return + } + defer resp.Body.Close() + + // If we got a response, it should be an error status (502) + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("expected 502 or connection error, got status %d", resp.StatusCode) + } +} + +func TestMitmProxy_UpstreamSlowResponse(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(500 * time.Millisecond) // Simulate slow upstream + fmt.Fprint(w, "slow-response") + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + client.Timeout = 5 * time.Second // Longer than the upstream delay + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/slow", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatal("request should have succeeded despite slow upstream:", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if string(body) != "slow-response" { + t.Errorf("expected 'slow-response', got %q", string(body)) + } +} + +func TestMitmProxy_GuestDisconnect(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) // Very slow — guest will disconnect first + fmt.Fprint(w, "too-late") + }) + defer cleanup() + + goroutinesBefore := runtime.NumGoroutine() + + guestConn, proxyConn := net.Pipe() + + go mitmAndForward(proxyConn, "api.example.com", addr, ca, secrets, &tls.Config{InsecureSkipVerify: true}) + + // Close guest side immediately to simulate disconnect + guestConn.Close() + + // Wait for goroutines to settle + time.Sleep(500 * time.Millisecond) + + goroutinesAfter := runtime.NumGoroutine() + // Allow some tolerance (±5 goroutines for runtime overhead) + if goroutinesAfter > goroutinesBefore+5 { + t.Errorf("possible goroutine leak: before=%d, after=%d", goroutinesBefore, goroutinesAfter) + } +} + +func TestMitmProxy_EmptyBody(t *testing.T) { + ca := newTestCA(t) + + secrets := testSecrets() + + addr, cleanup := startTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "ok") + }) + defer cleanup() + + client := dialThroughMITM(t, ca, "api.example.com", addr, secrets) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/empty", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatal("GET with no body should not panic:", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if string(body) != "ok" { + t.Errorf("expected 'ok', got %q", string(body)) + } +} + +// --- Routing Tests --- + +func TestMitmProxy_NoSecretHost_Passthrough(t *testing.T) { + // Verify that SecretHostMatcher correctly identifies non-secret hosts + secrets := testSecrets() + matcher := NewSecretHostMatcher(secrets) + + // api.example.com is in secrets' Hosts list + if !matcher.Matches("api.example.com") { + // Note: this will fail with stub since Matches always returns false. + // That's expected — will pass once implemented. + t.Error("expected api.example.com to match as a secret host") + } + + // random.example.com is NOT in any secret's Hosts list + if matcher.Matches("random.example.com") { + t.Error("expected random.example.com to NOT match as a secret host") + } +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_replacer.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_replacer.go new file mode 100644 index 00000000..349bc75d --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_replacer.go @@ -0,0 +1,199 @@ +package main + +import ( + "bytes" + "io" + "strings" +) + +const replacerBufSize = 64 * 1024 + +// SecretConfig holds a secret's placeholder mapping for substitution. +type SecretConfig struct { + Name string `json:"name"` + Hosts []string `json:"hosts"` + Placeholder string `json:"placeholder"` + Value string `json:"value"` +} + +func (s SecretConfig) String() string { + return "SecretConfig{Name:" + s.Name + ", Placeholder:" + s.Placeholder + ", Value:[REDACTED]}" +} + +type streamingReplacer struct { + src io.ReadCloser + replacer *strings.Replacer + buf []byte // internal read buffer for boundary handling + bufLen int // valid bytes in buf + maxPlaceholder int + prefixBytes []byte // first byte of each unique placeholder (for boundary detection) + + // overflow holds replaced output that didn't fit in the caller's buffer. + overflow []byte + overPos int + + srcDone bool + closed bool +} + +// newStreamingReplacer wraps body with streaming placeholder substitution. +// Returns body unchanged if secrets is empty or body is nil. +func newStreamingReplacer(body io.ReadCloser, secrets []SecretConfig) io.ReadCloser { + if body == nil || len(secrets) == 0 { + return body + } + + maxPH := 0 + pairs := make([]string, 0, len(secrets)*2) + seen := make(map[byte]bool) + for _, s := range secrets { + pairs = append(pairs, s.Placeholder, s.Value) + if len(s.Placeholder) > maxPH { + maxPH = len(s.Placeholder) + } + if len(s.Placeholder) > 0 { + seen[s.Placeholder[0]] = true + } + } + prefixBytes := make([]byte, 0, len(seen)) + for b := range seen { + prefixBytes = append(prefixBytes, b) + } + + return &streamingReplacer{ + src: body, + replacer: strings.NewReplacer(pairs...), + buf: make([]byte, replacerBufSize+maxPH), + maxPlaceholder: maxPH, + prefixBytes: prefixBytes, + } +} + +func (s *streamingReplacer) Read(p []byte) (int, error) { + if s.closed { + return 0, io.ErrClosedPipe + } + + // Drain overflow from previous call + if s.overPos < len(s.overflow) { + n := copy(p, s.overflow[s.overPos:]) + s.overPos += n + if s.overPos >= len(s.overflow) { + s.overflow = s.overflow[:0] + s.overPos = 0 + } + return n, nil + } + + if s.srcDone && s.bufLen == 0 { + return 0, io.EOF + } + + // Read from src until we have enough data to emit safely. + // Never return (0, nil) — that violates io.Reader expectations and + // causes io.ReadAll to spin. + for { + if !s.srcDone { + n, err := s.src.Read(s.buf[s.bufLen:]) + s.bufLen += n + if err == io.EOF { + s.srcDone = true + } else if err != nil { + return 0, err + } + } + + if s.bufLen == 0 { + return 0, io.EOF + } + + if s.srcDone { + // Final chunk: replace and emit all + replaced := s.replacer.Replace(string(s.buf[:s.bufLen])) + s.bufLen = 0 + n := copy(p, replaced) + if n < len(replaced) { + s.overflow = append(s.overflow[:0], replaced[n:]...) + s.overPos = 0 + } else if len(s.overflow) == 0 { + return n, io.EOF + } + return n, nil + } + + safeEnd := s.safeBoundary() + if safeEnd > 0 { + break // enough data — proceed to replacement + } + // Not enough data yet — loop to read more from src + } + + safeEnd := s.safeBoundary() + + safe := s.buf[:safeEnd] + var n int + + if !s.containsPrefixByte(safe) { + // Fast path: no placeholder prefix byte found, copy raw bytes directly + n = copy(p, safe) + if n < safeEnd { + s.overflow = append(s.overflow[:0], safe[n:]...) + s.overPos = 0 + } + } else { + // Slow path: run replacer + replaced := s.replacer.Replace(string(safe)) + n = copy(p, replaced) + if n < len(replaced) { + s.overflow = append(s.overflow[:0], replaced[n:]...) + s.overPos = 0 + } + } + + // Shift remaining bytes to front of buffer + remaining := s.bufLen - safeEnd + copy(s.buf, s.buf[safeEnd:s.bufLen]) + s.bufLen = remaining + + return n, nil +} + +// safeBoundary returns the number of bytes from the start of buf that can +// safely be replaced and emitted. +func (s *streamingReplacer) safeBoundary() int { + if s.bufLen <= s.maxPlaceholder-1 { + return 0 + } + + dangerStart := s.bufLen - (s.maxPlaceholder - 1) + danger := s.buf[dangerStart:s.bufLen] + // Return the earliest occurrence of any prefix byte in the danger zone. + minIdx := len(danger) + for _, b := range s.prefixBytes { + if idx := bytes.IndexByte(danger, b); idx >= 0 && idx < minIdx { + minIdx = idx + } + } + if minIdx < len(danger) { + return dangerStart + minIdx + } + return s.bufLen +} + +// containsPrefixByte checks if data contains any placeholder first byte. +func (s *streamingReplacer) containsPrefixByte(data []byte) bool { + for _, b := range s.prefixBytes { + if bytes.IndexByte(data, b) >= 0 { + return true + } + } + return false +} + +func (s *streamingReplacer) Close() error { + s.closed = true + if s.src != nil { + return s.src.Close() + } + return nil +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_replacer_test.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_replacer_test.go new file mode 100644 index 00000000..1a76e4db --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_replacer_test.go @@ -0,0 +1,618 @@ +package main + +import ( + "bytes" + "io" + "math/rand" + "runtime" + "strings" + "testing" +) + +// --- Helper types --- + +// limitedReader wraps an io.Reader and returns at most n bytes per Read call. +type limitedReader struct { + r io.Reader + n int +} + +func (lr *limitedReader) Read(p []byte) (int, error) { + if len(p) > lr.n { + p = p[:lr.n] + } + return lr.r.Read(p) +} + +// trackingCloser records whether Close() was called. +type trackingCloser struct { + r io.Reader + closed bool +} + +func (tc *trackingCloser) Read(p []byte) (int, error) { + return tc.r.Read(p) +} + +func (tc *trackingCloser) Close() error { + tc.closed = true + return nil +} + +// --- Helper functions --- + +func makeSecrets(pairs ...string) []SecretConfig { + var secrets []SecretConfig + for i := 0; i+1 < len(pairs); i += 2 { + secrets = append(secrets, SecretConfig{ + Name: pairs[i], + Placeholder: pairs[i], + Value: pairs[i+1], + }) + } + return secrets +} + +func assertBytesEqual(t *testing.T, expected, actual []byte, msg string) { + t.Helper() + if !bytes.Equal(expected, actual) { + // Truncate output for large bodies + expStr := string(expected) + actStr := string(actual) + if len(expStr) > 200 { + expStr = expStr[:200] + "...(truncated)" + } + if len(actStr) > 200 { + actStr = actStr[:200] + "...(truncated)" + } + t.Errorf("%s\nexpected: %q\nactual: %q", msg, expStr, actStr) + } +} + +// --- Basic functionality --- + +func TestStreamingReplacer_NilBody(t *testing.T) { + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "sk-123", + }} + result := newStreamingReplacer(nil, secrets) + if result != nil { + t.Error("expected nil when body is nil") + } +} + +func TestStreamingReplacer_NoSecrets(t *testing.T) { + body := io.NopCloser(strings.NewReader("hello")) + result := newStreamingReplacer(body, []SecretConfig{}) + if result != body { + t.Error("expected original body returned when secrets is empty") + } + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte("hello"), data, "body content should be unchanged") +} + +func TestStreamingReplacer_EmptyBody(t *testing.T) { + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "sk-123", + }} + body := io.NopCloser(strings.NewReader("")) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data) != 0 { + t.Errorf("expected 0 bytes, got %d", len(data)) + } +} + +func TestStreamingReplacer_NoPlaceholderPresent(t *testing.T) { + input := "just a normal request body" + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "sk-123", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(input), data, "output should be identical byte-for-byte") +} + +// --- Single placeholder --- + +func TestStreamingReplacer_SingleInSmallBody(t *testing.T) { + input := `{"auth":""}` + expected := `{"auth":"sk-real-key-123"}` + secrets := []SecretConfig{{ + Name: "openai", + Placeholder: "", + Value: "sk-real-key-123", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "placeholder should be replaced") +} + +func TestStreamingReplacer_PlaceholderAtStart(t *testing.T) { + input := "rest of body" + expected := "sk-123rest of body" + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "sk-123", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "placeholder at start should be replaced") +} + +func TestStreamingReplacer_PlaceholderAtEnd(t *testing.T) { + input := "body prefix " + expected := "body prefix sk-123" + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "sk-123", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "placeholder at end should be replaced") +} + +func TestStreamingReplacer_PlaceholderIsEntireBody(t *testing.T) { + input := "" + expected := "sk-123" + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "sk-123", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "placeholder as entire body should be replaced") +} + +// --- Multiple placeholders --- + +func TestStreamingReplacer_MultipleSamePlaceholder(t *testing.T) { + input := " and again" + expected := "val and val again" + secrets := []SecretConfig{{ + Name: "k", + Placeholder: "", + Value: "val", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "same placeholder should be replaced multiple times") +} + +func TestStreamingReplacer_MultipleDifferentSecrets(t *testing.T) { + input := "key1=&key2=" + expected := "key1=val-a&key2=val-b" + secrets := []SecretConfig{ + {Name: "a", Placeholder: "", Value: "val-a"}, + {Name: "b", Placeholder: "", Value: "val-b"}, + } + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "different placeholders should each be replaced") +} + +func TestStreamingReplacer_AdjacentPlaceholders(t *testing.T) { + input := "" + expected := "val-aval-b" + secrets := []SecretConfig{ + {Name: "a", Placeholder: "", Value: "val-a"}, + {Name: "b", Placeholder: "", Value: "val-b"}, + } + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "adjacent placeholders should both be replaced") +} + +// --- Boundary spanning --- + +func TestStreamingReplacer_BoundarySpan(t *testing.T) { + // Use a reader that returns exactly 16 bytes per Read. + // Place placeholder so it spans across the 16-byte boundary. + placeholder := "" + value := "sk-replaced" + // 10 bytes of padding + 20-byte placeholder = placeholder starts at offset 10, spans bytes 10-29 + input := "0123456789" + placeholder + "tail" + expected := "0123456789" + value + "tail" + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: placeholder, + Value: value, + }} + lr := &limitedReader{r: strings.NewReader(input), n: 16} + body := io.NopCloser(lr) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "placeholder spanning chunk boundary should be replaced") +} + +func TestStreamingReplacer_BoundaryAtOverlapEdge(t *testing.T) { + // Place placeholder at exactly (chunkSize - maxPlaceholder + 1) + placeholder := "" + value := "replaced-value" + maxPH := len(placeholder) + offset := replacerBufSize - maxPH + 1 + + var buf bytes.Buffer + // Write padding up to offset + for buf.Len() < offset { + buf.WriteByte('A') + } + buf.WriteString(placeholder) + buf.WriteString("end") + + input := buf.String() + expectedOutput := strings.Repeat("A", offset) + value + "end" + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: placeholder, + Value: value, + }} + + // Use a reader that returns chunks of replacerBufSize + lr := &limitedReader{r: strings.NewReader(input), n: replacerBufSize} + body := io.NopCloser(lr) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expectedOutput), data, "placeholder at overlap edge should be replaced") +} + +func TestStreamingReplacer_OneByteReads(t *testing.T) { + placeholder := "" + value := "secret-val" + input := "before" + placeholder + "after" + expected := "before" + value + "after" + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: placeholder, + Value: value, + }} + lr := &limitedReader{r: strings.NewReader(input), n: 1} + body := io.NopCloser(lr) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "one-byte reads should still produce correct substitution") +} + +// --- Large bodies --- + +func TestStreamingReplacer_LargeBody100MB(t *testing.T) { + if testing.Short() { + t.Skip("skipping large body test in short mode") + } + + placeholder := "" + value := "sk-replaced-value" + totalSize := 100 * 1024 * 1024 // 100MB + placeholderOffset := 50 * 1024 * 1024 + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: placeholder, + Value: value, + }} + + // Custom reader that generates data on the fly without allocating full buffer + pr, pw := io.Pipe() + go func() { + written := 0 + chunk := bytes.Repeat([]byte("X"), 64*1024) + for written < totalSize { + if written <= placeholderOffset && written+len(chunk) > placeholderOffset { + // Write up to placeholder offset + before := placeholderOffset - written + if before > 0 { + pw.Write(chunk[:before]) + written += before + } + pw.Write([]byte(placeholder)) + written += len(placeholder) + continue + } + remaining := totalSize - written + if remaining < len(chunk) { + chunk = chunk[:remaining] + } + pw.Write(chunk) + written += len(chunk) + } + pw.Close() + }() + + result := newStreamingReplacer(pr, secrets) + + // Read in fixed-size chunks to avoid io.ReadAll's doubling allocations. + // We only keep the last 1MB to verify the substitution happened while + // measuring that the replacer itself uses constant memory. + var memBefore, memAfter runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&memBefore) + + chunk := make([]byte, 256*1024) // 256KB read buffer + totalRead := 0 + foundValue := false + foundPlaceholder := false + tail := make([]byte, 0, 1024*1024) // keep last 1MB for verification + + for { + n, err := result.Read(chunk) + if n > 0 { + totalRead += n + // Check this chunk for placeholder/value + if bytes.Contains(chunk[:n], []byte(value)) { + foundValue = true + } + if bytes.Contains(chunk[:n], []byte(placeholder)) { + foundPlaceholder = true + } + // Keep tail for final verification + tail = append(tail, chunk[:n]...) + if len(tail) > 1024*1024 { + tail = tail[len(tail)-1024*1024:] + } + } + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + runtime.GC() + runtime.ReadMemStats(&memAfter) + + // Verify substitution happened + expectedLen := totalSize - len(placeholder) + len(value) + if totalRead != expectedLen { + t.Errorf("expected length %d, got %d", expectedLen, totalRead) + } + + if foundPlaceholder { + t.Error("placeholder should have been replaced in output") + } + if !foundValue { + t.Error("replacement value should be present in output") + } + + // Now we can accurately measure: heap growth should be just the replacer + // internals (~64KB buffer + overlap), not the full output. + heapGrowth := int64(memAfter.HeapInuse) - int64(memBefore.HeapInuse) + if heapGrowth > 2*1024*1024 { + t.Errorf("replacer heap overhead exceeded 2MB: %d bytes", heapGrowth) + } +} + +func TestStreamingReplacer_LargeBodyPlaceholderInFirst1KB(t *testing.T) { + placeholder := "" + value := "replaced" + // 100 bytes of prefix + placeholder + padding to 1MB + prefix := strings.Repeat("A", 100) + remaining := 1024*1024 - 100 - len(placeholder) + input := prefix + placeholder + strings.Repeat("B", remaining) + expected := prefix + value + strings.Repeat("B", remaining) + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: placeholder, + Value: value, + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "placeholder in first 1KB of large body should be replaced") +} + +func TestStreamingReplacer_LargeBodyNoPlaceholder(t *testing.T) { + size := 10 * 1024 * 1024 // 10MB + input := strings.Repeat("X", size) + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "val", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data) != size { + t.Errorf("expected output length %d, got %d", size, len(data)) + } +} + +// --- Edge cases --- + +func TestStreamingReplacer_UnconfiguredPlaceholderName(t *testing.T) { + input := "" + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "val", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(input), data, "unconfigured placeholder should remain unchanged") +} + +func TestStreamingReplacer_BrokenPrefix(t *testing.T) { + input := "", + Value: "val", + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(input), data, "broken prefix should remain unchanged") +} + +func TestStreamingReplacer_BinaryBody(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + binData := make([]byte, 4096) + rng.Read(binData) + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "val", + }} + body := io.NopCloser(bytes.NewReader(binData)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, binData, data, "binary body should pass through unchanged") +} + +func TestStreamingReplacer_ReplacementLonger(t *testing.T) { + placeholder := "" // 20 chars + value := strings.Repeat("X", 100) // 100 chars + input := "before" + placeholder + "after" + expected := "before" + value + "after" + + secrets := []SecretConfig{{ + Name: "key", + Placeholder: placeholder, + Value: value, + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "longer replacement should produce correct output") + expectedLen := len("before") + 100 + len("after") + if len(data) != expectedLen { + t.Errorf("expected output length %d, got %d", expectedLen, len(data)) + } +} + +func TestStreamingReplacer_ReplacementShorter(t *testing.T) { + placeholder := "" // 34 chars > 30 + value := "abc" // 3 chars + input := "before" + placeholder + "after" + expected := "before" + value + "after" + + secrets := []SecretConfig{{ + Name: "a", + Placeholder: placeholder, + Value: value, + }} + body := io.NopCloser(strings.NewReader(input)) + result := newStreamingReplacer(body, secrets) + data, err := io.ReadAll(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertBytesEqual(t, []byte(expected), data, "shorter replacement should produce correct output") + expectedLen := len("before") + 3 + len("after") + if len(data) != expectedLen { + t.Errorf("expected output length %d, got %d", expectedLen, len(data)) + } +} + +func TestStreamingReplacer_ClosePropagates(t *testing.T) { + tc := &trackingCloser{r: strings.NewReader("hello")} + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "val", + }} + result := newStreamingReplacer(tc, secrets) + err := result.Close() + if err != nil { + t.Fatalf("unexpected error on Close: %v", err) + } + if !tc.closed { + t.Error("Close should propagate to underlying reader") + } +} + +func TestStreamingReplacer_ReadAfterClose(t *testing.T) { + secrets := []SecretConfig{{ + Name: "key", + Placeholder: "", + Value: "val", + }} + body := io.NopCloser(strings.NewReader("hello world")) + result := newStreamingReplacer(body, secrets) + result.Close() + buf := make([]byte, 100) + _, err := result.Read(buf) + if err == nil { + t.Error("Read after Close should return an error") + } +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_test.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_test.go new file mode 100644 index 00000000..86997a81 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_test.go @@ -0,0 +1,878 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "testing" + "time" +) + +// ============================================================================ +// Section A: BoxCA Tests +// ============================================================================ + +func TestBoxCA_Creation(t *testing.T) { + ca := newTestCA(t) + + if !ca.cert.IsCA { + t.Error("CA certificate IsCA should be true") + } + if ca.cert.PublicKeyAlgorithm != x509.ECDSA { + t.Errorf("expected ECDSA, got %v", ca.cert.PublicKeyAlgorithm) + } + if ca.cert.KeyUsage&x509.KeyUsageCertSign == 0 { + t.Error("CA cert should have KeyUsageCertSign") + } + if ca.cert.Subject.CommonName == "" { + t.Error("CA cert CommonName should be non-empty") + } + now := time.Now() + if now.Before(ca.cert.NotBefore) { + t.Errorf("CA cert NotBefore (%v) is in the future", ca.cert.NotBefore) + } + if now.After(ca.cert.NotAfter) { + t.Errorf("CA cert NotAfter (%v) is in the past", ca.cert.NotAfter) + } +} + +func TestBoxCA_CACertPEM(t *testing.T) { + ca := newTestCA(t) + + pemBytes := ca.CACertPEM() + if len(pemBytes) == 0 { + t.Fatal("CACertPEM() returned empty bytes") + } + + block, _ := pem.Decode(pemBytes) + if block == nil { + t.Fatal("pem.Decode returned nil block") + } + if block.Type != "CERTIFICATE" { + t.Errorf("expected PEM type CERTIFICATE, got %q", block.Type) + } + + parsed, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("x509.ParseCertificate error: %v", err) + } + if parsed.SerialNumber.Cmp(ca.cert.SerialNumber) != 0 { + t.Error("parsed cert serial number does not match ca.cert") + } +} + +func TestBoxCA_GenerateHostCert_Valid(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("api.openai.com") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf cert: %v", err) + } + + if !containsString(leaf.DNSNames, "api.openai.com") { + t.Errorf("DNSNames %v should contain api.openai.com", leaf.DNSNames) + } + if leaf.IsCA { + t.Error("leaf cert should not be CA") + } + if leaf.PublicKeyAlgorithm != x509.ECDSA { + t.Errorf("expected ECDSA, got %v", leaf.PublicKeyAlgorithm) + } + + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: pool, + DNSName: "api.openai.com", + }); err != nil { + t.Errorf("cert verification failed: %v", err) + } +} + +func TestBoxCA_GenerateHostCert_Wildcard(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("*.openai.com") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf cert: %v", err) + } + + if !containsString(leaf.DNSNames, "*.openai.com") { + t.Errorf("DNSNames %v should contain *.openai.com", leaf.DNSNames) + } + + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: pool, + DNSName: "api.openai.com", + }); err != nil { + t.Errorf("wildcard cert should verify for api.openai.com: %v", err) + } +} + +func TestBoxCA_GenerateHostCert_IPAddress(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("192.168.1.1") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf cert: %v", err) + } + + expectedIP := net.ParseIP("192.168.1.1") + found := false + for _, ip := range leaf.IPAddresses { + if ip.Equal(expectedIP) { + found = true + break + } + } + if !found { + t.Errorf("IPAddresses %v should contain 192.168.1.1", leaf.IPAddresses) + } + if len(leaf.DNSNames) != 0 { + t.Errorf("IP cert should have empty DNSNames, got %v", leaf.DNSNames) + } +} + +func TestBoxCA_GenerateHostCert_Localhost(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("localhost") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf cert: %v", err) + } + + if !containsString(leaf.DNSNames, "localhost") { + t.Errorf("DNSNames %v should contain localhost", leaf.DNSNames) + } + + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: pool, + DNSName: "localhost", + }); err != nil { + t.Errorf("localhost cert verification failed: %v", err) + } +} + +func TestBoxCA_CertCache_Hit(t *testing.T) { + ca := newTestCA(t) + + cert1, err := ca.GenerateHostCert("api.openai.com") + if err != nil { + t.Fatalf("first call error: %v", err) + } + cert2, err := ca.GenerateHostCert("api.openai.com") + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if cert1 != cert2 { + t.Error("same hostname should return same *tls.Certificate pointer") + } +} + +func TestBoxCA_CertCache_DifferentHosts(t *testing.T) { + ca := newTestCA(t) + + certA, err := ca.GenerateHostCert("a.com") + if err != nil { + t.Fatalf("a.com error: %v", err) + } + certB, err := ca.GenerateHostCert("b.com") + if err != nil { + t.Fatalf("b.com error: %v", err) + } + + if certA == certB { + t.Error("different hostnames should return different cert pointers") + } + + leafA, _ := x509.ParseCertificate(certA.Certificate[0]) + leafB, _ := x509.ParseCertificate(certB.Certificate[0]) + if leafA.SerialNumber.Cmp(leafB.SerialNumber) == 0 { + t.Error("different hostnames should have different serial numbers") + } +} + +func TestBoxCA_CertCache_ConcurrentSameHost(t *testing.T) { + ca := newTestCA(t) + + const n = 100 + certs := make([]*tls.Certificate, n) + errs := make([]error, n) + var wg sync.WaitGroup + wg.Add(n) + + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + certs[idx], errs[idx] = ca.GenerateHostCert("api.openai.com") + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d error: %v", i, err) + } + } + + first := certs[0] + for i := 1; i < n; i++ { + if certs[i] != first { + t.Errorf("goroutine %d got different pointer than goroutine 0", i) + } + } +} + +func TestBoxCA_CertCache_ConcurrentDifferentHosts(t *testing.T) { + ca := newTestCA(t) + + const n = 100 + const numHosts = 10 + hosts := make([]string, numHosts) + for i := 0; i < numHosts; i++ { + hosts[i] = fmt.Sprintf("host%d.example.com", i) + } + + certs := make([]*tls.Certificate, n) + errs := make([]error, n) + var wg sync.WaitGroup + wg.Add(n) + + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + certs[idx], errs[idx] = ca.GenerateHostCert(hosts[idx%numHosts]) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d error: %v", i, err) + } + } + + unique := make(map[*tls.Certificate]bool) + for _, c := range certs { + unique[c] = true + } + if len(unique) != numHosts { + t.Errorf("expected %d unique certs, got %d", numHosts, len(unique)) + } +} + +func TestBoxCA_TLSHandshake_H1(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("test.example.com") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + serverTLSConf := &tls.Config{ + Certificates: []tls.Certificate{*tlsCert}, + NextProtos: []string{"http/1.1"}, + } + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + clientTLSConf := &tls.Config{ + RootCAs: pool, + ServerName: "test.example.com", + NextProtos: []string{"http/1.1"}, + } + + errCh := make(chan error, 2) + go func() { + srv := tls.Server(serverConn, serverTLSConf) + errCh <- srv.Handshake() + }() + go func() { + cli := tls.Client(clientConn, clientTLSConf) + errCh <- cli.Handshake() + }() + + for i := 0; i < 2; i++ { + if hsErr := <-errCh; hsErr != nil { + t.Errorf("handshake error: %v", hsErr) + } + } +} + +func TestBoxCA_TLSHandshake_H2(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("test.example.com") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + serverTLSConf := &tls.Config{ + Certificates: []tls.Certificate{*tlsCert}, + NextProtos: []string{"h2", "http/1.1"}, + } + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + clientTLSConf := &tls.Config{ + RootCAs: pool, + ServerName: "test.example.com", + NextProtos: []string{"h2", "http/1.1"}, + } + + type hsResult struct { + state tls.ConnectionState + err error + } + + srvCh := make(chan error, 1) + cliCh := make(chan hsResult, 1) + + go func() { + srv := tls.Server(serverConn, serverTLSConf) + srvCh <- srv.Handshake() + }() + go func() { + cli := tls.Client(clientConn, clientTLSConf) + err := cli.Handshake() + cliCh <- hsResult{state: cli.ConnectionState(), err: err} + }() + + if srvErr := <-srvCh; srvErr != nil { + t.Fatalf("server handshake error: %v", srvErr) + } + result := <-cliCh + if result.err != nil { + t.Fatalf("client handshake error: %v", result.err) + } + if result.state.NegotiatedProtocol != "h2" { + t.Errorf("expected h2, got %q", result.state.NegotiatedProtocol) + } +} + +func TestBoxCA_TLSHandshake_UntrustedCA(t *testing.T) { + ca := newTestCA(t) + + tlsCert, err := ca.GenerateHostCert("test.example.com") + if err != nil { + t.Fatalf("GenerateHostCert error: %v", err) + } + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + serverTLSConf := &tls.Config{ + Certificates: []tls.Certificate{*tlsCert}, + } + // Empty RootCAs -- CA is not trusted + clientTLSConf := &tls.Config{ + RootCAs: x509.NewCertPool(), + ServerName: "test.example.com", + } + + errCh := make(chan error, 2) + go func() { + srv := tls.Server(serverConn, serverTLSConf) + errCh <- srv.Handshake() + }() + + cli := tls.Client(clientConn, clientTLSConf) + clientErr := cli.Handshake() + if clientErr == nil { + t.Fatal("expected handshake error with untrusted CA, got nil") + } +} + +// ============================================================================ +// Section B: Header Substitution Tests +// ============================================================================ + +func TestSubstituteHeaders_Authorization(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Bearer ") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "sk-real-key"}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("Authorization") + want := "Bearer sk-real-key" + if got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } +} + +func TestSubstituteHeaders_MultipleHeaders(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Bearer ") + req.Header.Set("X-API-Key", "") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "value-a"}, + {Placeholder: "", Value: "value-b"}, + } + substituteHeaders(req, secrets) + + if got := req.Header.Get("Authorization"); got != "Bearer value-a" { + t.Errorf("Authorization = %q, want %q", got, "Bearer value-a") + } + if got := req.Header.Get("X-API-Key"); got != "value-b" { + t.Errorf("X-API-Key = %q, want %q", got, "value-b") + } +} + +func TestSubstituteHeaders_NoMatch(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Bearer real-key-already") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "sk-real-key"}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("Authorization") + if got != "Bearer real-key-already" { + t.Errorf("Authorization should be unchanged, got %q", got) + } +} + +func TestSubstituteHeaders_CustomHeader(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("X-Custom", "prefix--suffix") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "real-value"}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("X-Custom") + want := "prefix-real-value-suffix" + if got != want { + t.Errorf("X-Custom = %q, want %q", got, want) + } +} + +func TestSubstituteHeaders_MultiValueHeader(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Add("X-Multi", "first-") + req.Header.Add("X-Multi", "second-") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "replaced"}, + } + substituteHeaders(req, secrets) + + vals := req.Header.Values("X-Multi") + if len(vals) != 2 { + t.Fatalf("expected 2 values, got %d", len(vals)) + } + if vals[0] != "first-replaced" { + t.Errorf("vals[0] = %q, want %q", vals[0], "first-replaced") + } + if vals[1] != "second-replaced" { + t.Errorf("vals[1] = %q, want %q", vals[1], "second-replaced") + } +} + +func TestSubstituteHeaders_URLQueryString(t *testing.T) { + u, _ := url.Parse("https://api.example.com/v1?key=&other=foo") + req := &http.Request{ + Header: http.Header{}, + URL: u, + } + + secrets := []SecretConfig{ + {Placeholder: "", Value: "real-value"}, + } + substituteHeaders(req, secrets) + + q := req.URL.Query() + if got := q.Get("key"); got != "real-value" { + t.Errorf("URL query key = %q, want %q", got, "real-value") + } + if got := q.Get("other"); got != "foo" { + t.Errorf("URL query other = %q, want %q", got, "foo") + } +} + +func TestSubstituteHeaders_NoSecrets(t *testing.T) { + u, _ := url.Parse("https://api.example.com/v1?key=val") + req := &http.Request{ + Header: http.Header{}, + URL: u, + } + req.Header.Set("Authorization", "Bearer tok") + + substituteHeaders(req, nil) + + if got := req.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization should be unchanged, got %q", got) + } + if got := req.URL.Query().Get("key"); got != "val" { + t.Errorf("URL query key should be unchanged, got %q", got) + } +} + +func TestSubstituteHeaders_EmptySecrets(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Bearer ") + + substituteHeaders(req, []SecretConfig{}) + + got := req.Header.Get("Authorization") + if got != "Bearer " { + t.Errorf("empty secrets slice should leave header unchanged, got %q", got) + } +} + +func TestSubstituteHeaders_MultiplePlaceholdersInOneValue(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Basic :") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "admin"}, + {Placeholder: "", Value: "s3cret"}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("Authorization") + want := "Basic admin:s3cret" + if got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } +} + +func TestSubstituteHeaders_DuplicatePlaceholderInOneValue(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("X-Token", "-and-") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "val"}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("X-Token") + want := "val-and-val" + if got != want { + t.Errorf("X-Token = %q, want %q", got, want) + } +} + +func TestSubstituteHeaders_MultipleQueryParams(t *testing.T) { + u, _ := url.Parse("https://api.example.com/v1?key=&token=&plain=hello") + req := &http.Request{ + Header: http.Header{}, + URL: u, + } + + secrets := []SecretConfig{ + {Placeholder: "", Value: "key-val"}, + {Placeholder: "", Value: "tok-val"}, + } + substituteHeaders(req, secrets) + + q := req.URL.Query() + if got := q.Get("key"); got != "key-val" { + t.Errorf("URL query key = %q, want %q", got, "key-val") + } + if got := q.Get("token"); got != "tok-val" { + t.Errorf("URL query token = %q, want %q", got, "tok-val") + } + if got := q.Get("plain"); got != "hello" { + t.Errorf("URL query plain should be unchanged, got %q", got) + } +} + +func TestSubstituteHeaders_HeaderAndQueryCombined(t *testing.T) { + u, _ := url.Parse("https://api.example.com/v1?api_key=") + req := &http.Request{ + Header: http.Header{}, + URL: u, + } + req.Header.Set("Authorization", "Bearer ") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "real-key"}, + } + substituteHeaders(req, secrets) + + if got := req.Header.Get("Authorization"); got != "Bearer real-key" { + t.Errorf("Authorization = %q, want %q", got, "Bearer real-key") + } + if got := req.URL.Query().Get("api_key"); got != "real-key" { + t.Errorf("URL query api_key = %q, want %q", got, "real-key") + } +} + +func TestSubstituteHeaders_PlaceholderIsEntireValue(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("X-API-Key", "") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "the-whole-value"}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("X-API-Key") + if got != "the-whole-value" { + t.Errorf("X-API-Key = %q, want %q", got, "the-whole-value") + } +} + +func TestSubstituteHeaders_ValueContainsSpecialChars(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Bearer ") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "sk-proj-abc123/+=="}, + } + substituteHeaders(req, secrets) + + got := req.Header.Get("Authorization") + want := "Bearer sk-proj-abc123/+==" + if got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } +} + +func TestSubstituteHeaders_ManySecrets(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + } + + secrets := make([]SecretConfig, 50) + for i := 0; i < 50; i++ { + key := fmt.Sprintf("X-Key-%d", i) + placeholder := fmt.Sprintf("", i) + value := fmt.Sprintf("real-%d", i) + req.Header.Set(key, placeholder) + secrets[i] = SecretConfig{Placeholder: placeholder, Value: value} + } + substituteHeaders(req, secrets) + + for i := 0; i < 50; i++ { + key := fmt.Sprintf("X-Key-%d", i) + want := fmt.Sprintf("real-%d", i) + if got := req.Header.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } +} + +func TestSubstituteHeaders_NilURL(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + URL: nil, + } + req.Header.Set("Authorization", "Bearer ") + + secrets := []SecretConfig{ + {Placeholder: "", Value: "real"}, + } + + // Should not panic with nil URL + substituteHeaders(req, secrets) + + got := req.Header.Get("Authorization") + if got != "Bearer real" { + t.Errorf("Authorization = %q, want %q", got, "Bearer real") + } +} + +func TestSubstituteHeaders_Concurrent(t *testing.T) { + secrets := []SecretConfig{ + {Placeholder: "", Value: "real-value"}, + } + + var wg sync.WaitGroup + const n = 100 + wg.Add(n) + + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + req := &http.Request{ + Header: http.Header{}, + } + req.Header.Set("Authorization", "Bearer ") + substituteHeaders(req, secrets) + + got := req.Header.Get("Authorization") + if got != "Bearer real-value" { + t.Errorf("concurrent substitution failed: got %q", got) + } + }() + } + wg.Wait() +} + +// ============================================================================ +// Section C: Secret Host Matching Tests +// ============================================================================ + +func TestSecretHostMatch_Exact(t *testing.T) { + m := NewSecretHostMatcher([]SecretConfig{ + {Hosts: []string{"api.openai.com"}}, + }) + + tests := []struct { + host string + want bool + }{ + {"api.openai.com", true}, + {"other.openai.com", false}, + {"api.openai.com.evil.com", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := m.Matches(tt.host); got != tt.want { + t.Errorf("Matches(%q) = %v, want %v", tt.host, got, tt.want) + } + }) + } +} + +func TestSecretHostMatch_Wildcard(t *testing.T) { + m := NewSecretHostMatcher([]SecretConfig{ + {Hosts: []string{"*.openai.com"}}, + }) + + tests := []struct { + host string + want bool + }{ + {"api.openai.com", true}, + {"chat.openai.com", true}, + {"openai.com", false}, + {"sub.api.openai.com", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := m.Matches(tt.host); got != tt.want { + t.Errorf("Matches(%q) = %v, want %v", tt.host, got, tt.want) + } + }) + } +} + +func TestSecretHostMatch_MultipleHosts(t *testing.T) { + m := NewSecretHostMatcher([]SecretConfig{ + {Hosts: []string{"a.com", "b.com"}}, + }) + + if !m.Matches("a.com") { + t.Error("a.com should match") + } + if !m.Matches("b.com") { + t.Error("b.com should match") + } + if m.Matches("c.com") { + t.Error("c.com should not match") + } +} + +func TestSecretHostMatch_CaseInsensitive(t *testing.T) { + m := NewSecretHostMatcher([]SecretConfig{ + {Hosts: []string{"API.OpenAI.com"}}, + }) + + if !m.Matches("api.openai.com") { + t.Error("case-insensitive match should succeed for api.openai.com") + } +} + +func TestSecretHostMatch_MultipleSecretsSameHost(t *testing.T) { + secrets := []SecretConfig{ + {Name: "a", Hosts: []string{"x.com"}}, + {Name: "b", Hosts: []string{"x.com"}}, + } + m := NewSecretHostMatcher(secrets) + + if !m.Matches("x.com") { + t.Error("x.com should match") + } + + got := m.SecretsForHost("x.com") + if len(got) != 2 { + t.Fatalf("SecretsForHost(x.com) returned %d secrets, want 2", len(got)) + } + + names := map[string]bool{} + for _, s := range got { + names[s.Name] = true + } + if !names["a"] || !names["b"] { + t.Errorf("expected secrets a and b, got %v", got) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +func containsString(ss []string, target string) bool { + for _, s := range ss { + if s == target { + return true + } + } + return false +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_test_helpers_test.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_test_helpers_test.go new file mode 100644 index 00000000..d43be79a --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_test_helpers_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +// newTestCA generates a fresh ephemeral CA for tests. +// Uses the same ECDSA P-256 algorithm as the Rust CA generator. +func newTestCA(t *testing.T) *BoxCA { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("newTestCA: key generation failed: %v", err) + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "BoxLite Test CA"}, + NotBefore: now.Add(-1 * time.Minute), + NotAfter: now.Add(24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + MaxPathLen: 0, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("newTestCA: cert creation failed: %v", err) + } + + cert, _ := x509.ParseCertificate(certDER) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + return &BoxCA{cert: cert, key: key, certPEM: certPEM} +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_websocket.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_websocket.go new file mode 100644 index 00000000..6e98cea7 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_websocket.go @@ -0,0 +1,125 @@ +package main + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "net/http" + "strings" + + logrus "github.com/sirupsen/logrus" +) + +// isWebSocketUpgrade checks if the request is a WebSocket upgrade. +func isWebSocketUpgrade(req *http.Request) bool { + // Check Connection header contains "upgrade" token (case-insensitive, may be comma-separated) + connHeader := req.Header.Get("Connection") + hasUpgrade := false + for _, token := range strings.Split(connHeader, ",") { + if strings.EqualFold(strings.TrimSpace(token), "upgrade") { + hasUpgrade = true + break + } + } + if !hasUpgrade { + return false + } + // Check Upgrade header is "websocket" (case-insensitive) + upgrade := req.Header.Get("Upgrade") + return strings.EqualFold(upgrade, "websocket") +} + +// handleWebSocketUpgrade handles a WebSocket upgrade through the MITM proxy. +// Optional upstreamTLSConfig overrides upstream TLS (nil = derive from hostname). +// +// Limitation: only request headers are substituted. WebSocket message frames +// are relayed verbatim — placeholders in message bodies are NOT substituted. +// This is by design: WebSocket is a streaming protocol and frames may be +// fragmented arbitrarily, making reliable substitution impractical. +func handleWebSocketUpgrade(w http.ResponseWriter, req *http.Request, destAddr string, secrets []SecretConfig, upstreamTLSConfig ...*tls.Config) { + // Substitute secrets in request headers + substituteHeaders(req, secrets) + + hostname := req.Host + if h, _, err := net.SplitHostPort(hostname); err == nil { + hostname = h + } + + // Dial upstream with TLS (wss://) + rawConn, err := net.Dial("tcp", destAddr) + if err != nil { + logrus.WithError(err).WithField("destAddr", destAddr).Warn("websocket: upstream dial failed") + http.Error(w, "upstream connection failed", http.StatusBadGateway) + return + } + + upstreamConn := tls.Client(rawConn, resolveUpstreamTLS(hostname, upstreamTLSConfig...)) + + // Write the modified HTTP request to upstream + err = req.Write(upstreamConn) + if err != nil { + upstreamConn.Close() + logrus.WithError(err).Warn("websocket: upstream request write failed") + http.Error(w, "upstream write failed", http.StatusBadGateway) + return + } + + // Read upstream response + upstreamReader := bufio.NewReader(upstreamConn) + upstreamResp, err := http.ReadResponse(upstreamReader, req) + if err != nil { + upstreamConn.Close() + logrus.WithError(err).Warn("websocket: upstream response read failed") + http.Error(w, "upstream response failed", http.StatusBadGateway) + return + } + + // Hijack the guest connection + hijacker, ok := w.(http.Hijacker) + if !ok { + upstreamConn.Close() + upstreamResp.Body.Close() + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + + guestConn, guestBuf, err := hijacker.Hijack() + if err != nil { + upstreamConn.Close() + upstreamResp.Body.Close() + logrus.WithError(err).Warn("websocket: hijack failed") + return + } + + // Write the upstream 101 response back to the guest + err = upstreamResp.Write(guestBuf) + if err != nil { + guestConn.Close() + upstreamConn.Close() + return + } + guestBuf.Flush() + + // Bidirectional relay. When one direction finishes (EOF or error), + // close both connections to unblock the other io.Copy. Without this, + // a hanging upstream would block the goroutine forever. + done := make(chan struct{}, 2) + + go func() { + io.Copy(guestConn, upstreamReader) + guestConn.Close() + upstreamConn.Close() + done <- struct{}{} + }() + + go func() { + io.Copy(upstreamConn, guestConn) + guestConn.Close() + upstreamConn.Close() + done <- struct{}{} + }() + + <-done // first direction finished + <-done // second unblocked by Close() +} diff --git a/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_websocket_test.go b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_websocket_test.go new file mode 100644 index 00000000..77058609 --- /dev/null +++ b/boxlite/deps/libgvproxy-sys/gvproxy-bridge/mitm_websocket_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// --- Detection Tests --- + +func TestIsWebSocketUpgrade_True(t *testing.T) { + req := httptest.NewRequest("GET", "/ws", nil) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + + if !isWebSocketUpgrade(req) { + t.Error("expected true for valid WebSocket upgrade request") + } +} + +func TestIsWebSocketUpgrade_False_NoUpgrade(t *testing.T) { + req := httptest.NewRequest("GET", "/api", nil) + + if isWebSocketUpgrade(req) { + t.Error("expected false for normal GET request") + } +} + +func TestIsWebSocketUpgrade_False_NotWebSocket(t *testing.T) { + req := httptest.NewRequest("GET", "/h2c", nil) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "h2c") + + if isWebSocketUpgrade(req) { + t.Error("expected false for h2c upgrade (not websocket)") + } +} + +func TestIsWebSocketUpgrade_CaseInsensitive(t *testing.T) { + req := httptest.NewRequest("GET", "/ws", nil) + req.Header.Set("Connection", "upgrade") + req.Header.Set("Upgrade", "WebSocket") + + if !isWebSocketUpgrade(req) { + t.Error("expected true for case-insensitive WebSocket upgrade") + } +} + +// --- Handler Tests --- + +func TestHandleWebSocketUpgrade_HeaderSubstitution(t *testing.T) { + secrets := []SecretConfig{ + { + Name: "apikey", + Hosts: []string{"ws.example.com"}, + Placeholder: "", + Value: "secret-api-key", + }, + } + + // Start a TLS upstream server that reads the HTTP upgrade request and captures headers + ca := newTestCA(t) + upstreamCert, err := ca.GenerateHostCert("127.0.0.1") + if err != nil { + t.Fatal(err) + } + + receivedAuth := make(chan string, 1) + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + upstreamLn := tls.NewListener(rawLn, &tls.Config{ + Certificates: []tls.Certificate{*upstreamCert}, + }) + defer upstreamLn.Close() + + go func() { + conn, err := upstreamLn.Accept() + if err != nil { + return + } + defer conn.Close() + + reader := bufio.NewReader(conn) + req, err := http.ReadRequest(reader) + if err != nil { + receivedAuth <- "ERROR: " + err.Error() + return + } + receivedAuth <- req.Header.Get("Authorization") + + // Send back a minimal 101 response + resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + conn.Write([]byte(resp)) + }() + + destAddr := upstreamLn.Addr().String() + insecureTLS := &tls.Config{InsecureSkipVerify: true} + + // Create a test HTTP server that uses handleWebSocketUpgrade + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleWebSocketUpgrade(w, r, destAddr, secrets, insecureTLS) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Send a WebSocket upgrade request with a secret placeholder in Authorization + req, err := http.NewRequestWithContext(ctx, "GET", srv.URL+"/ws", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Authorization", "Bearer ") + + // Use raw transport to prevent Go from handling the upgrade + transport := &http.Transport{} + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal("request failed:", err) + } + defer resp.Body.Close() + + // Check what the upstream server received + select { + case auth := <-receivedAuth: + if auth != "Bearer secret-api-key" { + t.Errorf("expected upstream to receive 'Bearer secret-api-key', got %q", auth) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for upstream to receive request") + } +} + +func TestHandleWebSocketUpgrade_BidirectionalRelay(t *testing.T) { + secrets := []SecretConfig{} + + // Start a TLS echo server (reads a line, writes it back) + ca := newTestCA(t) + upstreamCert, err := ca.GenerateHostCert("127.0.0.1") + if err != nil { + t.Fatal(err) + } + + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + upstreamLn := tls.NewListener(rawLn, &tls.Config{ + Certificates: []tls.Certificate{*upstreamCert}, + }) + defer upstreamLn.Close() + + go func() { + conn, err := upstreamLn.Accept() + if err != nil { + return + } + defer conn.Close() + + reader := bufio.NewReader(conn) + _, err = http.ReadRequest(reader) + if err != nil { + return + } + + resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + conn.Write([]byte(resp)) + + // Echo loop + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + _, err = fmt.Fprint(conn, line) + if err != nil { + return + } + } + }() + + destAddr := upstreamLn.Addr().String() + insecureTLS := &tls.Config{InsecureSkipVerify: true} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleWebSocketUpgrade(w, r, destAddr, secrets, insecureTLS) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // Connect to the test server using raw TCP + srvAddr := strings.TrimPrefix(srv.URL, "http://") + conn, err := net.DialTimeout("tcp", srvAddr, 5*time.Second) + if err != nil { + t.Fatal("failed to connect:", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + + // Send WebSocket upgrade request + upgradeReq := "GET /ws HTTP/1.1\r\nHost: ws.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n" + _, err = io.WriteString(conn, upgradeReq) + if err != nil { + t.Fatal("failed to send upgrade:", err) + } + + reader := bufio.NewReader(conn) + + // Read upgrade response + resp, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatal("failed to read upgrade response:", err) + } + + if resp.StatusCode != http.StatusSwitchingProtocols { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("expected 101 Switching Protocols, got %d: %s", resp.StatusCode, string(body)) + } + + // Now the connection is "upgraded" — send a line and expect echo + _, err = fmt.Fprint(conn, "hello\n") + if err != nil { + t.Fatal("failed to send data:", err) + } + + line, err := reader.ReadString('\n') + if err != nil { + t.Fatal("failed to read echo:", err) + } + + if strings.TrimSpace(line) != "hello" { + t.Errorf("expected echo 'hello', got %q", strings.TrimSpace(line)) + } +} diff --git a/boxlite/deps/libgvproxy-sys/src/lib.rs b/boxlite/deps/libgvproxy-sys/src/lib.rs index 0e9d1ff3..366347a6 100644 --- a/boxlite/deps/libgvproxy-sys/src/lib.rs +++ b/boxlite/deps/libgvproxy-sys/src/lib.rs @@ -82,6 +82,7 @@ extern "C" { /// The callback must be thread-safe and must not panic. /// Pass NULL to restore default stderr logging. pub fn gvproxy_set_log_callback(callback: *const c_void); + } #[cfg(test)] diff --git a/boxlite/src/bin/shim/main.rs b/boxlite/src/bin/shim/main.rs index e85ebaa4..583067a9 100644 --- a/boxlite/src/bin/shim/main.rs +++ b/boxlite/src/bin/shim/main.rs @@ -20,38 +20,18 @@ use std::time::{Duration, Instant}; use boxlite::{ util, - vmm::{self, ExitInfo, InstanceSpec, VmmConfig, VmmKind, controller::watchdog}, + vmm::{self, ExitInfo, InstanceSpec, VmmConfig, controller::watchdog}, }; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; -use clap::Parser; use crash_capture::CrashCapture; #[allow(unused_imports)] use tracing_subscriber::{EnvFilter, fmt, prelude::*}; #[cfg(feature = "gvproxy")] -use boxlite::net::{ConnectionType, NetworkBackendEndpoint, gvproxy::GvproxyInstance}; - -/// Universal Box runner binary - subprocess that executes isolated Boxes -#[derive(Parser, Debug)] -#[command( - author, - version, - about = "BoxLite shim process - handles Box in isolated subprocess" -)] -struct ShimArgs { - /// Engine type to use for Box execution - /// - /// Supported engines: libkrun, firecracker - #[arg(long)] - engine: VmmKind, - - /// Box configuration as JSON string - /// - /// This contains the full InstanceSpec including rootfs path, volumes, - /// networking, guest entrypoint, and other runtime configuration. - #[arg(long)] - config: String, -} +use boxlite::net::gvproxy::GvproxyInstance; + +// No CLI args — all config (including engine type) is read from stdin pipe. +// This avoids /proc//cmdline exposure of secrets and CA keys. /// Initialize tracing with file logging. /// @@ -87,13 +67,21 @@ fn main() -> BoxliteResult<()> { let wall = chrono::Utc::now().format("%H:%M:%S%.6f"); eprintln!("[shim] {wall} T+0ms: main() entered"); - // Parse command line arguments with clap - // VmmKind parsed via FromStr trait automatically - let args = ShimArgs::parse(); + // Read config from stdin (piped by parent process). + // Stdin avoids /proc//cmdline exposure — CLI args are world-readable + // on Linux, and the config contains CA private keys and secret values. + let config_json = { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| BoxliteError::Engine(format!("Failed to read config from stdin: {e}")))?; + buf + }; - // Parse InstanceSpec from JSON - let config: InstanceSpec = serde_json::from_str(&args.config) - .map_err(|e| BoxliteError::Engine(format!("Failed to parse config JSON: {}", e)))?; + #[allow(unused_mut)] + let mut config: InstanceSpec = serde_json::from_str(&config_json) + .map_err(|e| BoxliteError::Engine(format!("Failed to parse config JSON: {e}")))?; timing("config parsed"); // Initialize logging using box_dir derived from exit_file path. @@ -112,7 +100,7 @@ fn main() -> BoxliteResult<()> { CrashCapture::install(config.exit_file.clone()); tracing::info!( - engine = ?args.engine, + engine = ?config.engine, box_id = %config.box_id, "Box runner starting" ); @@ -121,7 +109,7 @@ fn main() -> BoxliteResult<()> { let exit_file = config.exit_file.clone(); // Run the shim and handle errors - run_shim(args, config, timing).inspect_err(|e| { + run_shim(config, timing).inspect_err(|e| { let info = ExitInfo::Error { exit_code: 1, message: e.to_string(), @@ -134,7 +122,7 @@ fn main() -> BoxliteResult<()> { } #[allow(unused_mut)] -fn run_shim(args: ShimArgs, mut config: InstanceSpec, timing: impl Fn(&str)) -> BoxliteResult<()> { +fn run_shim(mut config: InstanceSpec, timing: impl Fn(&str)) -> BoxliteResult<()> { tracing::debug!( shares = ?config.fs_shares.shares(), "Filesystem shares configured" @@ -154,48 +142,12 @@ fn run_shim(args: ShimArgs, mut config: InstanceSpec, timing: impl Fn(&str)) -> // duration of the VM. When the shim process exits, OS cleans up all resources. #[cfg(feature = "gvproxy")] if let Some(ref net_config) = config.network_config { - tracing::info!( - port_mappings = ?net_config.port_mappings, - "Creating network backend (gvproxy) from config" - ); - - // Create gvproxy instance with caller-provided socket path + allowlist - let gvproxy = GvproxyInstance::new( - net_config.socket_path.clone(), - &net_config.port_mappings, - net_config.allow_net.clone(), - )?; + let (gvproxy, endpoint) = GvproxyInstance::from_config(net_config)?; + config.network_backend_endpoint = Some(endpoint); timing("gvproxy created"); - tracing::info!( - socket_path = ?net_config.socket_path, - "Network backend created" - ); - - // Create NetworkBackendEndpoint from socket path - // Platform-specific connection type: - // - macOS: UnixDgram with VFKit protocol - // - Linux: UnixStream with Qemu protocol - let connection_type = if cfg!(target_os = "macos") { - ConnectionType::UnixDgram - } else { - ConnectionType::UnixStream - }; - - // Use GUEST_MAC constant - must match DHCP static lease in gvproxy config - use boxlite::net::constants::GUEST_MAC; - - config.network_backend_endpoint = Some(NetworkBackendEndpoint::UnixSocket { - path: net_config.socket_path.clone(), - connection_type, - mac_address: GUEST_MAC, - }); - - // Leak the gvproxy instance to keep it alive for VM lifetime. - // This is intentional - the VM needs networking for its entire life, - // and OS cleanup handles resources when process exits. - let _gvproxy_leaked = Box::leak(Box::new(gvproxy)); - tracing::debug!("Leaked gvproxy instance for VM lifetime"); + // Leak to keep networking alive for VM lifetime (OS cleans up on exit) + Box::leak(Box::new(gvproxy)); } // Apply VMM seccomp filter with TSYNC (covers all threads including gvproxy) @@ -237,7 +189,7 @@ fn run_shim(args: ShimArgs, mut config: InstanceSpec, timing: impl Fn(&str)) -> // Create engine using inventory pattern (no match statement needed!) // Engines auto-register themselves at compile time - let mut engine = vmm::create_engine(args.engine, options)?; + let mut engine = vmm::create_engine(config.engine, options)?; timing("engine created"); tracing::info!("Engine created, creating Box instance"); diff --git a/boxlite/src/lib.rs b/boxlite/src/lib.rs index 15968493..c21d353e 100644 --- a/boxlite/src/lib.rs +++ b/boxlite/src/lib.rs @@ -50,7 +50,7 @@ pub use runtime::advanced_options::{ use runtime::layout::FilesystemLayout; pub use runtime::options::{ BoxArchive, BoxOptions, BoxliteOptions, CloneOptions, ExportOptions, NetworkSpec, RootfsSpec, - SnapshotOptions, + Secret, SnapshotOptions, }; /// Boxlite library version (from CARGO_PKG_VERSION at compile time). pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/boxlite/src/litebox/init/tasks/container_rootfs.rs b/boxlite/src/litebox/init/tasks/container_rootfs.rs index 86f48c98..b6044770 100644 --- a/boxlite/src/litebox/init/tasks/container_rootfs.rs +++ b/boxlite/src/litebox/init/tasks/container_rootfs.rs @@ -41,9 +41,14 @@ impl PipelineTask for ContainerRootfsTask { .layout .clone() .ok_or_else(|| BoxliteError::Internal("filesystem task must run first".into()))?; + let mut env = ctx.config.options.env.clone(); + // Inject secret placeholder env vars (e.g., BOXLITE_SECRET_OPENAI=). + // The MITM proxy substitutes real values at the network boundary. + env.extend(ctx.config.options.secrets.iter().map(|s| s.env_pair())); + ( ctx.config.options.rootfs.clone(), - ctx.config.options.env.clone(), + env, ctx.runtime.clone(), layout, ctx.reuse_rootfs, diff --git a/boxlite/src/litebox/init/tasks/guest_init.rs b/boxlite/src/litebox/init/tasks/guest_init.rs index cebf948b..c0a50592 100644 --- a/boxlite/src/litebox/init/tasks/guest_init.rs +++ b/boxlite/src/litebox/init/tasks/guest_init.rs @@ -31,6 +31,7 @@ impl PipelineTask for GuestInitTask { rootfs_init, container_mounts, network_spec, + ca_cert_pem, ) = { let mut ctx = ctx.lock().await; @@ -52,6 +53,7 @@ impl PipelineTask for GuestInitTask { BoxliteError::Internal("vmm_spawn task must run first".into()) })?; let network_spec = ctx.config.options.network.clone(); + let ca_cert_pem = ctx.ca_cert_pem.clone(); ( guest_session, container_image_config, @@ -60,6 +62,7 @@ impl PipelineTask for GuestInitTask { rootfs_init, container_mounts, network_spec, + ca_cert_pem, ) }; @@ -71,6 +74,7 @@ impl PipelineTask for GuestInitTask { &rootfs_init, &container_mounts, &network_spec, + ca_cert_pem.as_deref(), ) .await .inspect_err(|e| log_task_error(&box_id, task_name, e))?; @@ -90,6 +94,7 @@ impl PipelineTask for GuestInitTask { } /// Initialize guest and start container. +#[allow(clippy::too_many_arguments)] async fn run_guest_init( guest_session: GuestSession, container_image_config: &ContainerImageConfig, @@ -98,6 +103,7 @@ async fn run_guest_init( rootfs_init: &ContainerRootfsInitConfig, container_mounts: &[ContainerMount], network_spec: &NetworkSpec, + ca_cert_pem: Option<&str>, ) -> BoxliteResult<()> { let container_id_str = container_id.as_str(); @@ -127,12 +133,14 @@ async fn run_guest_init( // Step 2: Container Init (rootfs + container image config + user volume mounts) tracing::info!("Sending container configuration to guest"); let mut container_interface = guest_session.container().await?; + let ca_certs: Vec = ca_cert_pem.into_iter().map(|s| s.to_string()).collect(); let returned_id = container_interface .init( container_id_str, container_image_config.clone(), rootfs_init.clone(), container_mounts.to_vec(), + ca_certs, ) .await?; tracing::info!(container_id = %returned_id, "Container initialized"); diff --git a/boxlite/src/litebox/init/tasks/vmm_spawn.rs b/boxlite/src/litebox/init/tasks/vmm_spawn.rs index 8e83fa15..b9dae352 100644 --- a/boxlite/src/litebox/init/tasks/vmm_spawn.rs +++ b/boxlite/src/litebox/init/tasks/vmm_spawn.rs @@ -99,6 +99,11 @@ impl PipelineTask for VmmSpawnTask { ctx.volume_mgr = Some(volume_mgr); ctx.rootfs_init = Some(rootfs_init); ctx.container_mounts = Some(container_mounts); + // Store CA cert PEM for Container.Init gRPC (passed as CACert proto field) + ctx.ca_cert_pem = instance_spec + .network_config + .as_ref() + .and_then(|nc| nc.ca_cert_pem.clone()); Ok(()) } @@ -203,6 +208,7 @@ async fn build_config( // Assemble VMM instance spec let instance_spec = InstanceSpec { + engine: VmmKind::Libkrun, // only engine — will be dynamic when others are added // Box identification and security box_id: box_id.to_string(), security: options.advanced.security.clone(), @@ -293,6 +299,9 @@ fn build_guest_entrypoint( builder.with_env(key, value); } + // Secret placeholder env vars are injected in container_rootfs.rs (single source of truth). + // The guest init process inherits them from the container environment. + Ok(builder.build()) } @@ -340,6 +349,23 @@ fn build_network_config( let mut config = NetworkBackendConfig::new(final_mappings, layout.net_backend_socket_path()); config.allow_net = allow_net; + config.secrets = options.secrets.clone(); + + // Generate ephemeral MITM CA when secrets are configured. + // The CA cert+key flow through NetworkBackendConfig → GvproxyConfig → Go. + if !options.secrets.is_empty() { + match crate::net::ca::load_or_generate(&layout.ca_dir()) { + Ok(ca) => { + config.ca_cert_pem = Some(ca.cert_pem); + config.ca_key_pem = Some(ca.key_pem); + } + Err(e) => { + tracing::error!("MITM: CA setup failed, secrets disabled: {e}"); + config.secrets.clear(); + } + } + } + Some(config) } diff --git a/boxlite/src/litebox/init/types.rs b/boxlite/src/litebox/init/types.rs index d9ee6de0..8619293b 100644 --- a/boxlite/src/litebox/init/types.rs +++ b/boxlite/src/litebox/init/types.rs @@ -247,6 +247,8 @@ pub struct InitPipelineContext { pub rootfs_init: Option, pub container_mounts: Option>, pub guest_session: Option, + /// MITM CA cert PEM (set by vmm_spawn, read by guest_init for Container.Init gRPC). + pub ca_cert_pem: Option, #[cfg(target_os = "linux")] pub bind_mount: Option, @@ -274,6 +276,7 @@ impl InitPipelineContext { rootfs_init: None, container_mounts: None, guest_session: None, + ca_cert_pem: None, #[cfg(target_os = "linux")] bind_mount: None, } diff --git a/boxlite/src/net/ca.rs b/boxlite/src/net/ca.rs new file mode 100644 index 00000000..5bc604c0 --- /dev/null +++ b/boxlite/src/net/ca.rs @@ -0,0 +1,125 @@ +//! CA certificate generation and persistence for MITM secret substitution. +//! +//! Generates ECDSA P-256 CA certificates and persists them to the box directory +//! so the same CA survives box restarts. + +use boxlite_shared::errors::{BoxliteError, BoxliteResult}; +use rcgen::{CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, KeyUsagePurpose}; +use std::path::Path; +use time::{Duration, OffsetDateTime}; + +/// CA certificate and private key in PEM format. +pub struct MitmCa { + pub cert_pem: String, + pub key_pem: String, +} + +/// Generate a fresh ECDSA P-256 CA certificate. +pub fn generate() -> BoxliteResult { + let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| BoxliteError::Network(format!("MITM CA key generation failed: {e}")))?; + + let mut params = CertificateParams::default(); + params.distinguished_name = { + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "BoxLite MITM CA"); + dn + }; + + let now = OffsetDateTime::now_utc(); + params.not_before = now - Duration::minutes(1); + params.not_after = now + Duration::hours(24); + params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::CrlSign, KeyUsagePurpose::KeyCertSign]; + + let cert = params + .self_signed(&key_pair) + .map_err(|e| BoxliteError::Network(format!("MITM CA cert generation failed: {e}")))?; + + Ok(MitmCa { + cert_pem: cert.pem(), + key_pem: key_pair.serialize_pem(), + }) +} + +/// Load CA from files if they exist, otherwise generate and persist. +/// +/// Files: `{ca_dir}/cert.pem` (0644), `{ca_dir}/key.pem` (0600). +/// The CA directory must NOT be shared with the guest VM (it contains the private key). +pub fn load_or_generate(ca_dir: &Path) -> BoxliteResult { + let cert_path = ca_dir.join("cert.pem"); + let key_path = ca_dir.join("key.pem"); + + // Restart path: load existing CA (matches cert already in container rootfs) + if cert_path.exists() && key_path.exists() { + let cert_pem = std::fs::read_to_string(&cert_path).map_err(|e| { + BoxliteError::Network(format!( + "Failed to read CA cert {}: {e}", + cert_path.display() + )) + })?; + let key_pem = std::fs::read_to_string(&key_path).map_err(|e| { + BoxliteError::Network(format!("Failed to read CA key {}: {e}", key_path.display())) + })?; + tracing::info!("MITM: loaded persisted CA from {}", ca_dir.display()); + return Ok(MitmCa { cert_pem, key_pem }); + } + + // First start: generate + persist + let ca = generate()?; + + std::fs::create_dir_all(ca_dir).map_err(|e| { + BoxliteError::Network(format!("Failed to create CA dir {}: {e}", ca_dir.display())) + })?; + + std::fs::write(&cert_path, &ca.cert_pem) + .map_err(|e| BoxliteError::Network(format!("Failed to write CA cert: {e}")))?; + + std::fs::write(&key_path, &ca.key_pem) + .map_err(|e| BoxliteError::Network(format!("Failed to write CA key: {e}")))?; + + // Private key: owner-only permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)); + } + + tracing::info!("MITM: generated and persisted CA to {}", ca_dir.display()); + Ok(ca) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_produces_valid_pem() { + let ca = generate().unwrap(); + assert!(ca.cert_pem.starts_with("-----BEGIN CERTIFICATE-----")); + assert!(ca.key_pem.starts_with("-----BEGIN PRIVATE KEY-----")); + } + + #[test] + fn test_generate_produces_unique_certs() { + let ca1 = generate().unwrap(); + let ca2 = generate().unwrap(); + assert_ne!(ca1.cert_pem, ca2.cert_pem); + } + + #[test] + fn test_load_or_generate_persists_and_reloads() { + let dir = tempfile::tempdir().unwrap(); + let ca_dir = dir.path().join("ca"); + + // First call generates and writes files + let ca1 = load_or_generate(&ca_dir).unwrap(); + assert!(ca_dir.join("cert.pem").exists()); + assert!(ca_dir.join("key.pem").exists()); + + // Second call loads the same CA (restart scenario) + let ca2 = load_or_generate(&ca_dir).unwrap(); + assert_eq!(ca1.cert_pem, ca2.cert_pem); + assert_eq!(ca1.key_pem, ca2.key_pem); + } +} diff --git a/boxlite/src/net/gvproxy/config.rs b/boxlite/src/net/gvproxy/config.rs index 264de31d..a6e8ee72 100644 --- a/boxlite/src/net/gvproxy/config.rs +++ b/boxlite/src/net/gvproxy/config.rs @@ -73,6 +73,51 @@ pub struct GvproxyConfig { /// Network allowlist for DNS sinkhole filtering. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allow_net: Vec, + + /// Secrets for MITM proxy injection into outbound HTTP(S) requests. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secrets: Vec, + + /// PEM-encoded MITM CA certificate (generated by Rust, consumed by Go). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_cert_pem: Option, + + /// PEM-encoded MITM CA private key (PKCS8 format, consumed by Go). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_key_pem: Option, +} + +/// Secret configuration for gvproxy MITM proxy. +/// +/// JSON field names match the Go `SecretConfig` struct in gvproxy-bridge. +#[derive(Clone, Serialize, Deserialize)] +pub struct GvproxySecretConfig { + pub name: String, + pub hosts: Vec, + pub placeholder: String, + pub value: String, +} + +impl std::fmt::Debug for GvproxySecretConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GvproxySecretConfig") + .field("name", &self.name) + .field("hosts", &self.hosts) + .field("placeholder", &self.placeholder) + .field("value", &"[REDACTED]") + .finish() + } +} + +impl From<&crate::runtime::options::Secret> for GvproxySecretConfig { + fn from(s: &crate::runtime::options::Secret) -> Self { + Self { + name: s.name.clone(), + hosts: s.hosts.clone(), + placeholder: s.placeholder.clone(), + value: s.value.clone(), + } + } } /// Create a config with network defaults for the given socket path. @@ -93,6 +138,9 @@ fn defaults_with_socket_path(socket_path: PathBuf) -> GvproxyConfig { debug: false, capture_file: None, allow_net: Vec::new(), + secrets: Vec::new(), + ca_cert_pem: None, + ca_key_pem: None, } } @@ -178,6 +226,19 @@ impl GvproxyConfig { self.allow_net = allow_net; self } + + /// Set secrets for MITM proxy injection. + pub fn with_secrets(mut self, secrets: Vec) -> Self { + self.secrets = secrets; + self + } + + /// Set the MITM CA certificate and key (generated by Rust, consumed by Go). + pub fn with_ca(mut self, cert_pem: String, key_pem: String) -> Self { + self.ca_cert_pem = Some(cert_pem); + self.ca_key_pem = Some(key_pem); + self + } } #[cfg(test)] @@ -289,6 +350,83 @@ mod tests { assert_eq!(deserialized.socket_path, socket_path); } + // ======================================================================== + // Secret config tests + // ======================================================================== + + fn test_secret() -> crate::runtime::options::Secret { + crate::runtime::options::Secret { + name: "openai".to_string(), + hosts: vec!["api.openai.com".to_string()], + placeholder: "".to_string(), + value: "sk-test-super-secret-key-12345".to_string(), + } + } + + #[test] + fn test_gvproxy_secret_config_from_secret() { + let secret = test_secret(); + let config = GvproxySecretConfig::from(&secret); + assert_eq!(config.name, "openai"); + assert_eq!(config.hosts, vec!["api.openai.com"]); + assert_eq!(config.placeholder, ""); + assert_eq!(config.value, "sk-test-super-secret-key-12345"); + } + + #[test] + fn test_gvproxy_config_with_secrets() { + let secret = test_secret(); + let gvproxy_secret = GvproxySecretConfig::from(&secret); + let config = GvproxyConfig::new(test_socket_path(), vec![(8080, 80)]) + .with_secrets(vec![gvproxy_secret]); + assert_eq!(config.secrets.len(), 1); + assert_eq!(config.secrets[0].name, "openai"); + } + + #[test] + fn test_gvproxy_config_secrets_serialization() { + let secret = test_secret(); + let gvproxy_secret = GvproxySecretConfig::from(&secret); + let config = + GvproxyConfig::new(test_socket_path(), vec![]).with_secrets(vec![gvproxy_secret]); + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("\"secrets\"")); + assert!(json.contains("\"name\"")); + assert!(json.contains("\"placeholder\"")); + + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let secrets = value.get("secrets").unwrap().as_array().unwrap(); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0]["name"], "openai"); + assert_eq!(secrets[0]["placeholder"], ""); + assert_eq!(secrets[0]["hosts"][0], "api.openai.com"); + } + + #[test] + fn test_gvproxy_config_no_secrets_default() { + let config = GvproxyConfig::new(test_socket_path(), vec![]); + assert!(config.secrets.is_empty()); + let json = serde_json::to_string(&config).unwrap(); + // secrets field is skipped when empty due to skip_serializing_if + assert!( + !json.contains("\"secrets\""), + "empty secrets should be omitted from JSON" + ); + } + + #[test] + fn test_gvproxy_secret_config_serde_roundtrip() { + let secret = test_secret(); + let gvproxy_secret = GvproxySecretConfig::from(&secret); + let json = serde_json::to_string(&gvproxy_secret).unwrap(); + let deserialized: GvproxySecretConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, gvproxy_secret.name); + assert_eq!(deserialized.hosts, gvproxy_secret.hosts); + assert_eq!(deserialized.placeholder, gvproxy_secret.placeholder); + assert_eq!(deserialized.value, gvproxy_secret.value); + } + #[test] fn test_two_configs_have_different_socket_paths_in_json() { // Regression test: two concurrent boxes creating gvproxy configs. diff --git a/boxlite/src/net/gvproxy/instance.rs b/boxlite/src/net/gvproxy/instance.rs index 5a712b7d..e24c14f9 100644 --- a/boxlite/src/net/gvproxy/instance.rs +++ b/boxlite/src/net/gvproxy/instance.rs @@ -44,7 +44,7 @@ use super::stats::NetworkStats; /// /// // Create instance with caller-provided socket path /// let socket_path = PathBuf::from("/tmp/my-box/net.sock"); -/// let instance = GvproxyInstance::new(socket_path, &[(8080, 80), (8443, 443)], vec![])?; +/// let instance = GvproxyInstance::new(socket_path, &[(8080, 80), (8443, 443)], vec![], vec![], None, None)?; /// /// // Socket path is known from creation — no FFI call needed /// println!("Socket: {:?}", instance.socket_path()); @@ -67,16 +67,25 @@ impl GvproxyInstance { /// /// * `socket_path` - Caller-provided Unix socket path (must be unique per box) /// * `port_mappings` - List of (host_port, guest_port) tuples for port forwarding - pub fn new( + pub(crate) fn new( socket_path: PathBuf, port_mappings: &[(u16, u16)], allow_net: Vec, + secrets: Vec, + ca_cert_pem: Option<&str>, + ca_key_pem: Option<&str>, ) -> BoxliteResult { // Initialize logging callback (one-time setup) logging::init_logging(); - let config = super::config::GvproxyConfig::new(socket_path.clone(), port_mappings.to_vec()) - .with_allow_net(allow_net); + let mut config = + super::config::GvproxyConfig::new(socket_path.clone(), port_mappings.to_vec()) + .with_allow_net(allow_net) + .with_secrets(secrets); + + if let (Some(cert), Some(key)) = (ca_cert_pem, ca_key_pem) { + config = config.with_ca(cert.to_string(), key.to_string()); + } let id = ffi::create_instance(&config)?; @@ -92,6 +101,39 @@ impl GvproxyInstance { &self.socket_path } + /// Create a GvproxyInstance from a NetworkBackendConfig and return the endpoint. + /// + /// This is the primary constructor — takes the full network config, creates the + /// gvproxy instance, and returns the platform-specific endpoint for the VM. + pub fn from_config( + config: &super::super::NetworkBackendConfig, + ) -> BoxliteResult<(Self, super::super::NetworkBackendEndpoint)> { + let secrets = config.secrets.iter().map(Into::into).collect(); + let instance = Self::new( + config.socket_path.clone(), + &config.port_mappings, + config.allow_net.clone(), + secrets, + config.ca_cert_pem.as_deref(), + config.ca_key_pem.as_deref(), + )?; + + let connection_type = if cfg!(target_os = "macos") { + super::super::ConnectionType::UnixDgram + } else { + super::super::ConnectionType::UnixStream + }; + + use crate::net::constants::GUEST_MAC; + let endpoint = super::super::NetworkBackendEndpoint::UnixSocket { + path: config.socket_path.clone(), + connection_type, + mac_address: GUEST_MAC, + }; + + Ok((instance, endpoint)) + } + /// Get network statistics from this gvproxy instance /// /// Returns current network counters including bandwidth, TCP metrics, @@ -109,7 +151,7 @@ impl GvproxyInstance { /// ```no_run /// use boxlite::net::gvproxy::GvproxyInstance; /// - /// let instance = GvproxyInstance::new(path, &[(8080, 80)], vec![])?; + /// let instance = GvproxyInstance::new(path, &[(8080, 80)], vec![], vec![])?; /// let stats = instance.get_stats()?; /// /// // Check for packet drops due to maxInFlight limit @@ -267,9 +309,15 @@ mod tests { #[ignore] // Requires libgvproxy.dylib to be available fn test_gvproxy_create_destroy() { let socket_path = PathBuf::from("/tmp/test-gvproxy-instance.sock"); - let instance = - GvproxyInstance::new(socket_path.clone(), &[(8080, 80), (8443, 443)], Vec::new()) - .unwrap(); + let instance = GvproxyInstance::new( + socket_path.clone(), + &[(8080, 80), (8443, 443)], + Vec::new(), + Vec::new(), + None, + None, + ) + .unwrap(); // Socket path matches what we provided assert_eq!(instance.socket_path(), socket_path); @@ -283,8 +331,24 @@ mod tests { let path1 = PathBuf::from("/tmp/test-gvproxy-1.sock"); let path2 = PathBuf::from("/tmp/test-gvproxy-2.sock"); - let instance1 = GvproxyInstance::new(path1.clone(), &[(8080, 80)], Vec::new()).unwrap(); - let instance2 = GvproxyInstance::new(path2.clone(), &[(9090, 90)], Vec::new()).unwrap(); + let instance1 = GvproxyInstance::new( + path1.clone(), + &[(8080, 80)], + Vec::new(), + Vec::new(), + None, + None, + ) + .unwrap(); + let instance2 = GvproxyInstance::new( + path2.clone(), + &[(9090, 90)], + Vec::new(), + Vec::new(), + None, + None, + ) + .unwrap(); assert_ne!(instance1.id(), instance2.id()); assert_ne!(instance1.socket_path(), instance2.socket_path()); diff --git a/boxlite/src/net/gvproxy/mod.rs b/boxlite/src/net/gvproxy/mod.rs index 17606b0a..d925dbc8 100644 --- a/boxlite/src/net/gvproxy/mod.rs +++ b/boxlite/src/net/gvproxy/mod.rs @@ -82,7 +82,7 @@ use std::path::PathBuf; use std::sync::Arc; // Re-export public API -pub use config::{DnsZone, GvproxyConfig, PortMapping}; +pub use config::{DnsZone, GvproxyConfig, GvproxySecretConfig, PortMapping}; pub use instance::GvproxyInstance; pub use logging::init_logging; pub use stats::{NetworkStats, TcpStats}; @@ -146,10 +146,15 @@ impl GvisorTapBackend { ); // Create gvproxy instance with caller-provided socket path + let secrets: Vec = + config.secrets.iter().map(Into::into).collect(); let instance = Arc::new(GvproxyInstance::new( config.socket_path.clone(), &config.port_mappings, config.allow_net.clone(), + secrets, + config.ca_cert_pem.as_deref(), + config.ca_key_pem.as_deref(), )?); // Start background stats logging thread diff --git a/boxlite/src/net/mod.rs b/boxlite/src/net/mod.rs index 61f6365c..1a0a05e3 100644 --- a/boxlite/src/net/mod.rs +++ b/boxlite/src/net/mod.rs @@ -10,6 +10,7 @@ use boxlite_shared::errors::BoxliteResult; use std::path::PathBuf; +pub(crate) mod ca; pub mod constants; pub mod socket_path; @@ -56,6 +57,15 @@ pub struct NetworkBackendConfig { /// Network allowlist. When non-empty, DNS sinkhole blocks unlisted hosts. #[serde(default)] pub allow_net: Vec, + /// Secrets for MITM proxy injection. Passed through to gvproxy. + #[serde(default)] + pub secrets: Vec, + /// PEM-encoded MITM CA certificate (generated when secrets are configured). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_cert_pem: Option, + /// PEM-encoded MITM CA private key (PKCS8, generated when secrets are configured). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_key_pem: Option, } impl NetworkBackendConfig { @@ -64,6 +74,9 @@ impl NetworkBackendConfig { port_mappings, socket_path, allow_net: Vec::new(), + secrets: Vec::new(), + ca_cert_pem: None, + ca_key_pem: None, } } } diff --git a/boxlite/src/portal/interfaces/container.rs b/boxlite/src/portal/interfaces/container.rs index 94d0f932..bfd74166 100644 --- a/boxlite/src/portal/interfaces/container.rs +++ b/boxlite/src/portal/interfaces/container.rs @@ -1,7 +1,7 @@ //! Container service interface. use boxlite_shared::{ - BindMount, BoxliteError, BoxliteResult, ContainerClient, + BindMount, BoxliteError, BoxliteResult, CaCert, ContainerClient, ContainerConfig as ProtoContainerConfig, ContainerInitRequest, DiskRootfs, MergedRootfs, OverlayRootfs, RootfsInit, container_init_response, }; @@ -98,6 +98,7 @@ impl ContainerInterface { image_config: crate::images::ContainerImageConfig, rootfs: ContainerRootfsInitConfig, mounts: Vec, + ca_certs: Vec, ) -> BoxliteResult { let proto_config = ProtoContainerConfig { entrypoint: image_config.final_cmd(), @@ -137,6 +138,7 @@ impl ContainerInterface { container_config: Some(proto_config), rootfs: Some(rootfs.into_proto()), mounts: proto_mounts, + ca_certs: ca_certs.into_iter().map(|pem| CaCert { pem }).collect(), }; let response = self.client.init(request).await?.into_inner(); diff --git a/boxlite/src/runtime/layout.rs b/boxlite/src/runtime/layout.rs index c58038ef..b5bdcade 100644 --- a/boxlite/src/runtime/layout.rs +++ b/boxlite/src/runtime/layout.rs @@ -438,6 +438,15 @@ impl BoxFilesystemLayout { self.box_dir.join("bin") } + /// CA directory: ~/.boxlite/boxes/{box_id}/ca + /// + /// Stores ephemeral MITM CA cert+key for secret substitution. + /// NOT under shared/ — the guest must not read the private key. + /// Files: cert.pem (0644), key.pem (0600). + pub fn ca_dir(&self) -> PathBuf { + self.box_dir.join("ca") + } + /// Per-box logs directory: ~/.boxlite/boxes/{box_id}/logs /// /// Shim writes its logs here instead of the shared home_dir/logs/, diff --git a/boxlite/src/runtime/options.rs b/boxlite/src/runtime/options.rs index 1497ab34..df9d44d2 100644 --- a/boxlite/src/runtime/options.rs +++ b/boxlite/src/runtime/options.rs @@ -137,6 +137,89 @@ pub struct BoxOptions { /// If None, uses the image's USER directive (defaults to root). #[serde(default)] pub user: Option, + + /// Secrets for MITM proxy injection into outbound HTTP(S) requests. + /// + /// Each secret maps a placeholder string to a real value. When the box + /// makes an HTTP(S) request to a matching host, placeholders in request + /// headers and body are replaced with the actual secret value. + /// + /// The placeholder (e.g., ``) is visible to the + /// guest; the real value never enters the VM. + #[serde(default)] + pub secrets: Vec, +} + +/// A secret for MITM proxy injection. +/// +/// When the guest sends an HTTP(S) request to one of the listed hosts, +/// the MITM proxy replaces `placeholder` with `value` in headers and body. +/// The real `value` never enters the guest VM. +#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Secret { + /// Human-readable name for this secret (e.g., "openai_api_key"). + pub name: String, + /// Hosts where this secret should be injected (e.g., ["api.openai.com"]). + /// Supports exact match and wildcard patterns (e.g., "*.example.com"). + pub hosts: Vec, + /// Placeholder string visible to the guest (e.g., ""). + pub placeholder: String, + /// The actual secret value (e.g., "sk-..."). Never enters the VM. + /// + /// This field IS serialized (needed for DB persistence and shim config pipe). + /// Debug/Display impls redact it. GvproxySecretConfig also redacts in Debug. + /// The serialized config is protected by stdin pipe (no /proc/cmdline) and + /// DB file permissions. + pub value: String, +} + +impl Secret { + /// Environment variable key for this secret's placeholder (e.g., `BOXLITE_SECRET_OPENAI`). + /// + /// Sanitizes the name: replaces non-alphanumeric chars with `_`, ensures non-empty. + pub fn env_key(&self) -> String { + let sanitized: String = self + .name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + return "BOXLITE_SECRET__UNNAMED".to_string(); + } + format!("BOXLITE_SECRET_{sanitized}") + } + + /// Environment variable key-value pair: (env_key, placeholder). + pub fn env_pair(&self) -> (String, String) { + (self.env_key(), self.placeholder.clone()) + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Secret") + .field("name", &self.name) + .field("hosts", &self.hosts) + .field("placeholder", &self.placeholder) + .field("value", &"[REDACTED]") + .finish() + } +} + +impl std::fmt::Display for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Secret{{name:{}, placeholder:{}, value:[REDACTED]}}", + self.name, self.placeholder + ) + } } fn default_auto_remove() -> bool { @@ -165,6 +248,7 @@ impl Default for BoxOptions { entrypoint: None, cmd: None, user: None, + secrets: Vec::new(), } } } @@ -635,6 +719,168 @@ mod tests { assert_eq!(opts.cmd, Some(vec!["--iptables=false".to_string()])); } + // ======================================================================== + // Secret tests + // ======================================================================== + + fn test_secret() -> Secret { + Secret { + name: "openai".to_string(), + hosts: vec!["api.openai.com".to_string()], + placeholder: "".to_string(), + value: "sk-test-super-secret-key-12345".to_string(), + } + } + + #[test] + fn test_secret_serde_roundtrip() { + let secret = test_secret(); + let json = serde_json::to_string(&secret).unwrap(); + let deserialized: Secret = serde_json::from_str(&json).unwrap(); + assert_eq!(secret, deserialized); + // Value IS serialized (needed for DB persistence) + assert!(json.contains("sk-test-super-secret-key-12345")); + } + + #[test] + fn test_secret_env_key_valid_names() { + let cases = [ + ("openai", "BOXLITE_SECRET_OPENAI"), + ("my_key", "BOXLITE_SECRET_MY_KEY"), + ("KEY123", "BOXLITE_SECRET_KEY123"), + ("a-b-c", "BOXLITE_SECRET_A_B_C"), // hyphen → underscore + ]; + for (name, expected) in cases { + let secret = Secret { + name: name.into(), + hosts: vec![], + placeholder: String::new(), + value: String::new(), + }; + assert_eq!(secret.env_key(), expected, "name={name:?}"); + } + } + + #[test] + fn test_secret_env_key_sanitizes_invalid_names() { + let cases = [ + ("my key", "BOXLITE_SECRET_MY_KEY"), // space → _ + ("a/b/c", "BOXLITE_SECRET_A_B_C"), // slash → _ + ("", "BOXLITE_SECRET__UNNAMED"), // empty + ("café", "BOXLITE_SECRET_CAF_"), // non-ascii → _ + ]; + for (name, expected) in cases { + let secret = Secret { + name: name.into(), + hosts: vec![], + placeholder: String::new(), + value: String::new(), + }; + assert_eq!(secret.env_key(), expected, "name={name:?}"); + } + } + + #[test] + fn test_secret_debug_redacts_value() { + let secret = test_secret(); + let debug_output = format!("{:?}", secret); + assert!( + !debug_output.contains("sk-test-super-secret-key-12345"), + "Debug output must not contain the secret value" + ); + assert!( + debug_output.contains("[REDACTED]"), + "Debug output must contain [REDACTED]" + ); + assert!( + debug_output.contains("openai"), + "Debug output should contain the secret name" + ); + } + + #[test] + fn test_secret_display_redacts_value() { + let secret = test_secret(); + let display_output = format!("{}", secret); + assert!( + !display_output.contains("sk-test-super-secret-key-12345"), + "Display output must not contain the secret value" + ); + assert!( + display_output.contains("[REDACTED]"), + "Display output must contain [REDACTED]" + ); + } + + #[test] + fn test_secret_serde_json_fields() { + let secret = test_secret(); + let value = serde_json::to_value(&secret).unwrap(); + assert!(value.get("name").unwrap().is_string()); + assert!(value.get("hosts").unwrap().is_array()); + assert!(value.get("placeholder").unwrap().is_string()); + assert!(value.get("value").unwrap().is_string()); + assert_eq!(value.get("hosts").unwrap().as_array().unwrap().len(), 1); + } + + #[test] + fn test_box_options_with_secrets_default() { + let opts = BoxOptions::default(); + assert!(opts.secrets.is_empty(), "secrets should default to empty"); + } + + #[test] + fn test_box_options_with_secrets_serde() { + let opts = BoxOptions { + secrets: vec![test_secret()], + ..Default::default() + }; + let json = serde_json::to_string(&opts).unwrap(); + let deserialized: BoxOptions = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.secrets.len(), 1); + assert_eq!(deserialized.secrets[0], test_secret()); + } + + #[test] + fn test_box_options_secrets_in_json() { + let opts = BoxOptions { + secrets: vec![ + test_secret(), + Secret { + name: "anthropic".to_string(), + hosts: vec!["api.anthropic.com".to_string()], + placeholder: "".to_string(), + value: "sk-ant-secret".to_string(), + }, + ], + ..Default::default() + }; + let json = serde_json::to_string(&opts).unwrap(); + assert!( + json.contains("\"secrets\""), + "JSON must contain secrets key" + ); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let secrets_arr = value.get("secrets").unwrap().as_array().unwrap(); + assert_eq!(secrets_arr.len(), 2); + } + + #[test] + fn test_box_options_secrets_missing_from_json_defaults_empty() { + let json = r#"{ + "rootfs": {"Image": "alpine:latest"}, + "env": [], + "volumes": [], + "network": {"Enabled": {"allow_net": []}}, + "ports": [] + }"#; + let opts: BoxOptions = serde_json::from_str(json).unwrap(); + assert!( + opts.secrets.is_empty(), + "secrets should default to empty when missing from JSON" + ); + } + #[test] fn test_security_builder_non_consuming() { // Verify builder can be reused (non-consuming pattern) diff --git a/boxlite/src/vmm/controller/shim.rs b/boxlite/src/vmm/controller/shim.rs index adea1089..2c36b6c9 100644 --- a/boxlite/src/vmm/controller/shim.rs +++ b/boxlite/src/vmm/controller/shim.rs @@ -277,6 +277,7 @@ impl VmmController for ShimController { guest_entrypoint.env = env; // Use the modified env with RUST_LOG let serializable_config = InstanceSpec { + engine: self.engine_type, // Box identification and security (from ShimController) box_id: self.box_id.to_string(), security: self.options.advanced.security.clone(), @@ -324,7 +325,6 @@ impl VmmController for ShimController { let shim_spawn_start = Instant::now(); let spawner = ShimSpawner::new( &self.binary_path, - self.engine_type, &self.layout, self.box_id.as_str(), &self.options, diff --git a/boxlite/src/vmm/controller/spawn.rs b/boxlite/src/vmm/controller/spawn.rs index 544608cb..12c11ede 100644 --- a/boxlite/src/vmm/controller/spawn.rs +++ b/boxlite/src/vmm/controller/spawn.rs @@ -9,7 +9,6 @@ use crate::jailer::{Jail, JailerBuilder}; use crate::runtime::layout::BoxFilesystemLayout; use crate::runtime::options::BoxOptions; use crate::util::configure_library_env; -use crate::vmm::VmmKind; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use super::watchdog; @@ -36,7 +35,6 @@ pub struct SpawnedShim { /// are passed to [`spawn()`](Self::spawn). pub struct ShimSpawner<'a> { binary_path: &'a Path, - engine_type: VmmKind, layout: &'a BoxFilesystemLayout, box_id: &'a str, options: &'a BoxOptions, @@ -45,14 +43,12 @@ pub struct ShimSpawner<'a> { impl<'a> ShimSpawner<'a> { pub fn new( binary_path: &'a Path, - engine_type: VmmKind, layout: &'a BoxFilesystemLayout, box_id: &'a str, options: &'a BoxOptions, ) -> Self { Self { binary_path, - engine_type, layout, box_id, options, @@ -91,21 +87,23 @@ impl<'a> ShimSpawner<'a> { // 3. Setup pre-spawn isolation (cgroups on Linux, no-op on macOS) jail.prepare()?; - // 4. Build isolated command (includes pre_exec hook) - let shim_args = self.build_shim_args(config_json); - let mut cmd = jail.command(self.binary_path, &shim_args); + // 4. Build isolated command — no CLI args, config sent via stdin pipe + let no_args: &[String] = &[]; + let mut cmd = jail.command(self.binary_path, no_args); // 5. Configure environment self.configure_env(&mut cmd); - // 6. Configure stdio (stdin/stdout=null, stderr=file) + // 6. Configure stdio + // stdin=piped: config JSON is sent via stdin to avoid /proc/cmdline exposure + // (config contains CA private keys and secret values) let stderr_file = self.create_stderr_file()?; - cmd.stdin(Stdio::null()); + cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::null()); cmd.stderr(Stdio::from(stderr_file)); // 7. Spawn - let child = cmd.spawn().map_err(|e| { + let mut child = cmd.spawn().map_err(|e| { let err_msg = format!( "Failed to spawn VM subprocess at {}: {}", self.binary_path.display(), @@ -115,21 +113,26 @@ impl<'a> ShimSpawner<'a> { BoxliteError::Engine(err_msg) })?; - // 8. Close read end in parent (child inherited it via fork) + // 8. Write config to stdin, then close (shim reads until EOF). + // The child is already spawned and will read from stdin, so this is a + // producer-consumer pattern via the kernel pipe buffer. For typical + // configs (~2-5KB), write_all completes immediately. For large configs + // (>16KB on macOS, >64KB on Linux), write_all blocks until the child + // drains the buffer — which it does as its first action in main(). + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + stdin.write_all(config_json.as_bytes()).map_err(|e| { + BoxliteError::Engine(format!("Failed to write config to shim stdin: {e}")) + })?; + drop(stdin); // close write end — shim sees EOF + } + + // 9. Close read end in parent (child inherited it via fork) drop(child_setup); Ok(SpawnedShim { child, keepalive }) } - fn build_shim_args(&self, config_json: &str) -> Vec { - vec![ - "--engine".to_string(), - format!("{:?}", self.engine_type), - "--config".to_string(), - config_json.to_string(), - ] - } - fn configure_env(&self, cmd: &mut std::process::Command) { // Pass debugging environment variables to subprocess if let Ok(rust_log) = std::env::var("RUST_LOG") { @@ -190,18 +193,14 @@ mod tests { let spawner = ShimSpawner::new( Path::new("/usr/bin/boxlite-shim"), - VmmKind::Libkrun, &layout, "test-box", &options, ); - let args = spawner.build_shim_args("{\"test\":true}"); - assert_eq!(args.len(), 4); - assert_eq!(args[0], "--engine"); - assert_eq!(args[1], "Libkrun"); - assert_eq!(args[2], "--config"); - assert_eq!(args[3], "{\"test\":true}"); + // No CLI args — config is sent via stdin pipe + // Just verify the spawner was created without error + assert_eq!(spawner.box_id, "test-box"); } #[test] @@ -218,7 +217,6 @@ mod tests { let spawner = ShimSpawner::new( Path::new("/usr/bin/boxlite-shim"), - VmmKind::Libkrun, &layout, "test-box", &options, @@ -269,7 +267,6 @@ mod tests { let spawner = ShimSpawner::new( Path::new("/usr/bin/boxlite-shim"), - VmmKind::Libkrun, &layout, "test-box", &options, diff --git a/boxlite/src/vmm/mod.rs b/boxlite/src/vmm/mod.rs index 38ff1b37..9a3f107f 100644 --- a/boxlite/src/vmm/mod.rs +++ b/boxlite/src/vmm/mod.rs @@ -23,8 +23,11 @@ pub use factory::VmmFactory; pub use registry::create_engine; /// Available sandbox engine implementations. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +#[derive( + Clone, Copy, Debug, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize, +)] pub enum VmmKind { + #[default] Libkrun, Firecracker, } @@ -145,6 +148,9 @@ impl BlockDevices { /// communication channel, and additional environment variables. #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct InstanceSpec { + /// Engine type (e.g., Libkrun). Included in config to avoid CLI args. + #[serde(default)] + pub engine: VmmKind, /// Unique identifier for this box instance. /// Used for logging, cgroup naming, and isolation identification. pub box_id: String, diff --git a/boxlite/tests/secret_substitution.rs b/boxlite/tests/secret_substitution.rs new file mode 100644 index 00000000..36dbe70c --- /dev/null +++ b/boxlite/tests/secret_substitution.rs @@ -0,0 +1,143 @@ +//! Tests for secret substitution types, config propagation, and value redaction. +//! +//! These tests verify: +//! - JSON serialization matches what Go gvproxy expects +//! - Placeholder format is correct and consistent +//! - Debug/Display never leak secret values + +use boxlite::runtime::options::{BoxOptions, Secret}; + +fn make_secret(name: &str, host: &str, value: &str) -> Secret { + Secret { + name: name.to_string(), + hosts: vec![host.to_string()], + placeholder: format!("", name), + value: value.to_string(), + } +} + +#[cfg(feature = "gvproxy")] +mod gvproxy_tests { + use super::*; + use boxlite::net::gvproxy::{GvproxyConfig, GvproxySecretConfig}; + use std::path::PathBuf; + + #[test] + fn test_secret_config_json_matches_go_expectations() { + // Go's gvproxy expects: {"name":"...","hosts":[...],"placeholder":"...","value":"..."} + let secret = make_secret("openai", "api.openai.com", "sk-test-key"); + let gvproxy_secret = GvproxySecretConfig::from(&secret); + + let json_value = serde_json::to_value(&gvproxy_secret).unwrap(); + let obj = json_value.as_object().unwrap(); + + // Verify exact field names Go expects + assert!(obj.contains_key("name"), "Go expects 'name' field"); + assert!(obj.contains_key("hosts"), "Go expects 'hosts' field"); + assert!( + obj.contains_key("placeholder"), + "Go expects 'placeholder' field" + ); + assert!(obj.contains_key("value"), "Go expects 'value' field"); + + // Verify types + assert!(obj["name"].is_string()); + assert!(obj["hosts"].is_array()); + assert!(obj["placeholder"].is_string()); + assert!(obj["value"].is_string()); + + // Verify a full GvproxyConfig with secrets serializes correctly + let config = GvproxyConfig::new(PathBuf::from("/tmp/test.sock"), vec![]) + .with_secrets(vec![gvproxy_secret]); + let config_json = serde_json::to_value(&config).unwrap(); + let secrets_arr = config_json["secrets"].as_array().unwrap(); + assert_eq!(secrets_arr.len(), 1); + assert_eq!(secrets_arr[0]["name"], "openai"); + } + + #[test] + fn test_secret_propagation_to_gvproxy_config() { + let secrets = [ + make_secret("openai", "api.openai.com", "sk-openai"), + make_secret("anthropic", "api.anthropic.com", "sk-ant"), + ]; + + let gvproxy_secrets: Vec = + secrets.iter().map(GvproxySecretConfig::from).collect(); + + let config = GvproxyConfig::new(PathBuf::from("/tmp/test.sock"), vec![(8080, 80)]) + .with_secrets(gvproxy_secrets); + + assert_eq!(config.secrets.len(), 2); + assert_eq!(config.secrets[0].name, "openai"); + assert_eq!(config.secrets[0].hosts, vec!["api.openai.com"]); + assert_eq!(config.secrets[1].name, "anthropic"); + assert_eq!(config.secrets[1].hosts, vec!["api.anthropic.com"]); + } + + #[test] + fn test_gvproxy_secret_debug_contains_struct_info() { + let secret = make_secret("test", "example.com", "sk-SUPER-SECRET"); + let gvproxy_secret = GvproxySecretConfig::from(&secret); + let debug = format!("{:?}", gvproxy_secret); + assert!(debug.contains("test")); + } +} + +#[test] +fn test_secret_placeholder_format() { + let secret = make_secret("my_api_key", "example.com", "secret-val"); + assert_eq!(secret.placeholder, ""); + + // Placeholder should be preserved through serde + let json = serde_json::to_string(&secret).unwrap(); + let rt: Secret = serde_json::from_str(&json).unwrap(); + assert_eq!(rt.placeholder, ""); +} + +#[test] +fn test_secret_debug_never_leaks_value() { + let test_value = "sk-SUPER-SECRET-DO-NOT-LEAK-12345"; + + // Test Secret Debug + let secret = make_secret("test", "example.com", test_value); + let debug = format!("{:?}", secret); + assert!( + !debug.contains(test_value), + "Secret Debug must not contain the value, got: {debug}" + ); + assert!(debug.contains("[REDACTED]")); + + // Test Secret Display + let display = format!("{}", secret); + assert!( + !display.contains(test_value), + "Secret Display must not contain the value, got: {display}" + ); + assert!(display.contains("[REDACTED]")); + + // Test BoxOptions with secrets - Debug should not leak + let opts = BoxOptions { + secrets: vec![secret], + ..Default::default() + }; + let opts_debug = format!("{:?}", opts); + assert!( + !opts_debug.contains(test_value), + "BoxOptions Debug must not leak secret values, got: {opts_debug}" + ); +} + +#[test] +fn test_box_options_secrets_backward_compatible() { + // JSON without "secrets" field should still deserialize (serde default) + let json = r#"{ + "rootfs": {"Image": "alpine:latest"}, + "env": [], + "volumes": [], + "network": {"Enabled": {"allow_net": []}}, + "ports": [] + }"#; + let opts: BoxOptions = serde_json::from_str(json).unwrap(); + assert!(opts.secrets.is_empty()); +} diff --git a/guest/src/ca_trust.rs b/guest/src/ca_trust.rs new file mode 100644 index 00000000..5852782c --- /dev/null +++ b/guest/src/ca_trust.rs @@ -0,0 +1,29 @@ +//! CA certificate installer for container trust stores. +//! +//! Appends PEM-encoded CA certificates to a system CA bundle file. +//! Source-agnostic — the caller provides the PEM bytes and bundle path. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; + +/// Installs CA certificates into a trust bundle file. +pub struct CaInstaller { + bundle_path: PathBuf, +} + +impl CaInstaller { + /// Create an installer targeting a specific bundle file path. + pub fn with_bundle(bundle_path: PathBuf) -> Self { + Self { bundle_path } + } + + /// Append a PEM-encoded CA certificate to the trust bundle. + pub fn install(&self, pem: &[u8]) -> std::io::Result<()> { + let mut file = OpenOptions::new().append(true).open(&self.bundle_path)?; + file.write_all(b"\n")?; + file.write_all(pem)?; + file.write_all(b"\n")?; + Ok(()) + } +} diff --git a/guest/src/container/spec.rs b/guest/src/container/spec.rs index b7f92a8d..4ad95211 100644 --- a/guest/src/container/spec.rs +++ b/guest/src/container/spec.rs @@ -540,7 +540,7 @@ fn build_standard_mounts(bundle_path: &Path) -> BoxliteResult> { })?, ); - // Add /etc/hosts bind mount + // Add /etc/hosts bind mount (read-only — writable /etc/hosts allows DNS hijacking) let hosts_path = bundle_path.join("hosts"); mounts.push( MountBuilder::default() diff --git a/guest/src/main.rs b/guest/src/main.rs index 050563c2..1671212f 100644 --- a/guest/src/main.rs +++ b/guest/src/main.rs @@ -3,6 +3,8 @@ #[cfg(not(target_os = "linux"))] compile_error!("BoxLite guest is Linux-only; build with a Linux target"); +#[cfg(target_os = "linux")] +mod ca_trust; #[cfg(target_os = "linux")] mod container; #[cfg(target_os = "linux")] diff --git a/guest/src/service/container.rs b/guest/src/service/container.rs index 50e712e7..b1e9cf91 100644 --- a/guest/src/service/container.rs +++ b/guest/src/service/container.rs @@ -12,7 +12,7 @@ use boxlite_shared::{ }; use nix::mount::{mount, MsFlags}; use tonic::{Request, Response, Status}; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use crate::container::{Container, UserMount}; use crate::layout::GuestLayout; @@ -187,6 +187,27 @@ impl ContainerService for GuestServer { })); } + // Install CA certs into container trust store (from gRPC CACert field). + if !init_req.ca_certs.is_empty() { + let bundle = bundle_rootfs.join("etc/ssl/certs/ca-certificates.crt"); + let installer = crate::ca_trust::CaInstaller::with_bundle(bundle); + let mut installed = 0; + for ca in &init_req.ca_certs { + match installer.install(ca.pem.as_bytes()) { + Ok(()) => installed += 1, + Err(e) => warn!("Failed to install CA cert: {e}"), + } + } + if installed > 0 { + info!(count = installed, "CA certs installed in container"); + } else { + error!( + total = init_req.ca_certs.len(), + "All CA cert installations failed — HTTPS will not trust MITM proxy" + ); + } + } + // Convert proto BindMount to UserMount for OCI spec // Construct full source path from convention: /run/boxlite/shared/containers/{id}/volumes/{name} let guest_layout = boxlite_shared::layout::SharedGuestLayout::new("/run/boxlite/shared"); diff --git a/sdks/node/src/options.rs b/sdks/node/src/options.rs index b6c5e110..c44c0464 100644 --- a/sdks/node/src/options.rs +++ b/sdks/node/src/options.rs @@ -287,6 +287,7 @@ impl From for BoxOptions { entrypoint: js_opts.entrypoint, cmd: js_opts.cmd, user: js_opts.user, + secrets: vec![], // Secret substitution not yet supported in Node.js SDK } } } diff --git a/sdks/python/boxlite/__init__.py b/sdks/python/boxlite/__init__.py index ae906ca4..8fd688fd 100644 --- a/sdks/python/boxlite/__init__.py +++ b/sdks/python/boxlite/__init__.py @@ -27,6 +27,7 @@ HealthStatus, Options, RuntimeMetrics, + Secret, SecurityOptions, SnapshotHandle, SnapshotInfo, @@ -52,6 +53,7 @@ "CopyOptions", "HealthCheckOptions", "SecurityOptions", + "Secret", "SnapshotHandle", "SnapshotInfo", "SnapshotOptions", diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index de51dc8c..18023b49 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -16,7 +16,7 @@ use crate::box_handle::PyBox; use crate::exec::{PyExecStderr, PyExecStdin, PyExecStdout, PyExecution}; use crate::info::{PyBoxInfo, PyBoxStateInfo, PyHealthState, PyHealthStatus}; use crate::metrics::{PyBoxMetrics, PyRuntimeMetrics}; -use crate::options::{PyBoxOptions, PyBoxliteRestOptions, PyCopyOptions, PyOptions}; +use crate::options::{PyBoxOptions, PyBoxliteRestOptions, PyCopyOptions, PyOptions, PySecret}; use crate::runtime::PyBoxlite; use crate::snapshot_options::{PyCloneOptions, PyExportOptions, PySnapshotOptions}; use crate::snapshots::{PySnapshotHandle, PySnapshotInfo}; @@ -49,6 +49,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/sdks/python/src/options.rs b/sdks/python/src/options.rs index ae6d76fd..c87721d8 100644 --- a/sdks/python/src/options.rs +++ b/sdks/python/src/options.rs @@ -106,6 +106,81 @@ impl From for CopyOptions { } } +// ============================================================================ +// Secret +// ============================================================================ + +/// A secret to inject into outbound HTTPS requests via MITM proxy. +/// +/// The guest code uses a placeholder string (e.g., ````) +/// in HTTP headers. The host-side proxy replaces the placeholder with the +/// real secret value before forwarding the request. The actual secret never +/// enters the guest VM. +/// +/// Example:: +/// +/// from boxlite import Secret +/// +/// secret = Secret( +/// name="openai", +/// value="sk-...", +/// hosts=["api.openai.com"], +/// ) +/// # Pass to BoxOptions: +/// opts = BoxOptions(image="python:3.12", secrets=[secret]) +/// +#[pyclass(name = "Secret")] +#[derive(Clone, Debug)] +pub(crate) struct PySecret { + /// Human-readable name for the secret (e.g., "openai"). + #[pyo3(get, set)] + pub(crate) name: String, + + /// The real secret value (never sent to the guest). + #[pyo3(get, set)] + pub(crate) value: String, + + /// Hostnames where this secret should be injected. + /// Supports exact matches ("api.openai.com") and wildcards ("*.openai.com"). + #[pyo3(get, set)] + pub(crate) hosts: Vec, + + /// The placeholder string that appears in guest HTTP headers. + /// Defaults to ```` if not set explicitly. + #[pyo3(get, set)] + pub(crate) placeholder: Option, +} + +#[pymethods] +impl PySecret { + #[new] + #[pyo3(signature = (name, value, hosts=vec![], placeholder=None))] + fn new(name: String, value: String, hosts: Vec, placeholder: Option) -> Self { + Self { + name, + value, + hosts, + placeholder, + } + } + + /// Return the effective placeholder string. + fn get_placeholder(&self) -> String { + self.placeholder + .clone() + .unwrap_or_else(|| format!("", self.name)) + } + + fn __repr__(&self) -> String { + format!( + "Secret(name={:?}, hosts={:?}, placeholder={:?}, value=[REDACTED])", + self.name, + self.hosts, + self.get_placeholder(), + ) + } +} + // ============================================================================ // Box Options // ============================================================================ @@ -154,6 +229,10 @@ pub(crate) struct PyBoxOptions { /// Advanced options for expert users (security, mount isolation, health check). #[pyo3(get, set)] pub(crate) advanced: Option, + + /// Secrets to inject into outbound HTTPS requests via MITM proxy. + #[pyo3(get, set)] + pub(crate) secrets: Vec, } #[pymethods] @@ -177,6 +256,7 @@ impl PyBoxOptions { cmd=None, user=None, advanced=None, + secrets=vec![], ))] #[allow(clippy::too_many_arguments)] fn new( @@ -197,6 +277,7 @@ impl PyBoxOptions { cmd: Option>, user: Option, advanced: Option, + secrets: Vec, ) -> Self { Self { image, @@ -216,6 +297,7 @@ impl PyBoxOptions { cmd, user, advanced, + secrets, } } @@ -291,6 +373,20 @@ impl From for BoxOptions { } } + // Convert Python secrets to Rust secrets + opts.secrets = py_opts + .secrets + .into_iter() + .map(|s| boxlite::runtime::options::Secret { + name: s.name.clone(), + hosts: s.hosts, + placeholder: s + .placeholder + .unwrap_or_else(|| format!("", s.name)), + value: s.value, + }) + .collect(); + opts } } diff --git a/sdks/python/tests/test_secret_substitution.py b/sdks/python/tests/test_secret_substitution.py new file mode 100644 index 00000000..ae0b0f8b --- /dev/null +++ b/sdks/python/tests/test_secret_substitution.py @@ -0,0 +1,314 @@ +""" +Tests for Secret type and MITM secret substitution. + +Test coverage: + 1. PyO3 binding: Secret constructor, field access, mutation, repr + 2. BoxOptions integration: secrets coexist with other fields + 3. Integration: env var injection, CA cert, upstream substitution (requires VM) + +Requirements: + - make dev:python (build Python SDK) + - VM runtime for integration tests (libkrun + Hypervisor.framework) +""" + +from __future__ import annotations + +import pytest + +import boxlite + +# Skip entire module if Secret class is not available (e.g., cached wheel from prior version) +if not hasattr(boxlite, "Secret"): + pytest.skip( + "boxlite.Secret not available (rebuild SDK with: make dev:python)", + allow_module_level=True, + ) + +# ============================================================================= +# Unit tests (no VM required) — test PyO3 binding contract +# ============================================================================= + + +class TestSecretConstruction: + """Test Secret class creation and field access via PyO3.""" + + def test_basic_creation(self): + """Secret with all required fields.""" + s = boxlite.Secret( + name="openai", + value="sk-real-key-123", + hosts=["api.openai.com"], + ) + assert s.name == "openai" + assert s.value == "sk-real-key-123" + assert s.hosts == ["api.openai.com"] + + def test_multiple_hosts(self): + """Secret targeting multiple hostnames (PyO3 Vec conversion).""" + s = boxlite.Secret( + name="api_key", + value="key-123", + hosts=["api.openai.com", "api.anthropic.com", "api.google.com"], + ) + assert len(s.hosts) == 3 + assert "api.anthropic.com" in s.hosts + + def test_wildcard_host(self): + """Secret with wildcard hostname pattern.""" + s = boxlite.Secret( + name="corp_key", + value="key-456", + hosts=["*.internal.corp.com"], + ) + assert s.hosts == ["*.internal.corp.com"] + + def test_empty_hosts_default(self): + """Hosts defaults to empty list (PyO3 default parameter).""" + s = boxlite.Secret(name="test", value="val") + assert s.hosts == [] + + def test_custom_placeholder(self): + """Custom placeholder kwarg overrides auto-generated one.""" + s = boxlite.Secret( + name="openai", + value="sk-123", + hosts=["api.openai.com"], + placeholder="{{OPENAI_KEY}}", + ) + assert s.placeholder == "{{OPENAI_KEY}}" + assert s.get_placeholder() == "{{OPENAI_KEY}}" + + def test_field_mutation(self): + """PyO3 #[pyo3(get, set)] allows field mutation.""" + s = boxlite.Secret(name="test", value="val") + s.name = "updated" + s.value = "new-val" + s.hosts = ["new-host.com"] + assert s.name == "updated" + assert s.value == "new-val" + assert s.hosts == ["new-host.com"] + + def test_repr_redacts_value(self): + """repr() must not contain the actual secret value.""" + s = boxlite.Secret( + name="openai", + value="sk-super-secret-key-DO-NOT-LEAK", + hosts=["api.openai.com"], + ) + r = repr(s) + assert "sk-super-secret-key-DO-NOT-LEAK" not in r + assert "REDACTED" in r + assert "openai" in r + assert "api.openai.com" in r + + +class TestBoxOptionsWithSecrets: + """Test BoxOptions integration with secrets via PyO3.""" + + def test_secrets_with_other_options(self): + """Secrets coexist with other BoxOptions fields.""" + secret = boxlite.Secret(name="key", value="val", hosts=["api.example.com"]) + opts = boxlite.BoxOptions( + image="python:3.12", + cpus=2, + memory_mib=512, + env=[("FOO", "bar")], + allow_net=["api.example.com"], + secrets=[secret], + ) + assert opts.image == "python:3.12" + assert opts.cpus == 2 + assert opts.memory_mib == 512 + assert len(opts.env) == 1 + assert len(opts.secrets) == 1 + + def test_secrets_with_allow_net(self): + """Secrets and allow_net can be used together.""" + secret = boxlite.Secret(name="key", value="val", hosts=["api.openai.com"]) + opts = boxlite.BoxOptions( + image="alpine:latest", + allow_net=["api.openai.com", "pypi.org"], + secrets=[secret], + ) + assert len(opts.allow_net) == 2 + assert len(opts.secrets) == 1 + + def test_secret_fields_accessible_through_boxoptions(self): + """Secret fields accessible via BoxOptions.secrets[i].""" + secret = boxlite.Secret( + name="test", + value="secret-value", + hosts=["h1.com", "h2.com"], + ) + opts = boxlite.BoxOptions(secrets=[secret]) + s = opts.secrets[0] + assert s.name == "test" + assert s.value == "secret-value" + assert s.hosts == ["h1.com", "h2.com"] + assert s.get_placeholder() == "" + + +# ============================================================================= +# Integration tests (require VM + network) +# ============================================================================= + + +@pytest.fixture +def runtime(shared_sync_runtime): + """Use shared sync runtime.""" + return shared_sync_runtime + + +@pytest.mark.integration +class TestSecretIntegration: + """End-to-end secret substitution via MITM proxy.""" + + def test_secret_env_vars_and_ca_injection(self, runtime): + """With secrets: placeholder env vars present, real values hidden, CA cert injected.""" + secrets = [ + boxlite.Secret( + name="key_a", value="real-val-a-DO-NOT-LEAK", hosts=["a.com"] + ), + boxlite.Secret( + name="key_b", value="real-val-b-DO-NOT-LEAK", hosts=["b.com"] + ), + ] + sandbox = runtime.create( + boxlite.BoxOptions(image="alpine:latest", secrets=secrets) + ) + try: + # 1. Placeholder env vars exist with correct format + exec_a = sandbox.exec("printenv", ["BOXLITE_SECRET_KEY_A"]) + stdout_a = "".join(list(exec_a.stdout())).strip() + exec_a.wait() + assert "" in stdout_a + + exec_b = sandbox.exec("printenv", ["BOXLITE_SECRET_KEY_B"]) + stdout_b = "".join(list(exec_b.stdout())).strip() + exec_b.wait() + assert "" in stdout_b + + # 2. Real values NOT in env + execution = sandbox.exec("env", []) + full_env = "".join(list(execution.stdout())) + execution.wait() + assert "real-val-a-DO-NOT-LEAK" not in full_env + assert "real-val-b-DO-NOT-LEAK" not in full_env + + # 3. CA cert in trust store + execution = sandbox.exec("cat", ["/etc/ssl/certs/ca-certificates.crt"]) + ca_bundle = "".join(list(execution.stdout())) + execution.wait() + assert "BEGIN CERTIFICATE" in ca_bundle + + # 4. BOXLITE_CA_PEM not leaked to container processes + execution = sandbox.exec("printenv", ["BOXLITE_CA_PEM"]) + ca_pem = "".join(list(execution.stdout())).strip() + result = execution.wait() + assert result.exit_code == 1 or ca_pem == "" + finally: + sandbox.stop() + + def test_no_secret_baseline(self, runtime): + """Without secrets: no BOXLITE_SECRET_* env vars, no CA injection.""" + sandbox = runtime.create(boxlite.BoxOptions(image="alpine:latest")) + try: + execution = sandbox.exec("env", []) + full_env = "".join(list(execution.stdout())) + execution.wait() + + assert "BOXLITE_SECRET_" not in full_env + assert "BOXLITE_CA_PEM" not in full_env + finally: + sandbox.stop() + + def test_secret_substitution_reaches_upstream(self, runtime): + """The real secret value reaches the upstream endpoint (the whole point of MITM). + + Guest curls an HTTPS echo service with the placeholder in Authorization. + The MITM proxy substitutes the real value before it reaches the server. + The echo service reflects the headers back, so the guest sees the real value + in the response — proving substitution happened at the network boundary. + """ + real_value = "sk-test-real-key-12345" + secret = boxlite.Secret( + name="testkey", + value=real_value, + hosts=["httpbin.org"], + ) + sandbox = runtime.create( + boxlite.BoxOptions( + image="alpine:latest", + allow_net=["httpbin.org"], + secrets=[secret], + ) + ) + try: + # Guest sends placeholder in header; MITM substitutes real value; + # httpbin.org echoes it back in JSON response. + execution = sandbox.exec( + "wget", + [ + "-q", + "-O-", + "--header", + "Authorization: Bearer ", + "https://httpbin.org/headers", + ], + ) + stdout = "".join(list(execution.stdout())) + result = execution.wait() + + assert result.exit_code == 0, f"wget failed: {stdout}" + # The echoed Authorization header should contain the REAL value, + # not the placeholder — proving MITM substitution worked. + assert real_value in stdout, ( + f"Real secret not in upstream response. " + f"MITM substitution may not be working. Got: {stdout[:500]}" + ) + assert "" not in stdout, ( + "Placeholder leaked to upstream — MITM did not substitute" + ) + finally: + sandbox.stop() + + def test_non_secret_host_not_intercepted(self, runtime): + """HTTP to a host NOT in any secret's hosts list works without MITM. + + Uses HTTP (not HTTPS) to avoid TLS complications with BusyBox wget. + The key assertion: non-secret traffic is not blocked or broken. + """ + secret = boxlite.Secret( + name="key", + value="val", + hosts=["api.openai.com"], # only openai is MITM'd + ) + sandbox = runtime.create( + boxlite.BoxOptions( + image="alpine:latest", + allow_net=["httpbin.org"], + secrets=[secret], + ) + ) + try: + # httpbin.org is NOT in secret hosts — should work normally + execution = sandbox.exec( + "wget", + ["-q", "-O-", "http://httpbin.org/ip"], + ) + stdout = "".join(list(execution.stdout())) + result = execution.wait() + + assert result.exit_code == 0, ( + f"Non-secret host request failed — traffic may be broken. " + f"Output: {stdout[:500]}" + ) + assert "origin" in stdout, ( + f"Expected JSON with 'origin', got: {stdout[:200]}" + ) + finally: + sandbox.stop() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])