mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add /api/v2/aibridge/serve endpoint (#26506)
Adds a new enterprise-only `GET /api/v2/ai-gateway/serve` endpoint that standalone AI Gateway replicas use to connect to `coderd` over a DRPC-over-WebSocket transport, mirroring the existing in-memory path used by the embedded AI Bridge daemon.
- The endpoint upgrades the HTTP connection to a WebSocket, multiplexes it with yamux, and finally serves the three DRPC services (Recorder, MCPConfigurator, Authorizer).
- The `X-AI-Governance-Gateway-Key` header is used for authentication.
- The key is looked up by its hashed secret
- Missing or revoked keys return `401`.
- API version negotiation is enforced via a new `aibridged/proto` version (`v1.0`).
- Incompatible versions return `400`.
- `FeatureAIBridge` entitlement is required.
- Key liveness (`last_used_at`) is recorded immediately on connection and refreshed every 60 seconds while the session remains open.
- When key liveness detects the key was deleted (no rows where updated) session is closed.
#### Small refactors
* The three DRPC service registrations are extracted into `aibridgedserver.Register`, shared by both the in-memory and WebSocket paths.
* The literal `256 * 1024` used as the yamux-aligned WebSocket read limit is replaced with the named constant `drpcsdk.YamuxDefaultStreamWindowSize` in all call sites.
* as noted in review comment https://github.com/coder/coder/pull/26506#discussion_r3461905223 order of `SetReadLimit` and `WebsocketNetConn` calls was fixed.
This commit is contained in:
+2
-12
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc/drpcmux"
|
||||
"storj.io/drpc/drpcserver"
|
||||
|
||||
@@ -71,17 +70,8 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = aibridgedproto.DRPCRegisterRecorder(mux, srv)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register recorder service: %w", err)
|
||||
}
|
||||
err = aibridgedproto.DRPCRegisterMCPConfigurator(mux, srv)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register MCP configurator service: %w", err)
|
||||
}
|
||||
err = aibridgedproto.DRPCRegisterAuthorizer(mux, srv)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register key validator service: %w", err)
|
||||
if err := aibridgedserver.Register(mux, srv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux},
|
||||
drpcserver.Options{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package proto
|
||||
|
||||
import "github.com/coder/coder/v2/apiversion"
|
||||
|
||||
// Version history:
|
||||
//
|
||||
// API v1.0:
|
||||
// - Initial version. Serves the Recorder, MCPConfigurator, and Authorizer
|
||||
// services to embedded and standalone AI Gateway daemons.
|
||||
const (
|
||||
CurrentMajor = 1
|
||||
CurrentMinor = 0
|
||||
)
|
||||
|
||||
// CurrentVersion is the current aibridged API version.
|
||||
// Breaking changes to the aibridged API **MUST** increment CurrentMajor above.
|
||||
// Non-breaking changes to the aibridged API **MUST** increment CurrentMinor
|
||||
// above.
|
||||
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
|
||||
@@ -594,13 +594,13 @@ externalAuthLoop:
|
||||
// IsAuthorized validates a given Coder API key and returns the user ID to which it belongs (if valid).
|
||||
//
|
||||
// SECURITY: when in.KeyId is set (the "delegated" path), this method trusts the
|
||||
// caller's claim of identity and skips the key-secret check. This is safe only
|
||||
// because the DRPCServer is reachable solely via the in-process
|
||||
// [aibridged.MemTransportPipe]; the handler itself cannot tell whether it was
|
||||
// invoked over the in-memory pipe or a network socket. If this RPC is ever
|
||||
// exposed over a network boundary, any caller who knows a valid 10-char key ID
|
||||
// (which is not secret) could authenticate as the key's owner without the
|
||||
// secret. Do not bind this DRPCServer to a network listener.
|
||||
// caller's claim of identity and skips the key-secret check. This DRPCServer is
|
||||
// reachable both in-process via [aibridged.MemTransportPipe] and over the network
|
||||
// via the /api/v2/ai-gateway/serve endpoint. That endpoint admits only holders of
|
||||
// AI Gateway key, which are fully trusted. Standalone AI Gateway authenticates its
|
||||
// own users and acts on their behalf, much like a provisioner daemon. A Gateway key
|
||||
// holder can therefore act as any user without that user's secret. Per-user
|
||||
// authorization on this surface is a known gap.
|
||||
//
|
||||
// NOTE: this should really be using the code from [httpmw.ExtractAPIKey]. That function not only validates the key
|
||||
// but handles many other cases like updating last used, expiry, etc. This code does not currently use it for
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package aibridgedserver
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc/drpcmux"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/aibridged/proto"
|
||||
)
|
||||
|
||||
// Register registers the Recorder, MCPConfigurator, and Authorizer DRPC
|
||||
// services backed by srv onto mux. It is shared by the embedded in-memory
|
||||
// server and the standalone /api/v2/ai-gateway/serve WebSocket handler so both
|
||||
// expose an identical service set.
|
||||
func Register(mux *drpcmux.Mux, srv *Server) error {
|
||||
if err := proto.DRPCRegisterRecorder(mux, srv); err != nil {
|
||||
return xerrors.Errorf("register recorder service: %w", err)
|
||||
}
|
||||
if err := proto.DRPCRegisterMCPConfigurator(mux, srv); err != nil {
|
||||
return xerrors.Errorf("register MCP configurator service: %w", err)
|
||||
}
|
||||
if err := proto.DRPCRegisterAuthorizer(mux, srv); err != nil {
|
||||
return xerrors.Errorf("register authorizer service: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Generated
+24
@@ -1532,6 +1532,25 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/serve": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "AI Gateway serve",
|
||||
"operationId": "ai-gateway-serve",
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "Switching Protocols"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AIGatewayKey": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/sessions": {
|
||||
"get": {
|
||||
"description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
|
||||
@@ -28912,6 +28931,11 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"AIGatewayKey": {
|
||||
"type": "apiKey",
|
||||
"name": "X-AI-Governance-Gateway-Key",
|
||||
"in": "header"
|
||||
},
|
||||
"Authorization": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorizaiton",
|
||||
|
||||
Generated
+22
@@ -1355,6 +1355,23 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/serve": {
|
||||
"get": {
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "AI Gateway serve",
|
||||
"operationId": "ai-gateway-serve",
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "Switching Protocols"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AIGatewayKey": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/sessions": {
|
||||
"get": {
|
||||
"description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.",
|
||||
@@ -26681,6 +26698,11 @@
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"AIGatewayKey": {
|
||||
"type": "apiKey",
|
||||
"name": "X-AI-Governance-Gateway-Key",
|
||||
"in": "header"
|
||||
},
|
||||
"Authorization": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorizaiton",
|
||||
|
||||
@@ -338,6 +338,10 @@ type Options struct {
|
||||
// @securitydefinitions.apiKey CoderSessionToken
|
||||
// @in header
|
||||
// @name Coder-Session-Token
|
||||
|
||||
// @securitydefinitions.apiKey AIGatewayKey
|
||||
// @in header
|
||||
// @name X-AI-Governance-Gateway-Key
|
||||
// New constructs a Coder API handler.
|
||||
func New(options *Options) *API {
|
||||
if options == nil {
|
||||
|
||||
@@ -370,6 +370,11 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) {
|
||||
comment.router == "/api/v2/init-script/{os}/{arch}" {
|
||||
return // endpoints do not require authorization
|
||||
}
|
||||
if comment.router == "/api/v2/ai-gateway/serve" {
|
||||
assert.Equal(t, "AIGatewayKey", comment.security, "@Security must be AIGatewayKey")
|
||||
return
|
||||
}
|
||||
|
||||
assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user