feat: add API to serve proxy certificate (#21391)

Closes https://github.com/coder/internal/issues/1184
This commit is contained in:
Danny Kopping
2025-12-29 18:00:06 +00:00
committed by GitHub
parent a173c38715
commit 733b6b7db9
7 changed files with 337 additions and 7 deletions
+50
View File
@@ -0,0 +1,50 @@
package coderd
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
// RegisterInMemoryAIBridgeProxydHTTPHandler mounts [aibridgeproxyd.Server]'s HTTP handler
// onto [API]'s router, so that requests to aibridgedproxy will be relayed from Coder's API server
// to the in-memory aibridgedproxy.
func (api *API) RegisterInMemoryAIBridgeProxydHTTPHandler(srv http.Handler) {
if srv == nil {
panic("aibridgeproxyd cannot be nil")
}
api.aibridgeproxydHandler = srv
}
// aibridgeproxyHandler handles AI Bridge Proxy endpoints.
func aibridgeproxyHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
return func(r chi.Router) {
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
r.Use(middlewares...)
r.HandleFunc("/*", func(rw http.ResponseWriter, r *http.Request) {
// Check if the proxy is enabled.
if !api.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() {
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
Message: "AI Bridge Proxy is not enabled.",
})
return
}
// Check if the handler is registered.
if api.aibridgeproxydHandler == nil {
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
Message: "AI Bridge Proxy handler not mounted.",
})
return
}
// Strip the prefix and relay to the aibridgeproxyd handler.
http.StripPrefix("/api/v2/aibridge/proxy", api.aibridgeproxydHandler).ServeHTTP(rw, r)
})
}
}
+105
View File
@@ -0,0 +1,105 @@
package coderd_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
func TestAIBridgeProxyCertificateRetrieval(t *testing.T) {
t.Parallel()
t.Run("DisabledReturns404", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
// Proxy is disabled by default, so we don't need to set it explicitly.
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
// Make a request to the proxy CA cert endpoint.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/aibridge/proxy/ca-cert.pem", nil)
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("RequiresLicenseFeature", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
// No aibridge feature.
Features: license.Features{},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
// Make a request to the proxy CA cert endpoint.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/aibridge/proxy/ca-cert.pem", nil)
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("RequiresAuthentication", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
// Make a request to the proxy CA cert endpoint without authentication.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/aibridge/proxy/ca-cert.pem", nil)
require.NoError(t, err)
// No session token header set.
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
+6 -1
View File
@@ -230,6 +230,10 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Route("/aibridge", aibridgeHandler(api, apiKeyMiddleware))
})
api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Route("/aibridge/proxy", aibridgeproxyHandler(api, apiKeyMiddleware))
})
api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Get("/entitlements", api.serveEntitlements)
// /regions overrides the AGPL /regions endpoint
@@ -691,7 +695,8 @@ type API struct {
licenseMetricsCollector *license.MetricsCollector
tailnetService *tailnet.ClientService
aibridgedHandler http.Handler
aibridgedHandler http.Handler
aibridgeproxydHandler http.Handler
}
// writeEntitlementWarningsHeader writes the entitlement warnings to the response header