From 21c91cebaaacb0dab282ad852ce33c48554a2be5 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 5 Mar 2026 09:19:34 +0000 Subject: [PATCH] feat: add TLS listener support to aibridgeproxyd (#22411) ## Description Adds optional TLS support for the AI Bridge Proxy listener. When TLS cert and key files are provided, the proxy serves over HTTPS instead of plain HTTP. ## Changes * New configuration options to enable TLS on the proxy listener * Wraps the TCP listener in `tls.NewListener` when configured * Tests for validation errors, invalid files, and full integration (tunneled + MITM) through a TLS listener Note: Documentation for TLS listener setup and client configuration will be handled in a follow-up PR. Related to: https://github.com/coder/internal/issues/1335 --- cli/testdata/coder_server_--help.golden | 8 + cli/testdata/server-config.yaml.golden | 8 + coderd/apidoc/docs.go | 6 + coderd/apidoc/swagger.json | 6 + codersdk/deployment.go | 22 ++ docs/reference/api/general.md | 2 + docs/reference/api/schemas.md | 10 + docs/reference/cli/server.md | 20 ++ enterprise/aibridgeproxyd/aibridgeproxyd.go | 36 ++- .../aibridgeproxyd/aibridgeproxyd_test.go | 223 +++++++++++++++++- enterprise/cli/aibridgeproxyd.go | 2 + .../cli/testdata/coder_server_--help.golden | 8 + site/src/api/typesGenerated.ts | 2 + 13 files changed, 346 insertions(+), 7 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 1632f7cb5e..068762bf89 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -191,6 +191,14 @@ AI BRIDGE PROXY OPTIONS: Path to the CA private key file used to intercept (MITM) HTTPS traffic from AI clients. + --aibridge-proxy-tls-cert-file string, $CODER_AIBRIDGE_PROXY_TLS_CERT_FILE + Path to the TLS certificate file for the AI Bridge Proxy listener. + Must be set together with AI Bridge Proxy TLS Key File. + + --aibridge-proxy-tls-key-file string, $CODER_AIBRIDGE_PROXY_TLS_KEY_FILE + Path to the TLS private key file for the AI Bridge Proxy listener. + Must be set together with AI Bridge Proxy TLS Certificate File. + --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 5f0a3c6a63..5c9eb8bd8b 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -830,6 +830,14 @@ aibridgeproxy: # The address the AI Bridge Proxy will listen on. # (default: :8888, type: string) listen_addr: :8888 + # Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set + # together with AI Bridge Proxy TLS Key File. + # (default: , type: string) + tls_cert_file: "" + # Path to the TLS private key file for the AI Bridge Proxy listener. Must be set + # together with AI Bridge Proxy TLS Certificate File. + # (default: , type: string) + tls_key_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. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 57288f672d..e2922344eb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12576,6 +12576,12 @@ const docTemplate = `{ "listen_addr": { "type": "string" }, + "tls_cert_file": { + "type": "string" + }, + "tls_key_file": { + "type": "string" + }, "upstream_proxy": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ae328b04d1..0561c2f6b7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11184,6 +11184,12 @@ "listen_addr": { "type": "string" }, + "tls_cert_file": { + "type": "string" + }, + "tls_key_file": { + "type": "string" + }, "upstream_proxy": { "type": "string" }, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2e6f31fb56..782ef7e383 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3857,6 +3857,26 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupAIBridgeProxy, YAML: "listen_addr", }, + { + Name: "AI Bridge Proxy TLS Certificate File", + Description: "Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Key File.", + Flag: "aibridge-proxy-tls-cert-file", + Env: "CODER_AIBRIDGE_PROXY_TLS_CERT_FILE", + Value: &c.AI.BridgeProxyConfig.TLSCertFile, + Default: "", + Group: &deploymentGroupAIBridgeProxy, + YAML: "tls_cert_file", + }, + { + Name: "AI Bridge Proxy TLS Key File", + Description: "Path to the TLS private key file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Certificate File.", + Flag: "aibridge-proxy-tls-key-file", + Env: "CODER_AIBRIDGE_PROXY_TLS_KEY_FILE", + Value: &c.AI.BridgeProxyConfig.TLSKeyFile, + Default: "", + Group: &deploymentGroupAIBridgeProxy, + YAML: "tls_key_file", + }, { 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.", @@ -4014,6 +4034,8 @@ type AIBridgeBedrockConfig struct { type AIBridgeProxyConfig struct { Enabled serpent.Bool `json:"enabled" typescript:",notnull"` ListenAddr serpent.String `json:"listen_addr" typescript:",notnull"` + TLSCertFile serpent.String `json:"tls_cert_file" typescript:",notnull"` + TLSKeyFile serpent.String `json:"tls_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"` diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 012ff120d5..98a4bde250 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -170,6 +170,8 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "enabled": true, "key_file": "string", "listen_addr": "string", + "tls_cert_file": "string", + "tls_key_file": "string", "upstream_proxy": "string", "upstream_proxy_ca": "string" }, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1cb7987895..cd67ea783d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -623,6 +623,8 @@ "enabled": true, "key_file": "string", "listen_addr": "string", + "tls_cert_file": "string", + "tls_key_file": "string", "upstream_proxy": "string", "upstream_proxy_ca": "string" } @@ -637,6 +639,8 @@ | `enabled` | boolean | false | | | | `key_file` | string | false | | | | `listen_addr` | string | false | | | +| `tls_cert_file` | string | false | | | +| `tls_key_file` | string | false | | | | `upstream_proxy` | string | false | | | | `upstream_proxy_ca` | string | false | | | @@ -746,6 +750,8 @@ "enabled": true, "key_file": "string", "listen_addr": "string", + "tls_cert_file": "string", + "tls_key_file": "string", "upstream_proxy": "string", "upstream_proxy_ca": "string" }, @@ -2671,6 +2677,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "enabled": true, "key_file": "string", "listen_addr": "string", + "tls_cert_file": "string", + "tls_key_file": "string", "upstream_proxy": "string", "upstream_proxy_ca": "string" }, @@ -3240,6 +3248,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "enabled": true, "key_file": "string", "listen_addr": "string", + "tls_cert_file": "string", + "tls_key_file": "string", "upstream_proxy": "string", "upstream_proxy_ca": "string" }, diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 72f40459d2..acb0dcfc7b 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1912,6 +1912,26 @@ Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider requ The address the AI Bridge Proxy will listen on. +### --aibridge-proxy-tls-cert-file + +| | | +|-------------|--------------------------------------------------| +| Type | string | +| Environment | $CODER_AIBRIDGE_PROXY_TLS_CERT_FILE | +| YAML | aibridgeproxy.tls_cert_file | + +Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Key File. + +### --aibridge-proxy-tls-key-file + +| | | +|-------------|-------------------------------------------------| +| Type | string | +| Environment | $CODER_AIBRIDGE_PROXY_TLS_KEY_FILE | +| YAML | aibridgeproxy.tls_key_file | + +Path to the TLS private key file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Certificate File. + ### --aibridge-proxy-cert-file | | | diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 073f2f2381..5eb4c52a0c 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -66,6 +66,7 @@ type Server struct { proxy *goproxy.ProxyHttpServer httpServer *http.Server listener net.Listener + tlsEnabled bool coderAccessURL *url.URL aibridgeProviderFromHost func(host string) string // caCert is the PEM-encoded MITM CA certificate loaded during initialization. @@ -99,6 +100,10 @@ type requestContext struct { type Options struct { // ListenAddr is the address the proxy server will listen on. ListenAddr string + // TLSCertFile is the path to the TLS certificate file for the proxy listener. + TLSCertFile string + // TLSKeyFile is the path to the TLS private key file for the proxy listener. + TLSKeyFile string // CoderAccessURL is the URL of the Coder deployment where aibridged is running. // Requests to supported AI providers are forwarded here. CoderAccessURL string @@ -141,6 +146,12 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) return nil, xerrors.New("listen address is required") } + // Listener TLS requires both cert and key files. When set, the proxy listener + // is served over HTTPS, otherwise it defaults to HTTP. + if (opts.TLSCertFile != "") != (opts.TLSKeyFile != "") { + return nil, xerrors.New("tls cert file and tls key file must both be set") + } + if strings.TrimSpace(opts.CoderAccessURL) == "" { return nil, xerrors.New("coder access URL is required") } @@ -272,6 +283,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) ctx: ctx, logger: logger, proxy: proxy, + tlsEnabled: opts.TLSCertFile != "", coderAccessURL: coderAccessURL, aibridgeProviderFromHost: aibridgeProviderFromHost, caCert: certPEM, @@ -301,13 +313,27 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) // Handle responses from aibridged. proxy.OnResponse().DoFunc(srv.handleResponse) - // Create listener first so we can get the actual address. - // This is useful in tests where port 0 is used to avoid conflicts. + // Create a plain HTTP listener by default. Port 0 is accepted and resolves + // to a random available port, which is useful in tests to avoid conflicts. listener, err := net.Listen("tcp", opts.ListenAddr) if err != nil { return nil, xerrors.Errorf("failed to listen on %s: %w", opts.ListenAddr, err) } + // Upgrade to HTTPS by wrapping the listener in TLS. The plain listener is + // closed explicitly on error to avoid leaking the bound socket. + if opts.TLSCertFile != "" { + tlsCert, err := tls.LoadX509KeyPair(opts.TLSCertFile, opts.TLSKeyFile) + if err != nil { + _ = listener.Close() + return nil, xerrors.Errorf("load listener TLS certificate: %w", err) + } + listener = tls.NewListener(listener, &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{tlsCert}, + }) + } + srv.listener = listener // Start HTTP server in background @@ -318,6 +344,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) logger.Info(ctx, "aibridgeproxyd configured", slog.F("listen_addr", listener.Addr().String()), + slog.F("tls_listener_enabled", srv.tlsEnabled), slog.F("coder_access_url", coderAccessURL.String()), slog.F("domain_allowlist", mitmHosts), slog.F("upstream_proxy", opts.UpstreamProxy), @@ -342,6 +369,11 @@ func (s *Server) Addr() string { return s.listener.Addr().String() } +// IsTLSListener reports whether the proxy listener is serving TLS. +func (s *Server) IsTLSListener() bool { + return s.tlsEnabled +} + // Close gracefully shuts down the proxy server. func (s *Server) Close() error { if s.httpServer == nil { diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 821943a3cd..513787d68c 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -105,8 +105,47 @@ func generateSharedTestMITMCert() (certFile, keyFile string, err error) { return certPath, keyPath, nil } +// generateListenerCert generates a self-signed certificate and key for use as a +// proxy listener TLS certificate. Files are written to t.TempDir() and cleaned +// up automatically when the test ends. +func generateListenerCert(t *testing.T) (certFile, keyFile string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate listener key") + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test Listener"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + // The client connects to the proxy via IP address, so the certificate + // must include 127.0.0.1 as a Subject Alternative Name for validation to succeed. + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err, "create listener certificate") + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "listener.crt") + keyPath := filepath.Join(tmpDir, "listener.key") + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + require.NoError(t, os.WriteFile(certPath, certPEM, 0o600), "write listener cert file") + + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600), "write listener key file") + + return certPath, keyPath +} + type testProxyConfig struct { listenAddr string + tlsCertFile string + tlsKeyFile string coderAccessURL string allowedPorts []string certStore *aibridgeproxyd.CertCache @@ -167,6 +206,13 @@ func withMetrics(metrics *aibridgeproxyd.Metrics) testProxyOption { } } +func withListenerTLS(certFile, keyFile string) testProxyOption { + return func(cfg *testProxyConfig) { + cfg.tlsCertFile = certFile + cfg.tlsKeyFile = keyFile + } +} + // newTestProxy creates a new AI Bridge Proxy server for testing. // It uses the shared MITM certificate and registers cleanup automatically. // It waits for the proxy server to be ready before returning. @@ -190,6 +236,8 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server aibridgeOpts := aibridgeproxyd.Options{ ListenAddr: cfg.listenAddr, + TLSCertFile: cfg.tlsCertFile, + TLSKeyFile: cfg.tlsKeyFile, CoderAccessURL: cfg.coderAccessURL, MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, @@ -241,15 +289,21 @@ func getProxyCertPool(t *testing.T) *x509.CertPool { return certPool } -// newProxyClient creates an HTTP client configured to use the proxy. +// newProxyClient creates an HTTP(S) 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 MITM certificate. For tunneled requests, use the target server's cert. +// The certPool parameter specifies which certificates the client should trust: +// - If the proxy listener is TLS, include the listener certificate. +// - For MITM'd requests, include the proxy's MITM certificate. +// - For tunneled requests, include the target server's certificate. func newProxyClient(t *testing.T, srv *aibridgeproxyd.Server, proxyAuth string, certPool *x509.CertPool) *http.Client { t.Helper() - // Create an HTTP client configured to use the proxy. - proxyURL, err := url.Parse("http://" + srv.Addr()) + // Create an HTTP(S) client configured to use the proxy. + scheme := "http" + if srv.IsTLSListener() { + scheme = "https" + } + proxyURL, err := url.Parse(scheme + "://" + srv.Addr()) require.NoError(t, err) transport := &http.Transport{ @@ -364,6 +418,61 @@ func TestNew(t *testing.T) { require.Contains(t, err.Error(), "listen address is required") }) + t.Run("TLSCertWithoutKey", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + logger := slogtest.Make(t, nil) + + _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + TLSCertFile: "cert.pem", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "tls cert file and tls key file must both be set") + }) + + t.Run("TLSKeyWithoutCert", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + logger := slogtest.Make(t, nil) + + _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + TLSKeyFile: "key.pem", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "tls cert file and tls key file must both be set") + }) + + t.Run("InvalidListenerTLSFiles", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + logger := slogtest.Make(t, nil) + + _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + TLSCertFile: "/nonexistent/cert.pem", + TLSKeyFile: "/nonexistent/key.pem", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "load listener TLS certificate") + }) + t.Run("MissingCoderAccessURL", func(t *testing.T) { t.Parallel() @@ -616,6 +725,26 @@ func TestNew(t *testing.T) { require.NotNil(t, srv) }) + t.Run("SuccessWithListenerTLS", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + listenerCertFile, listenerKeyFile := generateListenerCert(t) + logger := slogtest.Make(t, nil) + + srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + TLSCertFile: listenerCertFile, + TLSKeyFile: listenerKeyFile, + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + }) + require.NoError(t, err) + require.NotNil(t, srv) + }) + t.Run("SuccessWithUpstreamProxy", func(t *testing.T) { t.Parallel() @@ -1250,6 +1379,90 @@ func TestProxy_MITM(t *testing.T) { } } +// TestListenerTLS verifies that the proxy works correctly when its listener is wrapped in TLS. +// It tests both tunneled and MITM'd requests through an HTTPS proxy listener. +func TestListenerTLS(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tunneled bool + expectedBody string + }{ + { + name: "Tunneled", + tunneled: true, + expectedBody: "hello from tunneled", + }, + { + name: "MITM", + tunneled: false, + expectedBody: "hello from aibridged", + }, + } + + // Shared across subtests since all use the same TLS listener certificate. + listenerCertFile, listenerKeyFile := generateListenerCert(t) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Mock aibridged server that receives MITM'd requests. + aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello from aibridged")) + })) + t.Cleanup(func() { aibridgedServer.Close() }) + + // Target server: response is returned directly for tunneled, intercepted for MITM. + tunneledServer, targetURL := newTargetServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello from tunneled")) + }) + + var proxyOpts []testProxyOption + proxyOpts = append(proxyOpts, + withListenerTLS(listenerCertFile, listenerKeyFile), + withCoderAccessURL(aibridgedServer.URL), + withAllowedPorts(targetURL.Port()), + ) + if tt.tunneled { + // Use a domain allowlist that excludes the target server so requests are tunneled. + proxyOpts = append(proxyOpts, withDomainAllowlist(aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI)) + } + + srv := newTestProxy(t, proxyOpts...) + + // Cert pool must include two certificates: the listener certificate to connect + // to the proxy over TLS, and the MITM or target certificate for the inner + // TLS handshake. + listenerCertPEM, err := os.ReadFile(listenerCertFile) + require.NoError(t, err) + var certPool *x509.CertPool + if tt.tunneled { + certPool = x509.NewCertPool() + certPool.AddCert(tunneledServer.Certificate()) + } else { + certPool = getProxyCertPool(t) + } + certPool.AppendCertsFromPEM(listenerCertPEM) + + client := newProxyClient(t, srv, makeProxyAuthHeader("test-token"), certPool) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, targetURL.String(), nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tt.expectedBody, string(body)) + }) + } +} + // TestServeCACert validates that a configured certificate file can be served correctly by the API. // // Note: Tests for certificate file errors (missing file, invalid PEM) are diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 4233b6a034..16b8cc7fa9 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -23,6 +23,8 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (*aibridgeproxyd.Server, error srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{ ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(), + TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(), + TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(), CoderAccessURL: coderAPI.AccessURL.String(), MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(), MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(), diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 77d0371929..3d1476f974 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -192,6 +192,14 @@ AI BRIDGE PROXY OPTIONS: Path to the CA private key file used to intercept (MITM) HTTPS traffic from AI clients. + --aibridge-proxy-tls-cert-file string, $CODER_AIBRIDGE_PROXY_TLS_CERT_FILE + Path to the TLS certificate file for the AI Bridge Proxy listener. + Must be set together with AI Bridge Proxy TLS Key File. + + --aibridge-proxy-tls-key-file string, $CODER_AIBRIDGE_PROXY_TLS_KEY_FILE + Path to the TLS private key file for the AI Bridge Proxy listener. + Must be set together with AI Bridge Proxy TLS Certificate File. + --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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 698092b966..6b40d18bb9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -82,6 +82,8 @@ export interface AIBridgeOpenAIConfig { export interface AIBridgeProxyConfig { readonly enabled: boolean; readonly listen_addr: string; + readonly tls_cert_file: string; + readonly tls_key_file: string; readonly cert_file: string; readonly key_file: string; readonly domain_allowlist: string;