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