feat: add core AI MITM proxy daemon

This commit is contained in:
Susana Cardoso Ferreira
2025-12-29 10:37:21 +00:00
parent d9e155113b
commit 11ea5b9224
16 changed files with 538 additions and 0 deletions
+14
View File
@@ -139,6 +139,20 @@ AI BRIDGE OPTIONS:
Maximum number of AI Bridge requests per second per replica. Set to 0
to disable (unlimited).
AI PROXY OPTIONS:
--aiproxy-cert-file string, $CODER_AIPROXY_CERT_FILE
Path to the CA certificate file for MITM.
--aiproxy-enabled bool, $CODER_AIPROXY_ENABLED (default: false)
Enable the AI MITM proxy for intercepting and decrypting AI provider
requests.
--aiproxy-key-file string, $CODER_AIPROXY_KEY_FILE
Path to the CA private key file for MITM.
--aiproxy-listen-addr string, $CODER_AIPROXY_LISTEN_ADDR (default: :8888)
The address the AI proxy will listen on.
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.
+13
View File
@@ -765,6 +765,19 @@ aibridge:
# (unlimited).
# (default: 0, type: int)
rateLimit: 0
aiproxy:
# Enable the AI MITM proxy for intercepting and decrypting AI provider requests.
# (default: false, type: bool)
enabled: false
# The address the AI proxy will listen on.
# (default: :8888, type: string)
listen_addr: :8888
# Path to the CA certificate file for MITM.
# (default: <unset>, type: string)
cert_file: ""
# Path to the CA private key file for MITM.
# (default: <unset>, type: string)
key_file: ""
# 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
+20
View File
@@ -12110,6 +12110,26 @@ const docTemplate = `{
"properties": {
"bridge": {
"$ref": "#/definitions/codersdk.AIBridgeConfig"
},
"proxy": {
"$ref": "#/definitions/codersdk.AIProxyConfig"
}
}
},
"codersdk.AIProxyConfig": {
"type": "object",
"properties": {
"cert_file": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"key_file": {
"type": "string"
},
"listen_addr": {
"type": "string"
}
}
},
+20
View File
@@ -10774,6 +10774,26 @@
"properties": {
"bridge": {
"$ref": "#/definitions/codersdk.AIBridgeConfig"
},
"proxy": {
"$ref": "#/definitions/codersdk.AIProxyConfig"
}
}
},
"codersdk.AIProxyConfig": {
"type": "object",
"properties": {
"cert_file": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"key_file": {
"type": "string"
},
"listen_addr": {
"type": "string"
}
}
},
+55
View File
@@ -1217,6 +1217,10 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Name: "AI Bridge",
YAML: "aibridge",
}
deploymentGroupAIProxy = serpent.Group{
Name: "AI Proxy",
YAML: "aiproxy",
}
deploymentGroupRetention = serpent.Group{
Name: "Retention",
Description: "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 that data type.",
@@ -3443,6 +3447,49 @@ Write out the current server config as YAML to stdout.`,
Group: &deploymentGroupAIBridge,
YAML: "rateLimit",
},
// AI Proxy Options
{
Name: "AI Proxy Enabled",
Description: "Enable the AI MITM proxy for intercepting and decrypting AI provider requests.",
Flag: "aiproxy-enabled",
Env: "CODER_AIPROXY_ENABLED",
Value: &c.AI.ProxyConfig.Enabled,
Default: "false",
Group: &deploymentGroupAIProxy,
YAML: "enabled",
},
{
Name: "AI Proxy Listen Address",
Description: "The address the AI proxy will listen on.",
Flag: "aiproxy-listen-addr",
Env: "CODER_AIPROXY_LISTEN_ADDR",
Value: &c.AI.ProxyConfig.ListenAddr,
Default: ":8888",
Group: &deploymentGroupAIProxy,
YAML: "listen_addr",
},
{
Name: "AI Proxy Certificate File",
Description: "Path to the CA certificate file for MITM.",
Flag: "aiproxy-cert-file",
Env: "CODER_AIPROXY_CERT_FILE",
Value: &c.AI.ProxyConfig.CertFile,
Default: "",
Group: &deploymentGroupAIProxy,
YAML: "cert_file",
},
{
Name: "AI Proxy Key File",
Description: "Path to the CA private key file for MITM.",
Flag: "aiproxy-key-file",
Env: "CODER_AIPROXY_KEY_FILE",
Value: &c.AI.ProxyConfig.KeyFile,
Default: "",
Group: &deploymentGroupAIProxy,
YAML: "key_file",
},
// Retention settings
{
Name: "Audit Logs Retention",
@@ -3535,8 +3582,16 @@ type AIBridgeBedrockConfig struct {
SmallFastModel serpent.String `json:"small_fast_model" typescript:",notnull"`
}
type AIProxyConfig struct {
Enabled serpent.Bool `json:"enabled" typescript:",notnull"`
ListenAddr serpent.String `json:"listen_addr" typescript:",notnull"`
CertFile serpent.String `json:"cert_file" typescript:",notnull"`
KeyFile serpent.String `json:"key_file" typescript:",notnull"`
}
type AIConfig struct {
BridgeConfig AIBridgeConfig `json:"bridge,omitempty"`
ProxyConfig AIProxyConfig `json:"proxy,omitempty"`
}
type SupportConfig struct {
+6
View File
@@ -183,6 +183,12 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
},
"rate_limit": 0,
"retention": 0
},
"proxy": {
"cert_file": "string",
"enabled": true,
"key_file": "string",
"listen_addr": "string"
}
},
"allow_workspace_renames": true,
+39
View File
@@ -711,6 +711,12 @@
},
"rate_limit": 0,
"retention": 0
},
"proxy": {
"cert_file": "string",
"enabled": true,
"key_file": "string",
"listen_addr": "string"
}
}
```
@@ -720,6 +726,27 @@
| Name | Type | Required | Restrictions | Description |
|----------|----------------------------------------------------|----------|--------------|-------------|
| `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | |
| `proxy` | [codersdk.AIProxyConfig](#codersdkaiproxyconfig) | false | | |
## codersdk.AIProxyConfig
```json
{
"cert_file": "string",
"enabled": true,
"key_file": "string",
"listen_addr": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------|---------|----------|--------------|-------------|
| `cert_file` | string | false | | |
| `enabled` | boolean | false | | |
| `key_file` | string | false | | |
| `listen_addr` | string | false | | |
## codersdk.APIAllowListTarget
@@ -2616,6 +2643,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
},
"rate_limit": 0,
"retention": 0
},
"proxy": {
"cert_file": "string",
"enabled": true,
"key_file": "string",
"listen_addr": "string"
}
},
"allow_workspace_renames": true,
@@ -3147,6 +3180,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
},
"rate_limit": 0,
"retention": 0
},
"proxy": {
"cert_file": "string",
"enabled": true,
"key_file": "string",
"listen_addr": "string"
}
},
"allow_workspace_renames": true,
+42
View File
@@ -1814,6 +1814,48 @@ Maximum number of concurrent AI Bridge requests per replica. Set to 0 to disable
Maximum number of AI Bridge requests per second per replica. Set to 0 to disable (unlimited).
### --aiproxy-enabled
| | |
|-------------|-------------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_AIPROXY_ENABLED</code> |
| YAML | <code>aiproxy.enabled</code> |
| Default | <code>false</code> |
Enable the AI MITM proxy for intercepting and decrypting AI provider requests.
### --aiproxy-listen-addr
| | |
|-------------|-----------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_AIPROXY_LISTEN_ADDR</code> |
| YAML | <code>aiproxy.listen_addr</code> |
| Default | <code>:8888</code> |
The address the AI proxy will listen on.
### --aiproxy-cert-file
| | |
|-------------|---------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_AIPROXY_CERT_FILE</code> |
| YAML | <code>aiproxy.cert_file</code> |
Path to the CA certificate file for MITM.
### --aiproxy-key-file
| | |
|-------------|--------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_AIPROXY_KEY_FILE</code> |
| YAML | <code>aiproxy.key_file</code> |
Path to the CA private key file for MITM.
### --audit-logs-retention
| | |
+113
View File
@@ -0,0 +1,113 @@
package aiproxyd
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net/http"
"time"
"github.com/elazarl/goproxy"
"golang.org/x/xerrors"
"cdr.dev/slog"
)
// Server is the AI MITM (Man-in-the-Middle) proxy server.
// It is responsible for:
// - intercepting HTTPS requests to AI providers
// - decrypting requests using the configured CA certificate
// - forwarding requests to aibridge for processing
type Server struct {
logger slog.Logger
proxy *goproxy.ProxyHttpServer
httpServer *http.Server
}
// Options configures the AI proxy server.
type Options struct {
// ListenAddr is the address the proxy server will listen on.
ListenAddr string
// CertFile is the path to the CA certificate file used for MITM.
CertFile string
// KeyFile is the path to the CA private key file used for MITM.
KeyFile string
}
func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) {
logger.Info(ctx, "initializing AI proxy server")
if opts.ListenAddr == "" {
return nil, xerrors.New("listen address is required")
}
if opts.CertFile == "" || opts.KeyFile == "" {
return nil, xerrors.New("cert file and key file are required")
}
// Load CA certificate for MITM
if err := loadMitmCertificate(opts.CertFile, opts.KeyFile); err != nil {
return nil, xerrors.Errorf("failed to load MITM certificate: %w", err)
}
proxy := goproxy.NewProxyHttpServer()
// Decrypt all HTTPS requests via MITM. Requests are forwarded to
// the original destination without modification for now.
// TODO(ssncferreira): Route requests to aibridged
// See https://github.com/coder/internal/issues/1181
proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm)
srv := &Server{
logger: logger,
proxy: proxy,
}
// Start HTTP server in background
srv.httpServer = &http.Server{
Addr: opts.ListenAddr,
Handler: proxy,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info(ctx, "starting AI proxy", slog.F("addr", opts.ListenAddr))
if err := srv.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error(ctx, "aiproxyd server error", slog.Error(err))
}
}()
return srv, nil
}
// loadMitmCertificate loads the CA certificate and key for MITM into goproxy.
func loadMitmCertificate(certFile, keyFile string) error {
tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return xerrors.Errorf("load x509 keypair: %w", err)
}
x509Cert, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return xerrors.Errorf("parse certificate: %w", err)
}
goproxy.GoproxyCa = tls.Certificate{
Certificate: tlsCert.Certificate,
PrivateKey: tlsCert.PrivateKey,
Leaf: x509Cert,
}
return nil
}
// Close gracefully shuts down the proxy server.
func (s *Server) Close() error {
if s.httpServer == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.httpServer.Shutdown(ctx)
}
+152
View File
@@ -0,0 +1,152 @@
package aiproxyd_test
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/enterprise/aiproxyd"
)
// generateTestCA creates a temporary CA certificate and key for testing.
func generateTestCA(t *testing.T) (certFile, keyFile string) {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "Test CA",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
tempDir := t.TempDir()
certFile = filepath.Join(tempDir, "ca.crt")
keyFile = filepath.Join(tempDir, "ca.key")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
err = os.WriteFile(certFile, certPEM, 0o600)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
err = os.WriteFile(keyFile, keyPEM, 0o600)
require.NoError(t, err)
return certFile, keyFile
}
func TestNew(t *testing.T) {
t.Parallel()
t.Run("MissingListenAddr", func(t *testing.T) {
t.Parallel()
certFile, keyFile := generateTestCA(t)
logger := slogtest.Make(t, nil)
_, err := aiproxyd.New(t.Context(), logger, aiproxyd.Options{
ListenAddr: "",
CertFile: certFile,
KeyFile: keyFile,
})
require.Error(t, err)
require.Contains(t, err.Error(), "listen address is required")
})
t.Run("MissingCertFile", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
_, err := aiproxyd.New(t.Context(), logger, aiproxyd.Options{
ListenAddr: ":0",
KeyFile: "key.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "cert file and key file are required")
})
t.Run("MissingKeyFile", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
_, err := aiproxyd.New(t.Context(), logger, aiproxyd.Options{
ListenAddr: ":0",
CertFile: "cert.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "cert file and key file are required")
})
t.Run("InvalidCertFile", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
_, err := aiproxyd.New(t.Context(), logger, aiproxyd.Options{
ListenAddr: ":0",
CertFile: "/nonexistent/cert.pem",
KeyFile: "/nonexistent/key.pem",
})
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load MITM certificate")
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
certFile, keyFile := generateTestCA(t)
logger := slogtest.Make(t, nil)
srv, err := aiproxyd.New(t.Context(), logger, aiproxyd.Options{
ListenAddr: "127.0.0.1:0",
CertFile: certFile,
KeyFile: keyFile,
})
require.NoError(t, err)
require.NotNil(t, srv)
err = srv.Close()
require.NoError(t, err)
})
}
func TestClose(t *testing.T) {
t.Parallel()
certFile, keyFile := generateTestCA(t)
logger := slogtest.Make(t, nil)
srv, err := aiproxyd.New(t.Context(), logger, aiproxyd.Options{
ListenAddr: "127.0.0.1:0",
CertFile: certFile,
KeyFile: keyFile,
})
require.NoError(t, err)
err = srv.Close()
require.NoError(t, err)
// Calling Close again should not error
err = srv.Close()
require.NoError(t, err)
}
+28
View File
@@ -0,0 +1,28 @@
//go:build !slim
package cli
import (
"context"
"github.com/coder/coder/v2/enterprise/aiproxyd"
"github.com/coder/coder/v2/enterprise/coderd"
)
func newAIProxyDaemon(coderAPI *coderd.API) (*aiproxyd.Server, error) {
ctx := context.Background()
coderAPI.Logger.Debug(ctx, "starting in-memory aiproxy daemon")
logger := coderAPI.Logger.Named("aiproxyd")
srv, err := aiproxyd.New(ctx, logger, aiproxyd.Options{
ListenAddr: coderAPI.DeploymentValues.AI.ProxyConfig.ListenAddr.String(),
CertFile: coderAPI.DeploymentValues.AI.ProxyConfig.CertFile.String(),
KeyFile: coderAPI.DeploymentValues.AI.ProxyConfig.KeyFile.String(),
})
if err != nil {
return nil, err
}
return srv, nil
}
+10
View File
@@ -165,6 +165,16 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
closers.Add(aibridgeDaemon)
}
// In-memory AI proxy daemon
if options.DeploymentValues.AI.ProxyConfig.Enabled.Value() {
aiProxyServer, err := newAIProxyDaemon(api)
if err != nil {
_ = closers.Close()
return nil, nil, xerrors.Errorf("create aiproxyd: %w", err)
}
closers.Add(aiProxyServer)
}
return api.AGPL, closers, nil
})
+14
View File
@@ -140,6 +140,20 @@ AI BRIDGE OPTIONS:
Maximum number of AI Bridge requests per second per replica. Set to 0
to disable (unlimited).
AI PROXY OPTIONS:
--aiproxy-cert-file string, $CODER_AIPROXY_CERT_FILE
Path to the CA certificate file for MITM.
--aiproxy-enabled bool, $CODER_AIPROXY_ENABLED (default: false)
Enable the AI MITM proxy for intercepting and decrypting AI provider
requests.
--aiproxy-key-file string, $CODER_AIPROXY_KEY_FILE
Path to the CA private key file for MITM.
--aiproxy-listen-addr string, $CODER_AIPROXY_LISTEN_ADDR (default: :8888)
The address the AI proxy will listen on.
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.
+1
View File
@@ -482,6 +482,7 @@ require (
github.com/coder/preview v1.0.4
github.com/danieljoos/wincred v1.2.3
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/elazarl/goproxy v1.7.2
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-git/v5 v5.16.2
github.com/icholy/replace v0.6.0
+2
View File
@@ -1049,6 +1049,8 @@ github.com/elastic/go-sysinfo v1.15.1 h1:zBmTnFEXxIQ3iwcQuk7MzaUotmKRp3OabbbWM8T
github.com/elastic/go-sysinfo v1.15.1/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk=
github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY=
github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4AA=
+9
View File
@@ -106,6 +106,15 @@ export interface AIBridgeUserPrompt {
// From codersdk/deployment.go
export interface AIConfig {
readonly bridge?: AIBridgeConfig;
readonly proxy?: AIProxyConfig;
}
// From codersdk/deployment.go
export interface AIProxyConfig {
readonly enabled: boolean;
readonly listen_addr: string;
readonly cert_file: string;
readonly key_file: string;
}
// From codersdk/allowlist.go