88"errors"
99"net"
1010"net/http"
11+ "net/url"
1112"strings"
1213"time"
1314
@@ -23,17 +24,21 @@ import (
2324// - decrypting requests using the configured CA certificate
2425// - forwarding requests to aibridged for processing
2526type Server struct {
26- ctx context.Context
27- logger slog.Logger
28- proxy * goproxy.ProxyHttpServer
29- httpServer * http.Server
30- listener net.Listener
27+ ctx context.Context
28+ logger slog.Logger
29+ proxy * goproxy.ProxyHttpServer
30+ httpServer * http.Server
31+ listener net.Listener
32+ coderAccessURL * url.URL
3133}
3234
3335// Options configures the AI Bridge Proxy server.
3436type Options struct {
3537// ListenAddr is the address the proxy server will listen on.
3638ListenAddr string
39+ // CoderAccessURL is the URL of the Coder deployment where aibridged is running.
40+ // Requests to supported AI providers are forwarded here.
41+ CoderAccessURL string
3742// CertFile is the path to the CA certificate file used for MITM.
3843CertFile string
3944// KeyFile is the path to the CA private key file used for MITM.
@@ -51,6 +56,14 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error)
5156return nil ,xerrors .New ("cert file and key file are required" )
5257}
5358
59+ if opts .CoderAccessURL == "" {
60+ return nil ,xerrors .New ("coder access URL is required" )
61+ }
62+ coderAccessURL ,err := url .Parse (opts .CoderAccessURL )
63+ if err != nil {
64+ return nil ,xerrors .Errorf ("invalid coder access URL %q: %w" ,opts .CoderAccessURL ,err )
65+ }
66+
5467// Load CA certificate for MITM
5568if err := loadMitmCertificate (opts .CertFile ,opts .KeyFile );err != nil {
5669return nil ,xerrors .Errorf ("failed to load MITM certificate: %w" ,err )
@@ -59,18 +72,21 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error)
5972proxy := goproxy .NewProxyHttpServer ()
6073
6174srv := & Server {
62- ctx :ctx ,
63- logger :logger ,
64- proxy :proxy ,
75+ ctx :ctx ,
76+ logger :logger ,
77+ proxy :proxy ,
78+ coderAccessURL :coderAccessURL ,
6579}
6680
6781// Extract Coder session token from proxy authentication to forward to aibridged.
68- // Decrypt all HTTPS requests via MITM. Requests are forwarded to
69- // the original destination without modification for now.
70- // TODO(ssncferreira): Route requests to aibridged will be implemented upstack.
71- // Related to https://github.com/coder/internal/issues/1181
7282proxy .OnRequest ().HandleConnect (goproxy .FuncHttpsHandler (srv .handleConnect ))
7383
84+ // Handle decrypted requests: route to aibridged for known AI providers, or passthrough to original destination.
85+ // TODO(ssncferreira): Currently the proxy always behaves as MITM, but this should only happen for known
86+ // AI providers as all other requests should be tunneled. This will be implemented upstack.
87+ // Related to https://github.com/coder/internal/issues/1182
88+ proxy .OnRequest ().DoFunc (srv .handleRequest )
89+
7490// Create listener first so we can get the actual address.
7591// This is useful in tests where port 0 is used to avoid conflicts.
7692listener ,err := net .Listen ("tcp" ,opts .ListenAddr )
@@ -218,3 +234,84 @@ func extractCoderTokenFromProxyAuth(proxyAuth string) string {
218234
219235return credentials [1 ]
220236}
237+
238+ // canonicalHost strips the port from a host:port string and lowercases it.
239+ func canonicalHost (host string )string {
240+ if i := strings .IndexByte (host ,':' );i != - 1 {
241+ host = host [:i ]
242+ }
243+ return strings .ToLower (host )
244+ }
245+
246+ // providerFromHost maps the request host to the aibridge provider name.
247+ // - Known AI providers return their provider name, used to route to the
248+ // corresponding aibridge endpoint.
249+ // - Unknown hosts return empty string and are passed through directly.
250+ //
251+ // TODO(ssncferreira): Provider list configurable via domain allowlists will be implemented upstack.
252+ //
253+ //Related to https://github.com/coder/internal/issues/1182.
254+ func providerFromHost (host string )string {
255+ switch canonicalHost (host ) {
256+ case "api.anthropic.com" :
257+ return "anthropic"
258+ case "api.openai.com" :
259+ return "openai"
260+ default :
261+ return ""
262+ }
263+ }
264+
265+ // handleRequest intercepts HTTP requests after MITM decryption.
266+ // - Requests to known AI providers are rewritten to aibridged, with the Coder session token
267+ // (from ctx.UserData, set during CONNECT) injected in the Authorization header.
268+ // - Unknown hosts are passed through to the original upstream.
269+ func (s * Server )handleRequest (req * http.Request ,ctx * goproxy.ProxyCtx ) (* http.Request ,* http.Response ) {
270+ // Check if this request is for a supported AI provider.
271+ provider := providerFromHost (req .Host )
272+ if provider == "" {
273+ s .logger .Debug (s .ctx ,"passthrough request to unknown host" ,
274+ slog .F ("host" ,req .Host ),
275+ slog .F ("method" ,req .Method ),
276+ slog .F ("path" ,req .URL .Path ),
277+ )
278+ return req ,nil
279+ }
280+
281+ // Get the Coder session token stored during CONNECT.
282+ coderToken ,_ := ctx .UserData .(string )
283+
284+ // Reject unauthenticated requests to AI providers.
285+ if coderToken == "" {
286+ s .logger .Warn (s .ctx ,"rejecting unauthenticated request to AI provider" ,
287+ slog .F ("host" ,req .Host ),
288+ slog .F ("provider" ,provider ),
289+ )
290+ resp := goproxy .NewResponse (req ,goproxy .ContentTypeText ,http .StatusProxyAuthRequired ,"Proxy authentication required" )
291+ // Describe to the client how to authenticate with the proxy.
292+ resp .Header .Set ("Proxy-Authenticate" ,`Basic realm="Coder AI Bridge Proxy"` )
293+ return req ,resp
294+ }
295+
296+ // Rewrite the request to point to aibridged.
297+ originalPath := req .URL .Path
298+ aibridgePath := "/" + provider + originalPath
299+
300+ newURL := * s .coderAccessURL
301+ newURL .Path = "/api/v2/aibridge" + aibridgePath
302+ newURL .RawQuery = req .URL .RawQuery
303+
304+ req .URL = & newURL
305+ req .Host = newURL .Host
306+
307+ // Set Authorization header for aibridged authentication.
308+ req .Header .Set ("Authorization" ,"Bearer " + coderToken )
309+
310+ s .logger .Debug (s .ctx ,"routing request to aibridged" ,
311+ slog .F ("provider" ,provider ),
312+ slog .F ("original_path" ,originalPath ),
313+ slog .F ("aibridged_url" ,newURL .String ()),
314+ )
315+
316+ return req ,nil
317+ }