Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gateway/.gitignore
Original file line number Diff line number Diff line change
@@ -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-*
Expand Down
19 changes: 17 additions & 2 deletions gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 84 additions & 1 deletion gateway/wrapper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
165 changes: 165 additions & 0 deletions gateway/wrapper/realtime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Binary file removed gateway/wrapper/wrapper
Binary file not shown.
Loading