From c79e8f27074830fbde8c3c7d61bb53f1e59dcaec Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 5 Mar 2026 09:06:38 +0000 Subject: [PATCH] refactor: clarify MITM certificate naming in aibridgeproxyd (#22408) ## Description Renames internal fields, variables, and comments related to the proxy's certificate/key configuration to explicitly reference their MITM CA purpose. The AI Bridge Proxy uses a CA certificate to sign dynamically generated leaf certificates during MITM interception of HTTPS traffic from AI clients. With the upcoming introduction of TLS listener certificates (for serving the proxy itself over HTTPS, implemented upstack https://github.com/coder/coder/pull/22411), the previous generic naming would become ambiguous. This refactor makes it clear which certificate is which. No user-facing flags, environment variables, YAML keys, or JSON fields were changed, this is purely an internal rename to avoid confusion going forward. Related to https://github.com/coder/internal/issues/1335 --- cli/testdata/coder_server_--help.golden | 15 +- cli/testdata/server-config.yaml.golden | 7 +- codersdk/deployment.go | 16 +- docs/reference/cli/server.md | 4 +- enterprise/aibridgeproxyd/aibridgeproxyd.go | 40 +-- .../aibridgeproxyd/aibridgeproxyd_test.go | 249 +++++++++--------- enterprise/cli/aibridgeproxyd.go | 4 +- .../cli/testdata/coder_server_--help.golden | 15 +- 8 files changed, 182 insertions(+), 168 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 930d735049..1632f7cb5e 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -175,19 +175,22 @@ AI BRIDGE OPTIONS: exporting these records to external SIEM or observability systems. AI BRIDGE PROXY OPTIONS: - --aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE - Path to the CA certificate file for AI Bridge Proxy. - --aibridge-proxy-enabled bool, $CODER_AIBRIDGE_PROXY_ENABLED (default: false) Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider requests. - --aibridge-proxy-key-file string, $CODER_AIBRIDGE_PROXY_KEY_FILE - Path to the CA private key file for AI Bridge Proxy. - --aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888) The address the AI Bridge Proxy will listen on. + --aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE + Path to the CA certificate file used to intercept (MITM) HTTPS traffic + from AI clients. This CA must be trusted by AI clients for the proxy + to decrypt their requests. + + --aibridge-proxy-key-file string, $CODER_AIBRIDGE_PROXY_KEY_FILE + Path to the CA private key file used to intercept (MITM) HTTPS traffic + from AI clients. + --aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests through. Format: http://[user:pass@]host:port or diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 85cd1ca407..5f0a3c6a63 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -830,10 +830,13 @@ aibridgeproxy: # The address the AI Bridge Proxy will listen on. # (default: :8888, type: string) listen_addr: :8888 - # Path to the CA certificate file for AI Bridge Proxy. + # Path to the CA certificate file used to intercept (MITM) HTTPS traffic from AI + # clients. This CA must be trusted by AI clients for the proxy to decrypt their + # requests. # (default: , type: string) cert_file: "" - # Path to the CA private key file for AI Bridge Proxy. + # Path to the CA private key file used to intercept (MITM) HTTPS traffic from AI + # clients. # (default: , type: string) key_file: "" # Comma-separated list of AI provider domains for which HTTPS traffic will be diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 34ade7561c..2e6f31fb56 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3858,21 +3858,21 @@ Write out the current server config as YAML to stdout.`, YAML: "listen_addr", }, { - Name: "AI Bridge Proxy Certificate File", - Description: "Path to the CA certificate file for AI Bridge Proxy.", + Name: "AI Bridge Proxy MITM CA Certificate File", + Description: "Path to the CA certificate file used to intercept (MITM) HTTPS traffic from AI clients. This CA must be trusted by AI clients for the proxy to decrypt their requests.", Flag: "aibridge-proxy-cert-file", Env: "CODER_AIBRIDGE_PROXY_CERT_FILE", - Value: &c.AI.BridgeProxyConfig.CertFile, + Value: &c.AI.BridgeProxyConfig.MITMCertFile, Default: "", Group: &deploymentGroupAIBridgeProxy, YAML: "cert_file", }, { - Name: "AI Bridge Proxy Key File", - Description: "Path to the CA private key file for AI Bridge Proxy.", + Name: "AI Bridge Proxy MITM CA Key File", + Description: "Path to the CA private key file used to intercept (MITM) HTTPS traffic from AI clients.", Flag: "aibridge-proxy-key-file", Env: "CODER_AIBRIDGE_PROXY_KEY_FILE", - Value: &c.AI.BridgeProxyConfig.KeyFile, + Value: &c.AI.BridgeProxyConfig.MITMKeyFile, Default: "", Group: &deploymentGroupAIBridgeProxy, YAML: "key_file", @@ -4014,8 +4014,8 @@ type AIBridgeBedrockConfig struct { type AIBridgeProxyConfig struct { Enabled serpent.Bool `json:"enabled" typescript:",notnull"` ListenAddr serpent.String `json:"listen_addr" typescript:",notnull"` - CertFile serpent.String `json:"cert_file" typescript:",notnull"` - KeyFile serpent.String `json:"key_file" typescript:",notnull"` + MITMCertFile serpent.String `json:"cert_file" typescript:",notnull"` + MITMKeyFile serpent.String `json:"key_file" typescript:",notnull"` DomainAllowlist serpent.StringArray `json:"domain_allowlist" typescript:",notnull"` UpstreamProxy serpent.String `json:"upstream_proxy" typescript:",notnull"` UpstreamProxyCA serpent.String `json:"upstream_proxy_ca" typescript:",notnull"` diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 2881341470..72f40459d2 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1920,7 +1920,7 @@ The address the AI Bridge Proxy will listen on. | Environment | $CODER_AIBRIDGE_PROXY_CERT_FILE | | YAML | aibridgeproxy.cert_file | -Path to the CA certificate file for AI Bridge Proxy. +Path to the CA certificate file used to intercept (MITM) HTTPS traffic from AI clients. This CA must be trusted by AI clients for the proxy to decrypt their requests. ### --aibridge-proxy-key-file @@ -1930,7 +1930,7 @@ Path to the CA certificate file for AI Bridge Proxy. | Environment | $CODER_AIBRIDGE_PROXY_KEY_FILE | | YAML | aibridgeproxy.key_file | -Path to the CA private key file for AI Bridge Proxy. +Path to the CA private key file used to intercept (MITM) HTTPS traffic from AI clients. ### --aibridge-proxy-upstream diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index ca919cd478..073f2f2381 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -48,17 +48,17 @@ const ( // proxyAuthRequiredMsg is the response body for 407 responses. var proxyAuthRequiredMsg = []byte(http.StatusText(http.StatusProxyAuthRequired)) -// loadMitmOnce ensures the MITM certificate is loaded exactly once. +// loadMITMOnce ensures the MITM certificate is loaded exactly once. // goproxy.GoproxyCa is a package-level global variable shared across all // goproxy.ProxyHttpServer instances in the process. In tests, multiple proxy // servers run in parallel, and without this guard they would race on writing // to GoproxyCa. In production, only one server runs, so this has no impact. -var loadMitmOnce sync.Once +var loadMITMOnce sync.Once // Server is the AI MITM (Man-in-the-Middle) proxy server. // It is responsible for: // - intercepting HTTPS requests to AI providers -// - decrypting requests using the configured CA certificate +// - decrypting requests using the configured MITM CA certificate // - forwarding requests to aibridged for processing type Server struct { ctx context.Context @@ -68,8 +68,8 @@ type Server struct { listener net.Listener coderAccessURL *url.URL aibridgeProviderFromHost func(host string) string - // caCert is the PEM-encoded CA certificate loaded during initialization. - // This is served to clients who need to trust the proxy. + // caCert is the PEM-encoded MITM CA certificate loaded during initialization. + // This is served to clients who need to trust the proxy's generated certificates. caCert []byte // Metrics is the Prometheus metrics for the proxy. If nil, metrics are disabled. metrics *Metrics @@ -102,10 +102,10 @@ type Options struct { // CoderAccessURL is the URL of the Coder deployment where aibridged is running. // Requests to supported AI providers are forwarded here. CoderAccessURL string - // CertFile is the path to the CA certificate file used for MITM. - CertFile string - // KeyFile is the path to the CA private key file used for MITM. - KeyFile string + // MITMCertFile is the path to the CA certificate file used for MITM. + MITMCertFile string + // MITMKeyFile is the path to the CA private key file used for MITM. + MITMKeyFile string // AllowedPorts is the list of ports allowed for CONNECT requests. // Defaults to ["80", "443"] if empty. AllowedPorts []string @@ -149,8 +149,9 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) return nil, xerrors.Errorf("invalid coder access URL %q: %w", opts.CoderAccessURL, err) } - if opts.CertFile == "" || opts.KeyFile == "" { - return nil, xerrors.New("cert file and key file are required") + // MITM cert and key are required to intercept and decrypt HTTPS traffic. + if opts.MITMCertFile == "" || opts.MITMKeyFile == "" { + return nil, xerrors.New("MITM CA cert file and key file are required") } allowedPorts := opts.AllowedPorts @@ -182,8 +183,8 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) } } - // Load CA certificate for MITM - certPEM, err := loadMitmCertificate(opts.CertFile, opts.KeyFile) + // Load the CA certificate for MITM. + certPEM, err := loadMITMCertificate(opts.MITMCertFile, opts.MITMKeyFile) if err != nil { return nil, xerrors.Errorf("failed to load MITM certificate: %w", err) } @@ -306,6 +307,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) if err != nil { return nil, xerrors.Errorf("failed to listen on %s: %w", opts.ListenAddr, err) } + srv.listener = listener // Start HTTP server in background @@ -357,14 +359,14 @@ func (s *Server) Close() error { return s.httpServer.Shutdown(ctx) } -// loadMitmCertificate loads the CA certificate and private key for MITM proxying. +// loadMITMCertificate loads the MITM CA certificate and private key for MITM proxying. // This function is safe to call concurrently - the certificate is only loaded once // into the global goproxy.GoproxyCa variable. // Returns the PEM-encoded certificate for serving to clients. -func loadMitmCertificate(certFile, keyFile string) ([]byte, error) { +func loadMITMCertificate(certFile, keyFile string) ([]byte, error) { tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { - return nil, xerrors.Errorf("load CA certificate: %w", err) + return nil, xerrors.Errorf("load MITM CA certificate: %w", err) } if len(tlsCert.Certificate) == 0 { @@ -373,7 +375,7 @@ func loadMitmCertificate(certFile, keyFile string) ([]byte, error) { x509Cert, err := x509.ParseCertificate(tlsCert.Certificate[0]) if err != nil { - return nil, xerrors.Errorf("parse CA certificate: %w", err) + return nil, xerrors.Errorf("parse MITM CA certificate: %w", err) } // Ensure that we only return the certificate and never any included private keys. @@ -383,7 +385,7 @@ func loadMitmCertificate(certFile, keyFile string) ([]byte, error) { }) // Only protect the global assignment with sync.Once - loadMitmOnce.Do(func() { + loadMITMOnce.Do(func() { goproxy.GoproxyCa = tls.Certificate{ Certificate: tlsCert.Certificate, PrivateKey: tlsCert.PrivateKey, @@ -801,7 +803,7 @@ func (s *Server) Handler() http.Handler { // connections. The certificate was validated during server initialization. func (s *Server) serveCACert(rw http.ResponseWriter, _ *http.Request) { if len(s.caCert) == 0 { - http.Error(rw, "CA certificate not configured", http.StatusNotFound) + http.Error(rw, "MITM CA certificate not configured", http.StatusNotFound) return } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 1afc5d8f10..821943a3cd 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -37,42 +37,45 @@ import ( ) var ( - // testCAOnce ensures the shared CA is generated exactly once. + // testMITMCertOnce ensures the shared MITM certificate is generated exactly once. // sync.Once guarantees single execution even with parallel tests. // Note: no retry on failure. - testCAOnce sync.Once - // Shared CA certificate and key paths, and any error from generation. - // These are set once by testCAOnce and read by all tests. - testCACert string - testCAKey string - errTestSharedCA error + testMITMCertOnce sync.Once + // Shared MITM certificate and key paths, and any error from generation. + // These are set once by testMITMCertOnce and read by all tests. + testMITMCert string + testMITMKey string + errTestSharedMITMCert error ) -// getSharedTestCA returns a shared CA certificate for all tests. +// getSharedTestMITMCert returns a shared MITM certificate for all tests. // This avoids race conditions with goproxy.GoproxyCa which is a global variable. -// Using sync.Once ensures the CA is generated exactly once, even when tests run -// in parallel. All tests share the same CA, so goproxy.GoproxyCa is only set once. -func getSharedTestCA(t *testing.T) (certFile, keyFile string) { +// Using sync.Once ensures the certificate is generated exactly once, even when +// tests run in parallel. All tests share the same certificate, so +// goproxy.GoproxyCa is only set once. +func getSharedTestMITMCert(t *testing.T) (certFile, keyFile string) { t.Helper() - testCAOnce.Do(func() { - testCACert, testCAKey, errTestSharedCA = generateSharedTestCA() + testMITMCertOnce.Do(func() { + testMITMCert, testMITMKey, errTestSharedMITMCert = generateSharedTestMITMCert() }) - require.NoError(t, errTestSharedCA, "failed to generate shared test CA") - return testCACert, testCAKey + require.NoError(t, errTestSharedMITMCert, "failed to generate shared test MITM certificate") + return testMITMCert, testMITMKey } -// generateSharedTestCA creates a shared CA certificate and key for testing. -func generateSharedTestCA() (certFile, keyFile string, err error) { - caKey, err := rsa.GenerateKey(rand.Reader, 2048) +// generateSharedTestMITMCert creates a shared MITM certificate and key for testing. +func generateSharedTestMITMCert() (certFile, keyFile string, err error) { + mitmKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return "", "", xerrors.Errorf("generate CA key: %w", err) + return "", "", xerrors.Errorf("generate MITM key: %w", err) } - caTemplate := x509.Certificate{ + // Create a self-signed root CA certificate used to sign per-hostname + // leaf certificates during MITM interception. + mitmTemplate := x509.Certificate{ SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "Shared Test CA"}, + Subject: pkix.Name{CommonName: "Shared Test MITM Cert"}, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, @@ -80,21 +83,21 @@ func generateSharedTestCA() (certFile, keyFile string, err error) { IsCA: true, } - caCertDER, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &caKey.PublicKey, caKey) + mitmCertDER, err := x509.CreateCertificate(rand.Reader, &mitmTemplate, &mitmTemplate, &mitmKey.PublicKey, mitmKey) if err != nil { - return "", "", xerrors.Errorf("create CA certificate: %w", err) + return "", "", xerrors.Errorf("create MITM certificate: %w", err) } tmpDir := os.TempDir() - certPath := filepath.Join(tmpDir, "aibridgeproxyd_test_ca.crt") - keyPath := filepath.Join(tmpDir, "aibridgeproxyd_test_ca.key") + certPath := filepath.Join(tmpDir, "aibridgeproxyd_test_mitm.crt") + keyPath := filepath.Join(tmpDir, "aibridgeproxyd_test_mitm.key") - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCertDER}) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: mitmCertDER}) if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { return "", "", xerrors.Errorf("write cert file: %w", err) } - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(caKey)}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(mitmKey)}) if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { return "", "", xerrors.Errorf("write key file: %w", err) } @@ -165,7 +168,7 @@ func withMetrics(metrics *aibridgeproxyd.Metrics) testProxyOption { } // newTestProxy creates a new AI Bridge Proxy server for testing. -// It uses the shared test CA and registers cleanup automatically. +// It uses the shared MITM certificate and registers cleanup automatically. // It waits for the proxy server to be ready before returning. func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server { t.Helper() @@ -182,14 +185,14 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server opt(cfg) } - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) aibridgeOpts := aibridgeproxyd.Options{ ListenAddr: cfg.listenAddr, CoderAccessURL: cfg.coderAccessURL, - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, AllowedPorts: cfg.allowedPorts, DomainAllowlist: cfg.domainAllowlist, AIBridgeProviderFromHost: cfg.aibridgeProviderFromHost, @@ -220,16 +223,16 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server return srv } -// getProxyCertPool returns a cert pool containing the shared test CA certificate. +// getProxyCertPool returns a cert pool containing the shared MITM certificate. // This is used for tests where requests are MITM'd by the proxy, so the client -// needs to trust the proxy's CA to verify the generated certificates. +// needs to trust the MITM certificate to verify the generated certificates. func getProxyCertPool(t *testing.T) *x509.CertPool { t.Helper() - certFile, _ := getSharedTestCA(t) + mitmCertFile, _ := getSharedTestMITMCert(t) - // Load the CA certificate so the client trusts the proxy's MITM certificate. - certPEM, err := os.ReadFile(certFile) + // Load the MITM certificate so the client trusts the proxy's generated certificates. + certPEM, err := os.ReadFile(mitmCertFile) require.NoError(t, err) certPool := x509.NewCertPool() ok := certPool.AppendCertsFromPEM(certPEM) @@ -241,7 +244,7 @@ func getProxyCertPool(t *testing.T) *x509.CertPool { // newProxyClient creates an HTTP client configured to use the proxy. // It adds a Proxy-Authorization header with the provided token for authentication. // The certPool parameter specifies which certificates the client should trust. -// For MITM'd requests, use the proxy's CA. For tunneled requests, use the target server's cert. +// For MITM'd requests, use the proxy's MITM certificate. For tunneled requests, use the target server's cert. func newProxyClient(t *testing.T, srv *aibridgeproxyd.Server, proxyAuth string, certPool *x509.CertPool) *http.Client { t.Helper() @@ -331,13 +334,13 @@ func TestNew(t *testing.T) { t.Run("MissingListenAddr", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -347,14 +350,14 @@ func TestNew(t *testing.T) { t.Run("EmptyListenAddr", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -364,13 +367,13 @@ func TestNew(t *testing.T) { t.Run("MissingCoderAccessURL", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -380,14 +383,14 @@ func TestNew(t *testing.T) { t.Run("EmptyCoderAccessURL", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: " ", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -397,14 +400,14 @@ func TestNew(t *testing.T) { t.Run("InvalidCoderAccessURL", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "://invalid", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -419,7 +422,7 @@ func TestNew(t *testing.T) { _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: ":0", CoderAccessURL: "http://localhost:3000", - KeyFile: "key.pem", + MITMKeyFile: "key.pem", DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -434,7 +437,7 @@ func TestNew(t *testing.T) { _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: ":0", CoderAccessURL: "http://localhost:3000", - CertFile: "cert.pem", + MITMCertFile: "cert.pem", DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -449,8 +452,8 @@ func TestNew(t *testing.T) { _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: ":0", CoderAccessURL: "http://localhost:3000", - CertFile: "/nonexistent/cert.pem", - KeyFile: "/nonexistent/key.pem", + MITMCertFile: "/nonexistent/cert.pem", + MITMKeyFile: "/nonexistent/key.pem", DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.Error(t, err) @@ -460,14 +463,14 @@ func TestNew(t *testing.T) { t.Run("MissingDomainAllowlist", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: ":0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "domain allow list is required") @@ -476,14 +479,14 @@ func TestNew(t *testing.T) { t.Run("EmptyDomainAllowlist", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: ":0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{""}, }) require.Error(t, err) @@ -493,14 +496,14 @@ func TestNew(t *testing.T) { t.Run("InvalidDomainAllowlist", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{"[invalid:domain"}, }) require.Error(t, err) @@ -510,14 +513,14 @@ func TestNew(t *testing.T) { t.Run("DomainWithNonAllowedPort", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{"api.anthropic.com:8443"}, }) require.Error(t, err) @@ -527,14 +530,14 @@ func TestNew(t *testing.T) { t.Run("AllowlistWithoutProviderMapping", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{"unknown.example.com"}, }) require.Error(t, err) @@ -544,14 +547,14 @@ func TestNew(t *testing.T) { t.Run("InvalidUpstreamProxy", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "://invalid-url", }) @@ -562,14 +565,14 @@ func TestNew(t *testing.T) { t.Run("UpstreamProxyCAFileNotFound", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "https://proxy.example.com:8080", UpstreamProxyCA: "/nonexistent/ca.pem", @@ -581,14 +584,14 @@ func TestNew(t *testing.T) { t.Run("UpstreamProxyAuthWithBothEmpty", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "http://:@proxy.example.com:8080", }) @@ -599,14 +602,14 @@ func TestNew(t *testing.T) { t.Run("Success", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.NoError(t, err) @@ -616,14 +619,14 @@ func TestNew(t *testing.T) { t.Run("SuccessWithUpstreamProxy", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "http://proxy.example.com:8080", }) @@ -634,18 +637,18 @@ func TestNew(t *testing.T) { t.Run("SuccessWithHTTPSUpstreamProxyAndCA", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) - // Use the shared test CA as the upstream proxy CA (it's a valid PEM cert) + // Use the shared MITM certificate as the upstream proxy CA (it's a valid PEM cert) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "https://proxy.example.com:8080", - UpstreamProxyCA: certFile, + UpstreamProxyCA: mitmCertFile, }) require.NoError(t, err) require.NotNil(t, srv) @@ -654,14 +657,14 @@ func TestNew(t *testing.T) { t.Run("SuccessWithUpstreamProxyAuth", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080", }) @@ -672,14 +675,14 @@ func TestNew(t *testing.T) { t.Run("SuccessWithUpstreamProxyUsernameAuthColon", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "http://proxyuser:@proxy.example.com:8080", }) @@ -690,15 +693,15 @@ func TestNew(t *testing.T) { t.Run("SuccessWithUpstreamProxyUsernameAuth", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) // Username only (no colon) should also succeed (password is optional) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "http://proxyuser@proxy.example.com:8080", }) @@ -709,14 +712,14 @@ func TestNew(t *testing.T) { t.Run("SuccessWithUpstreamProxyTokenAuth", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, UpstreamProxy: "http://:proxypass@proxy.example.com:8080", }) @@ -727,7 +730,7 @@ func TestNew(t *testing.T) { t.Run("SuccessWithMetrics", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) // Create metrics instance to verify it can be passed and stored. @@ -737,8 +740,8 @@ func TestNew(t *testing.T) { srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, Metrics: metrics, }) @@ -753,14 +756,14 @@ func TestClose(t *testing.T) { t.Run("Success", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, }) require.NoError(t, err) @@ -776,7 +779,7 @@ func TestClose(t *testing.T) { t.Run("WithMetrics", func(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) // Create metrics instance to verify Close() properly unregisters them. @@ -786,8 +789,8 @@ func TestClose(t *testing.T) { srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: certFile, - KeyFile: keyFile, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, Metrics: metrics, }) @@ -864,7 +867,7 @@ func TestProxy_CertCaching(t *testing.T) { // - For tunneled requests, the client connects directly to the target server // through a tunnel, so it needs to trust the target's self-signed certificate. // - For MITM'd requests, the client connects through the proxy which generates - // certificates signed by our test CA, so it needs to trust the proxy's CA. + // certificates signed by the MITM certificate, so it needs to trust the MITM certificate. var certPool *x509.CertPool if tt.tunneled { certPool = x509.NewCertPool() @@ -1183,7 +1186,7 @@ func TestProxy_MITM(t *testing.T) { // - For tunneled requests, the client connects directly to the target server // through a tunnel, so it needs to trust the target's self-signed certificate. // - For MITM'd requests, the client connects through the proxy which generates - // certificates signed by our test CA, so it needs to trust the proxy's CA. + // certificates signed by the MITM certificate, so it needs to trust the MITM certificate. var certPool *x509.CertPool if tt.tunneled { certPool = x509.NewCertPool() @@ -1260,7 +1263,7 @@ func TestServeCACert(t *testing.T) { srv := newTestProxy(t) - // Create a request to the CA cert endpoint via the Handler. + // Create a request to the MITM certificate endpoint via the Handler. req := httptest.NewRequest(http.MethodGet, "/ca-cert.pem", nil) rec := httptest.NewRecorder() @@ -1282,7 +1285,7 @@ func TestServeCACert(t *testing.T) { require.NotNil(t, cert) // Verify it matches the original certificate. - certFile, _ := getSharedTestCA(t) + certFile, _ := getSharedTestMITMCert(t) expectedCertPEM, err := os.ReadFile(certFile) require.NoError(t, err) require.Equal(t, expectedCertPEM, body) @@ -1294,9 +1297,9 @@ func TestServeCACert(t *testing.T) { func TestServeCACert_CompoundPEM(t *testing.T) { t.Parallel() - certFile, keyFile := getSharedTestCA(t) + certFile, keyFile := getSharedTestMITMCert(t) - // Read the shared CA cert and key to create a compound PEM file. + // Read the shared MITM certificate and key to create a compound PEM file. certPEM, err := os.ReadFile(certFile) require.NoError(t, err) keyPEM, err := os.ReadFile(keyFile) @@ -1318,8 +1321,8 @@ func TestServeCACert_CompoundPEM(t *testing.T) { srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", CoderAccessURL: "http://localhost:3000", - CertFile: compoundCertFile, - KeyFile: keyFile, + MITMCertFile: compoundCertFile, + MITMKeyFile: keyFile, DomainAllowlist: []string{"127.0.0.1", "localhost"}, AIBridgeProviderFromHost: func(host string) string { return "test-provider" @@ -1328,7 +1331,7 @@ func TestServeCACert_CompoundPEM(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = srv.Close() }) - // Create a request to the CA cert endpoint via the Handler. + // Create a request to the MITM certificate endpoint via the Handler. req := httptest.NewRequest(http.MethodGet, "/ca-cert.pem", nil) rec := httptest.NewRecorder() @@ -1364,7 +1367,7 @@ func TestServeCACert_CompoundPEM(t *testing.T) { // Verify the certificate is valid X.509. cert, err := x509.ParseCertificate(pemBlocks[0].Bytes) require.NoError(t, err) - require.Equal(t, "Shared Test CA", cert.Subject.CommonName) + require.Equal(t, "Shared Test MITM Cert", cert.Subject.CommonName) } func TestUpstreamProxy(t *testing.T) { @@ -1609,7 +1612,7 @@ func TestUpstreamProxy(t *testing.T) { // Configure certificate trust based on test case: // - For tunneled requests: client trusts final destination's CA. - // - For MITM: client trusts aiproxy's CA (fake certs). + // - For MITM: client trusts aiproxy's MITM certificate (for generated leaf certs). var certPool *x509.CertPool if tt.tunneled { certPool = x509.NewCertPool() diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 589c7efe68..4233b6a034 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -24,8 +24,8 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (*aibridgeproxyd.Server, error srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{ ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(), CoderAccessURL: coderAPI.AccessURL.String(), - CertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.CertFile.String(), - KeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.KeyFile.String(), + MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(), + MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(), DomainAllowlist: coderAPI.DeploymentValues.AI.BridgeProxyConfig.DomainAllowlist.Value(), UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(), UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(), diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index b6f3f6bd61..77d0371929 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -176,19 +176,22 @@ AI BRIDGE OPTIONS: exporting these records to external SIEM or observability systems. AI BRIDGE PROXY OPTIONS: - --aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE - Path to the CA certificate file for AI Bridge Proxy. - --aibridge-proxy-enabled bool, $CODER_AIBRIDGE_PROXY_ENABLED (default: false) Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider requests. - --aibridge-proxy-key-file string, $CODER_AIBRIDGE_PROXY_KEY_FILE - Path to the CA private key file for AI Bridge Proxy. - --aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888) The address the AI Bridge Proxy will listen on. + --aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE + Path to the CA certificate file used to intercept (MITM) HTTPS traffic + from AI clients. This CA must be trusted by AI clients for the proxy + to decrypt their requests. + + --aibridge-proxy-key-file string, $CODER_AIBRIDGE_PROXY_KEY_FILE + Path to the CA private key file used to intercept (MITM) HTTPS traffic + from AI clients. + --aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests through. Format: http://[user:pass@]host:port or