mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add request/response dump support to aibridgeproxyd (#24837)
Closes https://github.com/coder/coder/issues/24335
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+5
@@ -882,6 +882,11 @@ aibridgeproxy:
|
||||
# networks.
|
||||
# (default: <unset>, 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: <unset>, 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
|
||||
|
||||
Generated
+3
@@ -14000,6 +14000,9 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"api_dump_dir": {
|
||||
"type": "string"
|
||||
},
|
||||
"cert_file": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Generated
+3
@@ -12476,6 +12476,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"api_dump_dir": {
|
||||
"type": "string"
|
||||
},
|
||||
"cert_file": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1
@@ -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"
|
||||
|
||||
Generated
+5
@@ -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"
|
||||
|
||||
Generated
+10
@@ -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 | <code>string</code> |
|
||||
| Environment | <code>$CODER_AIBRIDGE_PROXY_DUMP_DIR</code> |
|
||||
| YAML | <code>aibridgeproxy.api_dump_dir</code> |
|
||||
|
||||
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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user