diff --git a/aibridge/intercept/apidump/apidump.go b/aibridge/intercept/apidump/apidump.go index 05d1c83e48..2387a1e43f 100644 --- a/aibridge/intercept/apidump/apidump.go +++ b/aibridge/intercept/apidump/apidump.go @@ -43,25 +43,25 @@ func NewBridgeMiddleware(baseDir string, provider string, model string, intercep return nil } - d := &dumper{ + d := &Dumper{ dumpPath: interceptDumpPath(baseDir, provider, model, interceptionID, clk), logger: logger, } return func(req *http.Request, next MiddlewareNext) (*http.Response, error) { - if err := d.dumpRequest(req); err != nil { + if err := d.DumpRequest(req); err != nil { logger.Named("apidump").Warn(req.Context(), "failed to dump request", slog.Error(err)) } resp, err := next(req) if err != nil { - if dumpErr := d.dumpError(err); dumpErr != nil { + if dumpErr := d.DumpError(err); dumpErr != nil { logger.Named("apidump").Warn(req.Context(), "failed to dump request error", slog.Error(dumpErr)) } return resp, err } - if err := d.dumpResponse(resp); err != nil { + if err := d.DumpResponse(resp); err != nil { logger.Named("apidump").Warn(req.Context(), "failed to dump response", slog.Error(err)) } @@ -69,12 +69,24 @@ func NewBridgeMiddleware(baseDir string, provider string, model string, intercep } } -type dumper struct { +// Dumper writes HTTP request/response dump files to disk. Each +// Dumper is associated with a single base path; the .req.txt, +// .resp.txt, and .req_error.txt suffixes are appended automatically. +type Dumper struct { dumpPath string logger slog.Logger } -func (d *dumper) dumpRequest(req *http.Request) error { +// NewDumper returns a Dumper that writes dump files rooted at +// dumpPath. The caller constructs a unique path per request (e.g. +// provider + request ID). logger is used for non-fatal I/O warnings. +func NewDumper(dumpPath string, logger slog.Logger) *Dumper { + return &Dumper{dumpPath: dumpPath, logger: logger} +} + +// DumpRequest writes the request to a .req.txt file. The request +// body is read and restored so downstream consumers are unaffected. +func (d *Dumper) DumpRequest(req *http.Request) error { dumpPath := d.dumpPath + SuffixRequest if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil { return xerrors.Errorf("create dump dir: %w", err) @@ -117,7 +129,8 @@ func (d *dumper) dumpRequest(req *http.Request) error { return os.WriteFile(dumpPath, buf.Bytes(), 0o644) //nolint:gosec // https://github.com/coder/aibridge/pull/256#discussion_r3072143983 } -func (d *dumper) dumpError(reqErr error) error { +// DumpError writes the error message to a .req_error.txt file. +func (d *Dumper) DumpError(reqErr error) error { dumpPath := d.dumpPath + SuffixError if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil { return xerrors.Errorf("create dump dir: %w", err) @@ -125,7 +138,9 @@ func (d *dumper) dumpError(reqErr error) error { return os.WriteFile(dumpPath, []byte(reqErr.Error()+"\n"), 0o644) //nolint:gosec // same rationale as other dump files } -func (d *dumper) dumpResponse(resp *http.Response) error { +// DumpResponse writes the response headers and wraps the body so +// it streams to a .resp.txt file as it is consumed. +func (d *Dumper) DumpResponse(resp *http.Response) error { dumpPath := d.dumpPath + SuffixResponse // Build raw HTTP response headers @@ -166,7 +181,7 @@ func (d *dumper) dumpResponse(resp *http.Response) error { // for deterministic output. // `sensitive` and `overrides` must both supply keys in canonicalized form. // See [textproto.MIMEHeader]. -func (*dumper) writeRedactedHeaders(w io.Writer, headers http.Header, sensitive map[string]struct{}, overrides map[string]string) error { +func (*Dumper) writeRedactedHeaders(w io.Writer, headers http.Header, sensitive map[string]struct{}, overrides map[string]string) error { // Collect all header keys including overrides. headerKeys := make([]string, 0, len(headers)+len(overrides)) seen := make(map[string]struct{}, len(headers)+len(overrides)) @@ -249,25 +264,25 @@ type dumpRoundTripper struct { } func (rt *dumpRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - dumper := dumper{ + d := Dumper{ dumpPath: passthroughDumpPath(rt.baseDir, rt.provider, req.URL.Path, rt.clk), logger: rt.logger, } - if err := dumper.dumpRequest(req); err != nil { - dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request", slog.Error(err)) + if err := d.DumpRequest(req); err != nil { + d.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request", slog.Error(err)) } resp, err := rt.inner.RoundTrip(req) if err != nil { - if dumpErr := dumper.dumpError(err); dumpErr != nil { - dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request error", slog.Error(dumpErr)) + if dumpErr := d.DumpError(err); dumpErr != nil { + d.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request error", slog.Error(dumpErr)) } return resp, err } - if err := dumper.dumpResponse(resp); err != nil { - dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough response", slog.Error(err)) + if err := d.DumpResponse(resp); err != nil { + d.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough response", slog.Error(err)) } return resp, nil diff --git a/aibridge/intercept/apidump/headers.go b/aibridge/intercept/apidump/headers.go index b6a69fa8a2..cf6646acf0 100644 --- a/aibridge/intercept/apidump/headers.go +++ b/aibridge/intercept/apidump/headers.go @@ -2,13 +2,15 @@ package apidump // sensitiveRequestHeaders are headers that should be redacted from request dumps. var sensitiveRequestHeaders = map[string]struct{}{ - "Authorization": {}, - "X-Api-Key": {}, - "Api-Key": {}, - "X-Auth-Token": {}, - "Cookie": {}, - "Proxy-Authorization": {}, - "X-Amz-Security-Token": {}, + "Api-Key": {}, + "Authorization": {}, + "Cookie": {}, + "Proxy-Authorization": {}, + "X-Amz-Security-Token": {}, + "X-Api-Key": {}, + "X-Auth-Token": {}, + "X-Coder-AI-Governance-Session-Token": {}, + "X-Coder-AI-Governance-Token": {}, } // sensitiveResponseHeaders are headers that should be redacted from response dumps. diff --git a/aibridge/intercept/apidump/headers_test.go b/aibridge/intercept/apidump/headers_test.go index 7c50b990cd..832b6fd95d 100644 --- a/aibridge/intercept/apidump/headers_test.go +++ b/aibridge/intercept/apidump/headers_test.go @@ -46,7 +46,7 @@ func TestSensitiveHeaderLists(t *testing.T) { func TestWriteRedactedHeaders(t *testing.T) { t.Parallel() - d := &dumper{ + d := &Dumper{ dumpPath: interceptDumpPath("/tmp", "test", "test", uuid.New(), quartz.NewMock(t)), logger: slog.Make(), } diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 2862a45c3b..e2bc1d1762 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -174,6 +174,12 @@ AI BRIDGE OPTIONS: exporting these records to external SIEM or observability systems. AI BRIDGE PROXY OPTIONS: + --aibridge-proxy-dump-dir string, $CODER_AIBRIDGE_PROXY_DUMP_DIR + Directory for dumping MITM request/response pairs to disk for + debugging. When set, each proxied request produces .req.txt and + .resp.txt files organized by provider. Sensitive headers are redacted. + Leave empty to disable. + --aibridge-proxy-allowed-private-cidrs string-array, $CODER_AIBRIDGE_PROXY_ALLOWED_PRIVATE_CIDRS Comma-separated list of CIDR ranges that are permitted even though they fall within blocked private/reserved IP ranges. By default all diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 22ad14e506..ce49e08e68 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -882,6 +882,11 @@ aibridgeproxy: # networks. # (default: , type: string-array) allowed_private_cidrs: [] + # Directory for dumping MITM request/response pairs to disk for debugging. When + # set, each proxied request produces .req.txt and .resp.txt files organized by + # provider. Sensitive headers are redacted. Leave empty to disable. + # (default: , type: string) + api_dump_dir: "" # 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 a9e8f47cb4..dcc169292e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14000,6 +14000,9 @@ const docTemplate = `{ "type": "string" } }, + "api_dump_dir": { + "type": "string" + }, "cert_file": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d57994180c..2bbe7f6de9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12476,6 +12476,9 @@ "type": "string" } }, + "api_dump_dir": { + "type": "string" + }, "cert_file": { "type": "string" }, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 55b8678395..9ece07b531 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3990,6 +3990,16 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupAIBridgeProxy, YAML: "allowed_private_cidrs", }, + { + Name: "AI Bridge Proxy API Dump Directory", + Description: "Directory for dumping MITM request/response pairs to disk for debugging. When set, each proxied request produces .req.txt and .resp.txt files organized by provider. Sensitive headers are redacted. Leave empty to disable.", + Flag: "aibridge-proxy-dump-dir", + Env: "CODER_AIBRIDGE_PROXY_DUMP_DIR", + Value: &c.AI.BridgeProxyConfig.APIDumpDir, + Default: "", + Group: &deploymentGroupAIBridgeProxy, + YAML: "api_dump_dir", + }, // Retention settings { @@ -4144,6 +4154,7 @@ type AIBridgeProxyConfig struct { UpstreamProxy serpent.String `json:"upstream_proxy" typescript:",notnull"` UpstreamProxyCA serpent.String `json:"upstream_proxy_ca" typescript:",notnull"` AllowedPrivateCIDRs serpent.StringArray `json:"allowed_private_cidrs" typescript:",notnull"` + APIDumpDir serpent.String `json:"api_dump_dir" typescript:",notnull"` } type ChatConfig struct { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 04e919c959..48157d228e 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -166,6 +166,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "allowed_private_cidrs": [ "string" ], + "api_dump_dir": "string", "cert_file": "string", "domain_allowlist": [ "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d232008c36..0d5b3b2bb0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -786,6 +786,7 @@ "allowed_private_cidrs": [ "string" ], + "api_dump_dir": "string", "cert_file": "string", "domain_allowlist": [ "string" @@ -805,6 +806,7 @@ | Name | Type | Required | Restrictions | Description | |-------------------------|-----------------|----------|--------------|-------------| | `allowed_private_cidrs` | array of string | false | | | +| `api_dump_dir` | string | false | | | | `cert_file` | string | false | | | | `domain_allowlist` | array of string | false | | | | `enabled` | boolean | false | | | @@ -1246,6 +1248,7 @@ "allowed_private_cidrs": [ "string" ], + "api_dump_dir": "string", "cert_file": "string", "domain_allowlist": [ "string" @@ -5229,6 +5232,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "allowed_private_cidrs": [ "string" ], + "api_dump_dir": "string", "cert_file": "string", "domain_allowlist": [ "string" @@ -5820,6 +5824,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "allowed_private_cidrs": [ "string" ], + "api_dump_dir": "string", "cert_file": "string", "domain_allowlist": [ "string" diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 2aa7ce2242..6350c5a836 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1993,6 +1993,16 @@ Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS conne Comma-separated list of CIDR ranges that are permitted even though they fall within blocked private/reserved IP ranges. By default all private ranges are blocked to prevent SSRF attacks. Use this to allow access to specific internal networks. +### --aibridge-proxy-dump-dir + +| | | +|-------------|---------------------------------------------| +| Type | string | +| Environment | $CODER_AIBRIDGE_PROXY_DUMP_DIR | +| YAML | aibridgeproxy.api_dump_dir | + +Directory for dumping MITM request/response pairs to disk for debugging. When set, each proxied request produces .req.txt and .resp.txt files organized by provider. Sensitive headers are redacted. Leave empty to disable. + ### --audit-logs-retention | | | diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 85e9d4ad48..d796d36dbb 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -37,6 +37,13 @@ const ( HostCopilot = "api.individual.githubcopilot.com" ) +// RoundTripDumper captures an HTTP request/response pair to disk. +type RoundTripDumper interface { + DumpRequest(*http.Request) error + DumpResponse(*http.Response) error + DumpError(error) error +} + const ( // ProxyAuthRealm is the realm used in Proxy-Authenticate challenges. // The realm helps clients identify which credentials to use. @@ -125,6 +132,9 @@ type Server struct { caCert []byte // allowedPrivateRanges are CIDR ranges exempt from the blocked IP denylist. allowedPrivateRanges []net.IPNet + // newDumper creates a RoundTripDumper for a given provider and request + // ID. Nil when dumping is disabled. + newDumper func(provider, requestID string) RoundTripDumper // Metrics is the Prometheus metrics for the proxy. If nil, metrics are disabled. metrics *Metrics } @@ -147,6 +157,9 @@ type requestContext struct { // Set in handleRequest for MITM'd requests. // Sent to aibridged via custom header for cross-service correlation. RequestID uuid.UUID + // Dumper captures request/response pairs to disk when API dump is + // enabled. Nil when dumping is disabled. + Dumper RoundTripDumper } // Options configures the AI Bridge Proxy server. @@ -193,6 +206,11 @@ type Options struct { // access to specific internal networks while keeping all other private // ranges blocked. If empty, all private ranges are blocked. AllowedPrivateCIDRs []string + // NewDumper, when non-nil, is called for each MITM request to create + // a RoundTripDumper that writes .req.txt and .resp.txt files. The + // caller is responsible for constructing the dumper with the correct + // base path. + NewDumper func(provider, requestID string) RoundTripDumper // Metrics is the prometheus metrics instance for recording proxy metrics. // If nil, metrics will not be recorded. Metrics *Metrics @@ -307,6 +325,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) aibridgeProviderFromHost: aibridgeProviderFromHost, caCert: certPEM, allowedPrivateRanges: allowedPrivateRanges, + newDumper: opts.NewDumper, metrics: opts.Metrics, } @@ -452,6 +471,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) slog.F("domain_allowlist", mitmHosts), slog.F("upstream_proxy", opts.UpstreamProxy), slog.F("allowed_private_cidrs", opts.AllowedPrivateCIDRs), + slog.F("api_dump_enabled", opts.NewDumper != nil), ) go func() { @@ -967,6 +987,15 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. slog.F("aibridged_url", aiBridgeParsedURL.String()), ) + // Dump the outgoing request when API dumping is enabled. + if s.newDumper != nil { + d := s.newDumper(reqCtx.Provider, reqCtx.RequestID.String()) + reqCtx.Dumper = d + if err := d.DumpRequest(req); err != nil { + logger.Warn(s.ctx, "failed to dump request", slog.Error(err)) + } + } + // Record MITM request handling. if s.metrics != nil { s.metrics.MITMRequestsTotal.WithLabelValues(reqCtx.Provider).Inc() @@ -1039,6 +1068,13 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt s.metrics.MITMResponsesTotal.WithLabelValues(strconv.Itoa(resp.StatusCode), provider).Inc() } + // Dump the response to disk when a dumper was created for this request. + if reqCtx != nil && reqCtx.Dumper != nil { + if err := reqCtx.Dumper.DumpResponse(resp); err != nil { + logger.Warn(s.ctx, "failed to dump response", slog.Error(err)) + } + } + return resp } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 516912df62..bd4ac07089 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -26,6 +26,7 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -155,6 +156,7 @@ type testProxyConfig struct { upstreamProxy string upstreamProxyCA string allowedPrivateCIDRs []string + newDumper func(string, string) aibridgeproxyd.RoundTripDumper metrics *aibridgeproxyd.Metrics } @@ -229,6 +231,12 @@ func withAllowedPrivateCIDRs(cidrs ...string) testProxyOption { } } +func withNewDumper(fn func(string, string) aibridgeproxyd.RoundTripDumper) testProxyOption { + return func(cfg *testProxyConfig) { + cfg.newDumper = fn + } +} + func withMetrics(metrics *aibridgeproxyd.Metrics) testProxyOption { return func(cfg *testProxyConfig) { cfg.metrics = metrics @@ -279,6 +287,7 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server UpstreamProxy: cfg.upstreamProxy, UpstreamProxyCA: cfg.upstreamProxyCA, AllowedPrivateCIDRs: cfg.allowedPrivateCIDRs, + NewDumper: cfg.newDumper, Metrics: cfg.metrics, } if cfg.certStore != nil { @@ -2353,3 +2362,133 @@ func TestProxy_PrivateIPBlocking(t *testing.T) { }) } } + +// TestProxy_APIDump verifies that when NewDumper is configured, the proxy +// calls DumpRequest and DumpResponse for MITM'd requests. +func TestProxy_APIDump(t *testing.T) { + t.Parallel() + + aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(aibridgedServer.Close) + + var ( + dumpedProvider string + dumpedRequestID string + reqDumped bool + respDumped bool + ) + + srv := newTestProxy(t, + withCoderAccessURL(aibridgedServer.URL), + withAllowedPorts("443"), + withDomainAllowlist(aibridgeproxyd.HostAnthropic), + withAIBridgeProviderFromHost(testProviderFromHost), + withNewDumper(func(provider, requestID string) aibridgeproxyd.RoundTripDumper { + dumpedProvider = provider + dumpedRequestID = requestID + return &mockDumper{ + onRequest: func() { reqDumped = true }, + onResponse: func() { respDumped = true }, + } + }), + ) + + certPool := getProxyCertPool(t) + client := newProxyClient(t, srv, makeProxyAuthHeader("coder-token"), certPool, false) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.anthropic.com/v1/messages", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer user-llm-token") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Equal(t, "anthropic", dumpedProvider) + assert.NotEmpty(t, dumpedRequestID) + _, err = uuid.Parse(dumpedRequestID) + require.NoError(t, err, "request ID passed to NewDumper must be a valid UUID") + assert.True(t, reqDumped, "DumpRequest should have been called") + assert.True(t, respDumped, "DumpResponse should have been called") +} + +// TestProxy_APIDump_ErrorsDoNotAffectProxy verifies that dump failures +// do not break the proxied request/response flow. +func TestProxy_APIDump_ErrorsDoNotAffectProxy(t *testing.T) { + t.Parallel() + + aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(aibridgedServer.Close) + + srv := newTestProxy(t, + withCoderAccessURL(aibridgedServer.URL), + withAllowedPorts("443"), + withDomainAllowlist(aibridgeproxyd.HostAnthropic), + withAIBridgeProviderFromHost(testProviderFromHost), + withNewDumper(func(_, _ string) aibridgeproxyd.RoundTripDumper { + return &failingDumper{} + }), + ) + + certPool := getProxyCertPool(t) + client := newProxyClient(t, srv, makeProxyAuthHeader("coder-token"), certPool, false) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.anthropic.com/v1/messages", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer user-token") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // The proxy must return the upstream response despite dump errors. + require.Equal(t, http.StatusOK, resp.StatusCode) + require.JSONEq(t, `{"ok":true}`, string(body)) +} + +type mockDumper struct { + onRequest func() + onResponse func() + onError func() +} + +func (m *mockDumper) DumpRequest(_ *http.Request) error { + if m.onRequest != nil { + m.onRequest() + } + return nil +} + +func (m *mockDumper) DumpResponse(_ *http.Response) error { + if m.onResponse != nil { + m.onResponse() + } + return nil +} + +func (m *mockDumper) DumpError(_ error) error { + if m.onError != nil { + m.onError() + } + return nil +} + +// failingDumper always returns errors, used to verify dump failures +// do not affect proxy behavior. +type failingDumper struct{} + +func (*failingDumper) DumpRequest(*http.Request) error { return xerrors.New("dump request failed") } +func (*failingDumper) DumpResponse(*http.Response) error { return xerrors.New("dump response failed") } +func (*failingDumper) DumpError(error) error { return xerrors.New("dump error failed") } diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 7f26a68b09..abc5320e92 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -5,12 +5,14 @@ package cli import ( "context" "net/url" + "path/filepath" "strings" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/intercept/apidump" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/enterprise/coderd" ) @@ -26,6 +28,13 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API, providers []aibridge.Provider) reg := prometheus.WrapRegistererWithPrefix("coder_aibridgeproxyd_", coderAPI.PrometheusRegistry) metrics := aibridgeproxyd.NewMetrics(reg) + var newDumper func(provider, requestID string) aibridgeproxyd.RoundTripDumper + if dumpDir := coderAPI.DeploymentValues.AI.BridgeProxyConfig.APIDumpDir.String(); dumpDir != "" { + newDumper = func(provider, requestID string) aibridgeproxyd.RoundTripDumper { + return apidump.NewDumper(filepath.Join(dumpDir, provider, requestID), logger) + } + } + srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{ ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(), TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(), @@ -38,6 +47,7 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API, providers []aibridge.Provider) UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(), UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(), AllowedPrivateCIDRs: coderAPI.DeploymentValues.AI.BridgeProxyConfig.AllowedPrivateCIDRs.Value(), + NewDumper: newDumper, Metrics: metrics, }) if err != nil { diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index a0cc791d54..3702806593 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -175,6 +175,12 @@ AI BRIDGE OPTIONS: exporting these records to external SIEM or observability systems. AI BRIDGE PROXY OPTIONS: + --aibridge-proxy-dump-dir string, $CODER_AIBRIDGE_PROXY_DUMP_DIR + Directory for dumping MITM request/response pairs to disk for + debugging. When set, each proxied request produces .req.txt and + .resp.txt files organized by provider. Sensitive headers are redacted. + Leave empty to disable. + --aibridge-proxy-allowed-private-cidrs string-array, $CODER_AIBRIDGE_PROXY_ALLOWED_PRIVATE_CIDRS Comma-separated list of CIDR ranges that are permitted even though they fall within blocked private/reserved IP ranges. By default all diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7cf20be705..bacba8a19d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -165,6 +165,7 @@ export interface AIBridgeProxyConfig { readonly upstream_proxy: string; readonly upstream_proxy_ca: string; readonly allowed_private_cidrs: string; + readonly api_dump_dir: string; } // From codersdk/aibridge.go