diff --git a/gateway/.gitignore b/gateway/.gitignore index 7bbe3490e..4bcf63f57 100644 --- a/gateway/.gitignore +++ b/gateway/.gitignore @@ -1,6 +1,11 @@ # Plugin build output (built per-host, never checked in). build/ +# Wrapper binary. `go build ./...` in wrapper/ emits an executable named +# after the directory (`wrapper`); the real one is built in the +# Dockerfile's wrapper-builder stage. Never check the local copy in. +wrapper/wrapper + # Bifrost runtime state (config DB, logs DB, log files). data/config.db data/config.db-* diff --git a/gateway/README.md b/gateway/README.md index 192a0fdac..bbd99ceee 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -85,8 +85,23 @@ binary, and produces a single Alpine runtime image with all three. The dashboard UI and Bifrost's `/api/*` admin endpoints (governance, config, logs) require HTTP Basic auth. Inference endpoints (`/v1/*`, -`/openai/*`, `/anthropic/*`, etc.) stay open so existing agents work -unchanged. +`/openai/*`, `/anthropic/*`, etc.) require a valid virtual key +(`enforce_auth_on_inference: true` in config.json's `client` block); +existing agents pass one already. + +### Realtime endpoints are blocked at the wrapper + +The wrapper refuses bifrost's realtime routes (`/realtime`, +`/v1/realtime`, `/openai/**/realtime`, and their `/calls`, +`/client_secrets`, `/sessions` subpaths) with a `403` before they reach +bifrost-http. Bifrost's realtime WebSocket / WebRTC handlers dial the +upstream provider — with the account's real key — during connection +setup, which runs *before* the per-turn virtual-key check. So an +unauthenticated caller can open provider sockets on the account (quota +exhaustion, key-validity probing) even though the mandatory-VK check +still blocks actual token generation. Hive's agents are text-only, so +the whole family is disabled by default. Set `BIFROST_ENABLE_REALTIME=1` +to opt back in if a swarm ever needs voice. Credentials come from two env vars that get resolved at boot by Bifrost itself (config.json references `env.BIFROST_ADMIN_USER` and diff --git a/gateway/wrapper/main.go b/gateway/wrapper/main.go index 2f23375fb..571ba1a8b 100644 --- a/gateway/wrapper/main.go +++ b/gateway/wrapper/main.go @@ -427,17 +427,40 @@ func filesEqual(a, b string) (bool, error) { // headers can deliver both VK and macaroon in a single API-key field. // The plugin proxy intentionally does NOT run that rewrite — its // /_plugin/* admin surface uses a different auth scheme entirely. +// +// Unless BIFROST_ENABLE_REALTIME is truthy, the realtime family of +// routes is refused here at the wrapper (see isRealtimePath / blockRealtime). func newProxy(logger *log.Logger, pluginReady bool) http.Handler { bifrostURL := mustParseURL(bifrostUpstream) bifrostProxy := newSingleHostReverseProxy(bifrostURL, logger, "bifrost") installAuthRewrite(bifrostProxy) - var pluginProxy *httputil.ReverseProxy + // pluginProxy is an interface (not *httputil.ReverseProxy) so newRouter + // stays unit-testable with fake upstreams; nil means "no plugin server". + var pluginProxy http.Handler if pluginReady { pluginURL := mustParseURL(pluginUpstream) pluginProxy = newSingleHostReverseProxy(pluginURL, logger, "plugin") } + realtimeBlocked := !realtimeEnabled() + if realtimeBlocked { + logger.Printf("realtime endpoints DISABLED at the wrapper " + + "(/realtime, /v1/realtime, /openai/**/realtime and their " + + "/calls, /client_secrets, /sessions subpaths return 403); " + + "set BIFROST_ENABLE_REALTIME=1 to allow") + } else { + logger.Printf("realtime endpoints ENABLED (BIFROST_ENABLE_REALTIME is set)") + } + + return newRouter(bifrostProxy, pluginProxy, realtimeBlocked, logger) +} + +// newRouter builds the request router the public listener serves. Split +// out from newProxy so the routing decisions (plugin vs realtime-block vs +// bifrost) can be unit-tested against fake upstream handlers without +// dialing the real loopback services. +func newRouter(bifrostProxy, pluginProxy http.Handler, realtimeBlocked bool, logger *log.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, pluginPathPrefix) { if pluginProxy == nil { @@ -452,10 +475,70 @@ func newProxy(logger *log.Logger, pluginReady bool) http.Handler { pluginProxy.ServeHTTP(w, r) return } + // Refuse realtime routes before they can reach bifrost-http. This + // matters because bifrost's realtime WebSocket / WebRTC handlers + // dial the upstream provider (with the org's real key) during the + // connection setup that runs BEFORE the per-turn governance check — + // so an unauthenticated caller can open provider sockets on the + // account (quota exhaustion / key-validity oracle) even though the + // mandatory-virtual-key check later blocks actual token generation. + // The gateway's macaroon plugin never sees realtime either (it hooks + // PreLLMHook; realtime runs its own turn pipeline). Hive uses text + // agents, not voice, so the whole family is disabled by default. + if realtimeBlocked && isRealtimePath(r.URL.Path) { + blockRealtime(w, r, logger) + return + } bifrostProxy.ServeHTTP(w, r) }) } +// isRealtimePath reports whether p is one of bifrost's realtime API +// routes. Every realtime route bifrost registers carries a `realtime` +// path segment regardless of its integration prefix — the WebSocket +// endpoints (`/v1/realtime`, `/realtime`, `/openai/realtime`, +// `/openai/v1/realtime`), the WebRTC SDP exchange (`.../realtime/calls`), +// and the ephemeral-secret aliases (`.../realtime/client_secrets`, +// `.../realtime/sessions`). Matching on the segment blocks the whole +// family and stays correct if bifrost adds another prefix variant. +// +// The match is exact per segment (case-insensitive), so a hypothetical +// unrelated route like `/v1/realtimeless` is NOT blocked. +func isRealtimePath(p string) bool { + for _, seg := range strings.Split(p, "/") { + if strings.EqualFold(seg, "realtime") { + return true + } + } + return false +} + +// blockRealtime refuses a realtime request with 403 and a small JSON +// body, and logs the attempt so operators can see probing. Returning +// here means bifrost-http's realtime handler never runs, so no upstream +// provider socket is opened. +func blockRealtime(w http.ResponseWriter, r *http.Request, logger *log.Logger) { + logger.Printf("blocked realtime request method=%s path=%s remote=%s", + r.Method, r.URL.Path, r.RemoteAddr) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, + `{"error":{"code":"realtime_disabled","message":"realtime endpoints are disabled on this gateway"}}`) +} + +// realtimeEnabled reports whether BIFROST_ENABLE_REALTIME opts back into +// bifrost's realtime routes. Default (unset / empty / anything not +// truthy) is disabled. Truthy: 1/true/yes/on (case-insensitive) — the +// same loose grammar the plugin's env readers use. +func realtimeEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("BIFROST_ENABLE_REALTIME"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + // newSingleHostReverseProxy builds an httputil.ReverseProxy targeting // the given URL. We add a custom error handler so upstream failures // (e.g. connection refused after a crash) become 502s with a useful diff --git a/gateway/wrapper/realtime_test.go b/gateway/wrapper/realtime_test.go new file mode 100644 index 000000000..88aa2374c --- /dev/null +++ b/gateway/wrapper/realtime_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsRealtimePath(t *testing.T) { + blocked := []string{ + // WebSocket endpoints (OpenAIRealtimePaths, "" and "/openai" prefixes). + "/v1/realtime", + "/realtime", + "/openai/realtime", + "/openai/v1/realtime", + // WebRTC SDP exchange (OpenAIRealtimeWebRTCCallsPaths). + "/v1/realtime/calls", + "/realtime/calls", + "/openai/realtime/calls", + "/openai/v1/realtime/calls", + // Ephemeral client-secret / session aliases. + "/v1/realtime/client_secrets", + "/v1/realtime/sessions", + "/openai/v1/realtime/client_secrets", + // Case-insensitive segment match. + "/v1/Realtime", + } + for _, p := range blocked { + if !isRealtimePath(p) { + t.Errorf("isRealtimePath(%q) = false, want true", p) + } + } + + allowed := []string{ + "/v1/chat/completions", + "/v1/responses", + "/v1/embeddings", + "/v1/models", + "/anthropic/v1/messages", + "/_plugin/health", + "/health", + "/", + // Must not misfire on a substring — only an exact segment counts. + "/v1/realtimeless", + "/v1/notrealtime", + "/v1/realtimely/calls", + } + for _, p := range allowed { + if isRealtimePath(p) { + t.Errorf("isRealtimePath(%q) = true, want false", p) + } + } +} + +func TestRealtimeEnabled(t *testing.T) { + cases := []struct { + val string + want bool + }{ + {"", false}, + {"0", false}, + {"false", false}, + {"no", false}, + {"off", false}, + {"nonsense", false}, + {"1", true}, + {"true", true}, + {"TRUE", true}, + {"Yes", true}, + {"on", true}, + {" on ", true}, // trimmed + } + for _, c := range cases { + t.Setenv("BIFROST_ENABLE_REALTIME", c.val) + if got := realtimeEnabled(); got != c.want { + t.Errorf("realtimeEnabled() with %q = %v, want %v", c.val, got, c.want) + } + } +} + +// markerHandler records that it was hit and writes a recognizable body so +// tests can assert which upstream a request was routed to. +func markerHandler(name string, hit *bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *hit = true + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, name) + }) +} + +func TestNewRouterRealtimeBlocking(t *testing.T) { + logger := log.New(io.Discard, "", 0) + + newRouterFor := func(blocked bool, bifrostHit, pluginHit *bool) http.Handler { + return newRouter( + markerHandler("bifrost", bifrostHit), + markerHandler("plugin", pluginHit), + blocked, + logger, + ) + } + + t.Run("realtime blocked returns 403 and never reaches bifrost", func(t *testing.T) { + for _, p := range []string{"/v1/realtime", "/openai/v1/realtime/calls", "/v1/realtime/client_secrets"} { + var bifrostHit, pluginHit bool + h := newRouterFor(true, &bifrostHit, &pluginHit) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) + if rec.Code != http.StatusForbidden { + t.Errorf("path %q: status = %d, want %d", p, rec.Code, http.StatusForbidden) + } + if bifrostHit { + t.Errorf("path %q: bifrost upstream was hit; realtime should be blocked before it", p) + } + } + }) + + t.Run("realtime allowed when enabled reaches bifrost", func(t *testing.T) { + var bifrostHit, pluginHit bool + h := newRouterFor(false, &bifrostHit, &pluginHit) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/realtime", nil)) + if !bifrostHit { + t.Error("realtime enabled: bifrost upstream was not hit") + } + }) + + t.Run("normal inference reaches bifrost even when realtime blocked", func(t *testing.T) { + var bifrostHit, pluginHit bool + h := newRouterFor(true, &bifrostHit, &pluginHit) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)) + if !bifrostHit { + t.Error("chat completions: bifrost upstream was not hit") + } + if rec.Code != http.StatusOK { + t.Errorf("chat completions: status = %d, want %d", rec.Code, http.StatusOK) + } + }) + + t.Run("plugin path reaches plugin upstream", func(t *testing.T) { + var bifrostHit, pluginHit bool + h := newRouterFor(true, &bifrostHit, &pluginHit) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_plugin/health", nil)) + if !pluginHit { + t.Error("plugin path: plugin upstream was not hit") + } + if bifrostHit { + t.Error("plugin path: bifrost upstream should not be hit") + } + }) + + t.Run("plugin path with no plugin server returns 503", func(t *testing.T) { + var bifrostHit bool + h := newRouter(markerHandler("bifrost", &bifrostHit), nil, true, logger) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_plugin/health", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + }) +} diff --git a/gateway/wrapper/wrapper b/gateway/wrapper/wrapper deleted file mode 100755 index c76ecee66..000000000 Binary files a/gateway/wrapper/wrapper and /dev/null differ