diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 218649c546..5cba55ea4e 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -165,6 +165,17 @@ AI BRIDGE PROXY OPTIONS: --aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888) The address the AI Bridge Proxy will listen on. + --aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM + URL of an upstream HTTP proxy to chain passthrough (non-allowlisted) + requests through. Format: http://[user:pass@]host:port or + https://[user:pass@]host:port. + + --aibridge-proxy-upstream-ca string, $CODER_AIBRIDGE_PROXY_UPSTREAM_CA + Path to a PEM-encoded CA certificate to trust for the upstream proxy's + TLS connection. Only needed for HTTPS upstream proxies with + certificates not trusted by the system. If not provided, the system + certificate pool is used. + CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index cb570b0b9e..6cd8eb0d14 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -798,6 +798,15 @@ aibridgeproxy: domain_allowlist: - api.anthropic.com - api.openai.com + # URL of an upstream HTTP proxy to chain passthrough (non-allowlisted) requests + # through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port. + # (default: , type: string) + upstream_proxy: "" + # Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS + # connection. Only needed for HTTPS upstream proxies with certificates not trusted + # by the system. If not provided, the system certificate pool is used. + # (default: , type: string) + upstream_proxy_ca: "" # Configure data retention policies for various database tables. Retention # policies automatically purge old data to reduce database size and improve # performance. Setting a retention duration to 0 disables automatic purging for diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3027f6cad1..74ef30db76 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12157,6 +12157,12 @@ const docTemplate = `{ }, "listen_addr": { "type": "string" + }, + "upstream_proxy": { + "type": "string" + }, + "upstream_proxy_ca": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index dc35d71e5f..b18e7359cd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10809,6 +10809,12 @@ }, "listen_addr": { "type": "string" + }, + "upstream_proxy": { + "type": "string" + }, + "upstream_proxy_ca": { + "type": "string" } } }, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ec258c960a..b131339899 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3547,6 +3547,26 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupAIBridgeProxy, YAML: "domain_allowlist", }, + { + Name: "AI Bridge Proxy Upstream Proxy", + Description: "URL of an upstream HTTP proxy to chain passthrough (non-allowlisted) requests through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.", + Flag: "aibridge-proxy-upstream", + Env: "CODER_AIBRIDGE_PROXY_UPSTREAM", + Value: &c.AI.BridgeProxyConfig.UpstreamProxy, + Default: "", + Group: &deploymentGroupAIBridgeProxy, + YAML: "upstream_proxy", + }, + { + Name: "AI Bridge Proxy Upstream Proxy CA", + Description: "Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS connection. Only needed for HTTPS upstream proxies with certificates not trusted by the system. If not provided, the system certificate pool is used.", + Flag: "aibridge-proxy-upstream-ca", + Env: "CODER_AIBRIDGE_PROXY_UPSTREAM_CA", + Value: &c.AI.BridgeProxyConfig.UpstreamProxyCA, + Default: "", + Group: &deploymentGroupAIBridgeProxy, + YAML: "upstream_proxy_ca", + }, // Retention settings { @@ -3647,6 +3667,8 @@ type AIBridgeProxyConfig struct { CertFile serpent.String `json:"cert_file" typescript:",notnull"` KeyFile 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"` } type AIConfig struct { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 3ab6f425e6..009a5105d1 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -169,7 +169,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ ], "enabled": true, "key_file": "string", - "listen_addr": "string" + "listen_addr": "string", + "upstream_proxy": "string", + "upstream_proxy_ca": "string" }, "bridge": { "anthropic": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 095512443a..f21cbd67a3 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -604,19 +604,23 @@ ], "enabled": true, "key_file": "string", - "listen_addr": "string" + "listen_addr": "string", + "upstream_proxy": "string", + "upstream_proxy_ca": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|-----------------|----------|--------------|-------------| -| `cert_file` | string | false | | | -| `domain_allowlist` | array of string | false | | | -| `enabled` | boolean | false | | | -| `key_file` | string | false | | | -| `listen_addr` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------|-----------------|----------|--------------|-------------| +| `cert_file` | string | false | | | +| `domain_allowlist` | array of string | false | | | +| `enabled` | boolean | false | | | +| `key_file` | string | false | | | +| `listen_addr` | string | false | | | +| `upstream_proxy` | string | false | | | +| `upstream_proxy_ca` | string | false | | | ## codersdk.AIBridgeTokenUsage @@ -723,7 +727,9 @@ ], "enabled": true, "key_file": "string", - "listen_addr": "string" + "listen_addr": "string", + "upstream_proxy": "string", + "upstream_proxy_ca": "string" }, "bridge": { "anthropic": { @@ -2639,7 +2645,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ], "enabled": true, "key_file": "string", - "listen_addr": "string" + "listen_addr": "string", + "upstream_proxy": "string", + "upstream_proxy_ca": "string" }, "bridge": { "anthropic": { @@ -3184,7 +3192,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ], "enabled": true, "key_file": "string", - "listen_addr": "string" + "listen_addr": "string", + "upstream_proxy": "string", + "upstream_proxy_ca": "string" }, "bridge": { "anthropic": { diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 52a0bda14a..a4c38dec4c 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1889,6 +1889,26 @@ Path to the CA certificate file for AI Bridge Proxy. Path to the CA private key file for AI Bridge Proxy. +### --aibridge-proxy-upstream + +| | | +|-------------|---------------------------------------------| +| Type | string | +| Environment | $CODER_AIBRIDGE_PROXY_UPSTREAM | +| YAML | aibridgeproxy.upstream_proxy | + +URL of an upstream HTTP proxy to chain passthrough (non-allowlisted) requests through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port. + +### --aibridge-proxy-upstream-ca + +| | | +|-------------|------------------------------------------------| +| Type | string | +| Environment | $CODER_AIBRIDGE_PROXY_UPSTREAM_CA | +| YAML | aibridgeproxy.upstream_proxy_ca | + +Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS connection. Only needed for HTTPS upstream proxies with certificates not trusted by the system. If not provided, the system certificate pool is used. + ### --audit-logs-retention | | | diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 79d7e3bf48..dd87495fd8 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "net/url" + "os" "slices" "strings" "sync" @@ -74,6 +75,16 @@ type Options struct { // Only requests to these domains will be MITM'd and forwarded to aibridged. // Requests to other domains will be tunneled directly without decryption. DomainAllowlist []string + // UpstreamProxy is the URL of an upstream HTTP proxy to chain passthrough + // (non-allowlisted) requests through. If empty, passthrough requests connect + // directly to their destinations. + // Format: http://[user:pass@]host:port or https://[user:pass@]host:port + UpstreamProxy string + // UpstreamProxyCA is the path to a PEM-encoded CA certificate file to trust + // for the upstream proxy's TLS connection. Only needed for HTTPS upstream + // proxies with certificates not trusted by the system. If empty, the system + // certificate pool is used. + UpstreamProxyCA string } func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) { @@ -132,6 +143,65 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) proxy.CertStore = NewCertCache() } + // Always set secure TLS defaults, overriding goproxy's default. + // This ensures secure TLS connections for: + // - HTTPS upstream proxy connections + // - MITM'd requests if aibridge uses HTTPS + rootCAs, err := x509.SystemCertPool() + if err != nil { + return nil, xerrors.Errorf("failed to load system certificate pool: %w", err) + } + + // Configure upstream proxy for passthrough (non-allowlisted) requests. + // This only affects CONNECT requests to domains not in the allowlist. + // MITM'd requests (allowlisted domains) are handled by aiproxy and forwarded + // to aibridge directly, not through the upstream proxy. AI Bridge respects + // proxy environment variables if set, so the upstream proxy is used at that + // layer instead. + if opts.UpstreamProxy != "" { + upstreamURL, err := url.Parse(opts.UpstreamProxy) + if err != nil { + return nil, xerrors.Errorf("invalid upstream proxy URL %q: %w", opts.UpstreamProxy, err) + } + + logger.Info(ctx, "configuring upstream proxy for passthrough requests", + slog.F("upstream", upstreamURL.Host), + ) + + // Set transport without Proxy to ensure MITM'd requests go directly to aibridge, + // not through any upstream proxy. + proxy.Tr = &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + }, + } + + // Add custom CA certificate if provided (for corporate proxies with private CAs). + // If no CA certificate is provided, the system certificate pool is used. + if opts.UpstreamProxyCA != "" { + if upstreamURL.Scheme == "https" { + caCert, err := os.ReadFile(opts.UpstreamProxyCA) + if err != nil { + return nil, xerrors.Errorf("failed to read upstream proxy CA certificate from %q: %w", opts.UpstreamProxyCA, err) + } + if !rootCAs.AppendCertsFromPEM(caCert) { + return nil, xerrors.Errorf("failed to parse upstream proxy CA certificate") + } + logger.Info(ctx, "configured upstream proxy CA certificate") + } else { + logger.Warn(ctx, "upstream proxy CA certificate is only used for HTTPS upstream proxies, ignoring", + slog.F("upstream_scheme", upstreamURL.Scheme), + ) + } + } + + // Configure passthrough CONNECT requests to go through upstream proxy. + // This only affects non-allowlisted domains; allowlisted domains are + // MITM'd and forwarded to aibridge. + proxy.ConnectDial = proxy.NewConnectDialToProxy(opts.UpstreamProxy) + } + srv := &Server{ ctx: ctx, logger: logger, diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 1dce7fccf9..5e550e3338 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -8,6 +8,7 @@ import ( "crypto/x509/pkix" "encoding/base64" "encoding/pem" + "fmt" "io" "math/big" "net" @@ -101,6 +102,8 @@ type testProxyConfig struct { allowedPorts []string certStore *aibridgeproxyd.CertCache domainAllowlist []string + upstreamProxy string + upstreamProxyCA string } type testProxyOption func(*testProxyConfig) @@ -129,6 +132,18 @@ func withDomainAllowlist(domains ...string) testProxyOption { } } +func withUpstreamProxy(upstreamProxy string) testProxyOption { + return func(cfg *testProxyConfig) { + cfg.upstreamProxy = upstreamProxy + } +} + +func withUpstreamProxyCA(upstreamProxyCA string) testProxyOption { + return func(cfg *testProxyConfig) { + cfg.upstreamProxyCA = upstreamProxyCA + } +} + // newTestProxy creates a new AI Bridge Proxy server for testing. // It uses the shared test CA and registers cleanup automatically. // It waits for the proxy server to be ready before returning. @@ -154,6 +169,8 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server KeyFile: keyFile, AllowedPorts: cfg.allowedPorts, DomainAllowlist: cfg.domainAllowlist, + UpstreamProxy: cfg.upstreamProxy, + UpstreamProxyCA: cfg.upstreamProxyCA, } if cfg.certStore != nil { aibridgeOpts.CertStore = cfg.certStore @@ -446,6 +463,43 @@ func TestNew(t *testing.T) { require.Contains(t, err.Error(), "invalid port in domain") }) + t.Run("InvalidUpstreamProxy", func(t *testing.T) { + t.Parallel() + + certFile, keyFile := getSharedTestCA(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, + DomainAllowlist: []string{"api.anthropic.com"}, + UpstreamProxy: "://invalid-url", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid upstream proxy URL") + }) + + t.Run("UpstreamProxyCAFileNotFound", func(t *testing.T) { + t.Parallel() + + certFile, keyFile := getSharedTestCA(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, + DomainAllowlist: []string{"api.anthropic.com"}, + UpstreamProxy: "https://proxy.example.com:8080", + UpstreamProxyCA: "/nonexistent/ca.pem", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to read upstream proxy CA certificate") + }) + t.Run("Success", func(t *testing.T) { t.Parallel() @@ -461,9 +515,44 @@ func TestNew(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, srv) + }) - err = srv.Close() + t.Run("SuccessWithUpstreamProxy", func(t *testing.T) { + t.Parallel() + + certFile, keyFile := getSharedTestCA(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, + DomainAllowlist: []string{"api.anthropic.com", "api.openai.com"}, + UpstreamProxy: "http://proxy.example.com:8080", + }) require.NoError(t, err) + require.NotNil(t, srv) + }) + + t.Run("SuccessWithHTTPSUpstreamProxyAndCA", func(t *testing.T) { + t.Parallel() + + certFile, keyFile := getSharedTestCA(t) + logger := slogtest.Make(t, nil) + + // Use the shared test CA 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, + DomainAllowlist: []string{"api.anthropic.com"}, + UpstreamProxy: "https://proxy.example.com:8080", + UpstreamProxyCA: certFile, + }) + require.NoError(t, err) + require.NotNil(t, srv) }) } @@ -983,3 +1072,250 @@ func TestServeCACert_CompoundPEM(t *testing.T) { require.NoError(t, err) require.Equal(t, "Shared Test CA", cert.Subject.CommonName) } + +func TestUpstreamProxy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + // passthrough determines whether the request should be tunneled through + // the upstream proxy (true) or MITM'd by aiproxy (false). + // When true, the target domain is NOT in the allowlist. + // When false, the target domain IS in the allowlist. + passthrough bool + // upstreamProxyTLS determines whether the upstream proxy uses TLS. + // When true, aiproxy must be configured with the upstream proxy's CA. + upstreamProxyTLS bool + // buildTargetURL constructs the request URL. For passthrough, it uses + // the final destination URL. For MITM, it uses api.anthropic.com. + buildTargetURL func(finalDestinationURL *url.URL) string + // expectedAIBridgePath is the path aibridge should receive for MITM requests. + expectedAIBridgePath string + }{ + { + name: "NonAllowlistedDomain_PassthroughToHTTPUpstreamProxy", + passthrough: true, + upstreamProxyTLS: false, + buildTargetURL: func(finalDestinationURL *url.URL) string { + return fmt.Sprintf("https://%s/passthrough-path", finalDestinationURL.Host) + }, + }, + { + name: "NonAllowlistedDomain_PassthroughToHTTPSUpstreamProxy", + passthrough: true, + upstreamProxyTLS: true, + buildTargetURL: func(finalDestinationURL *url.URL) string { + return fmt.Sprintf("https://%s/passthrough-path", finalDestinationURL.Host) + }, + }, + { + name: "AllowlistedDomain_MITMByAIProxy", + passthrough: false, + upstreamProxyTLS: false, + buildTargetURL: func(_ *url.URL) string { + return "https://api.anthropic.com:443/v1/messages" + }, + expectedAIBridgePath: "/api/v2/aibridge/anthropic/v1/messages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Track requests received by each component to verify the flow. + var ( + upstreamProxyCONNECTReceived bool + upstreamProxyCONNECTHost string + finalDestinationReceived bool + finalDestinationPath string + finalDestinationBody string + aibridgeReceived bool + aibridgePath string + aibridgeAuthHeader string + aibridgeBody string + ) + + // Create mock final destination server representing the actual target: + // - For passthrough requests, traffic should reach this server. + // - For MITM requests, traffic should NOT reach this server. + finalDestination, finalDestinationURL := newTargetServer(t, func(w http.ResponseWriter, r *http.Request) { + finalDestinationReceived = true + finalDestinationPath = r.URL.Path + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + finalDestinationBody = string(body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("final destination response")) + }) + + // Upstream proxy handler: same logic for both HTTP and HTTPS. + upstreamProxyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "expected CONNECT request", http.StatusBadRequest) + return + } + + upstreamProxyCONNECTReceived = true + upstreamProxyCONNECTHost = r.Host + + // Connect to the mock final destination server. + targetConn, err := net.Dial("tcp", finalDestinationURL.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer targetConn.Close() + + // Hijack the connection to take over the raw TCP socket. + // After responding "200 Connection Established", the proxy stops being + // an HTTP server and becomes a transparent tunnel that copies bytes + // bidirectionally. The http package can't handle this mode, so we + // hijack and manage the connection ourselves. + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + // Send 200 Connection Established to signal tunnel is ready. + _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + + // Copy data bidirectionally between aiproxy and final destination. + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, _ = io.Copy(targetConn, clientConn) + }() + go func() { + defer wg.Done() + _, _ = io.Copy(clientConn, targetConn) + }() + wg.Wait() + }) + + // Create upstream proxy: HTTP or HTTPS based on test case. + var upstreamProxy *httptest.Server + var upstreamProxyCAFile string + if tt.upstreamProxyTLS { + upstreamProxy = httptest.NewTLSServer(upstreamProxyHandler) + // Write the upstream proxy's CA cert to a temp file for aiproxy to trust. + upstreamProxyCAFile = filepath.Join(t.TempDir(), "upstream-proxy-ca.pem") + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: upstreamProxy.Certificate().Raw, + }) + err := os.WriteFile(upstreamProxyCAFile, certPEM, 0o600) + require.NoError(t, err) + } else { + upstreamProxy = httptest.NewServer(upstreamProxyHandler) + } + t.Cleanup(upstreamProxy.Close) + + // Create a mock aibridged server: + // - For passthrough requests, traffic should NOT reach this server. + // - For MITM requests, aiproxy rewrites the URL and forwards here. + aibridgeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + aibridgeReceived = true + aibridgePath = r.URL.Path + aibridgeAuthHeader = r.Header.Get("Authorization") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + aibridgeBody = string(body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("aibridge response")) + })) + t.Cleanup(aibridgeServer.Close) + + // Build the target URL for this test case. + targetURL := tt.buildTargetURL(finalDestinationURL) + parsedTargetURL, err := url.Parse(targetURL) + require.NoError(t, err) + + // Configure allowlist based on test case: + // - For passthrough, api.anthropic.com is in allowlist, but we target a different host. + // - For MITM, api.anthropic.com must be in the allowlist. + domainAllowlist := []string{"api.anthropic.com"} + + // Create aiproxy with upstream proxy configured. + proxyOpts := []testProxyOption{ + withCoderAccessURL(aibridgeServer.URL), + withDomainAllowlist(domainAllowlist...), + withUpstreamProxy(upstreamProxy.URL), + withAllowedPorts("80", "443", finalDestinationURL.Port(), parsedTargetURL.Port()), + } + if upstreamProxyCAFile != "" { + proxyOpts = append(proxyOpts, withUpstreamProxyCA(upstreamProxyCAFile)) + } + srv := newTestProxy(t, proxyOpts...) + + // Configure certificate trust based on test case: + // - For passthrough: client trusts final destination's CA. + // - For MITM: client trusts aiproxy's CA (fake certs). + var certPool *x509.CertPool + if tt.passthrough { + certPool = x509.NewCertPool() + certPool.AddCert(finalDestination.Certificate()) + } else { + certPool = getProxyCertPool(t) + } + + // Create HTTP client configured to use aiproxy. + client := newProxyClient(t, srv, makeProxyAuthHeader("test-coder-token"), certPool) + + // Make request through aiproxy. + requestBody := `{"test": "data", "foo": "bar"}` + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, targetURL, strings.NewReader(requestBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the request flow based on test case. + if tt.passthrough { + require.True(t, upstreamProxyCONNECTReceived, + "upstream proxy should receive CONNECT for non-allowlisted domain") + require.Equal(t, finalDestinationURL.Host, upstreamProxyCONNECTHost, + "upstream proxy should receive CONNECT to correct host") + require.True(t, finalDestinationReceived, + "final destination should receive the passthrough request") + require.Equal(t, parsedTargetURL.Path, finalDestinationPath, + "final destination should receive correct path") + require.Equal(t, requestBody, finalDestinationBody, + "final destination should receive the exact request body") + require.False(t, aibridgeReceived, + "aibridge should NOT receive request for non-allowlisted domain") + } else { + require.False(t, upstreamProxyCONNECTReceived, + "upstream proxy should NOT receive CONNECT for allowlisted domain") + require.True(t, aibridgeReceived, + "aibridge should receive the MITM'd request") + require.Equal(t, tt.expectedAIBridgePath, aibridgePath, + "aibridge should receive rewritten path") + require.Equal(t, "Bearer test-coder-token", aibridgeAuthHeader, + "aibridge should receive auth header extracted from proxy auth") + require.Equal(t, requestBody, aibridgeBody, + "aibridge should receive the exact request body") + require.False(t, finalDestinationReceived, + "final destination should NOT receive request for allowlisted domain") + } + }) + } +} diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 9ee6725b72..94fd66516b 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -23,6 +23,8 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (*aibridgeproxyd.Server, error CertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.CertFile.String(), KeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.KeyFile.String(), DomainAllowlist: coderAPI.DeploymentValues.AI.BridgeProxyConfig.DomainAllowlist.Value(), + UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(), + UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(), }) if err != nil { return nil, xerrors.Errorf("failed to start in-memory aibridgeproxy daemon: %w", err) diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 30be0bcad5..7f53de54be 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -166,6 +166,17 @@ AI BRIDGE PROXY OPTIONS: --aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888) The address the AI Bridge Proxy will listen on. + --aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM + URL of an upstream HTTP proxy to chain passthrough (non-allowlisted) + requests through. Format: http://[user:pass@]host:port or + https://[user:pass@]host:port. + + --aibridge-proxy-upstream-ca string, $CODER_AIBRIDGE_PROXY_UPSTREAM_CA + Path to a PEM-encoded CA certificate to trust for the upstream proxy's + TLS connection. Only needed for HTTPS upstream proxies with + certificates not trusted by the system. If not provided, the system + certificate pool is used. + CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 56fbc207ff..b220ab3df5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -73,6 +73,8 @@ export interface AIBridgeProxyConfig { readonly cert_file: string; readonly key_file: string; readonly domain_allowlist: string; + readonly upstream_proxy: string; + readonly upstream_proxy_ca: string; } // From codersdk/aibridge.go