Files
2026-09-11 06:14:38 +02:00

1424 lines
51 KiB
Go

package server
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/example/ollama-fair-gateway/internal/auth"
"github.com/example/ollama-fair-gateway/internal/autotune"
"github.com/example/ollama-fair-gateway/internal/config"
"github.com/example/ollama-fair-gateway/internal/proxy"
"github.com/example/ollama-fair-gateway/internal/webui"
)
const (
uiSessionCookie = "ofg_session"
uiStateCookie = "ofg_oidc_state"
uiCSRFCookie = "ofg_csrf"
)
type oidcLoginState struct {
State string `json:"state"`
Verifier string `json:"verifier"`
Redirect string `json:"redirect"`
Expires int64 `json:"expires"`
}
func (s *Server) handleUIPublic(w http.ResponseWriter, r *http.Request) bool {
if !s.cfg.UI.Enabled {
return false
}
if r.URL.Path == "/gateway/ui-api/bootstrap" {
writeJSON(w, 200, map[string]any{
"title": s.cfg.UI.Title,
"ui_path": s.cfg.UI.Path,
"oidc_enabled": s.cfg.UI.OIDC.Enabled,
"oidc_login_url": s.cfg.UI.Path + "/login/oidc",
})
return true
}
base := s.cfg.UI.Path
if r.URL.Path == base {
http.Redirect(w, r, base+"/", http.StatusTemporaryRedirect)
return true
}
if !strings.HasPrefix(r.URL.Path, base+"/") {
return false
}
rel := strings.TrimPrefix(r.URL.Path, base+"/")
switch rel {
case "login/oidc":
s.uiOIDCLogin(w, r)
return true
case "callback":
s.uiOIDCCallback(w, r)
return true
case "logout":
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return true
}
s.clearUICookies(w, r)
w.WriteHeader(http.StatusNoContent)
return true
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return true
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
w.Header().Set("Cache-Control", "no-store")
h := webui.Handler()
r2 := r.Clone(r.Context())
r2.URL.Path = "/" + rel
h.ServeHTTP(w, r2)
return true
}
func (s *Server) injectUISession(r *http.Request) bool {
if !s.cfg.UI.Enabled || r.Header.Get("Authorization") != "" || r.Header.Get("X-API-Key") != "" {
return false
}
c, err := r.Cookie(uiSessionCookie)
if err != nil || c.Value == "" {
return false
}
token, err := s.sessions.Get(r.Context(), c.Value)
if err != nil || token == "" {
return false
}
r.Header.Set("Authorization", "Bearer "+token)
return true
}
func (s *Server) uiOIDCLogin(w http.ResponseWriter, r *http.Request) {
if !s.cfg.UI.OIDC.Enabled || !s.auth.OIDCEnabled() {
proxy.WriteJSONError(w, 404, "oidc_disabled", "browser OIDC login is disabled")
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
ep, ok := s.auth.OIDCBrowserEndpoints()
if !ok || ep.Authorization == "" {
proxy.WriteJSONError(w, 503, "oidc_unavailable", "OIDC authorization endpoint unavailable")
return
}
state := randomURLToken(24)
verifier := randomURLToken(48)
redirect := s.uiRedirectURL(r)
payload := oidcLoginState{State: state, Verifier: verifier, Redirect: redirect, Expires: time.Now().Add(10 * time.Minute).Unix()}
signed, err := s.signUIState(payload)
if err != nil {
proxy.WriteJSONError(w, 500, "oidc_state", err.Error())
return
}
http.SetCookie(w, &http.Cookie{Name: uiStateCookie, Value: signed, Path: s.cfg.UI.Path + "/", MaxAge: 600, HttpOnly: true, Secure: s.uiSecureCookie(r), SameSite: http.SameSiteLaxMode})
h := sha256.Sum256([]byte(verifier))
q := url.Values{}
q.Set("response_type", "code")
q.Set("client_id", s.cfg.UI.OIDC.ClientID)
q.Set("redirect_uri", redirect)
q.Set("scope", strings.Join(s.cfg.UI.OIDC.Scopes, " "))
q.Set("state", state)
q.Set("code_challenge", base64.RawURLEncoding.EncodeToString(h[:]))
q.Set("code_challenge_method", "S256")
target := ep.Authorization
sep := "?"
if strings.Contains(target, "?") {
sep = "&"
}
http.Redirect(w, r, target+sep+q.Encode(), http.StatusFound)
}
func (s *Server) uiOIDCCallback(w http.ResponseWriter, r *http.Request) {
if !s.cfg.UI.OIDC.Enabled || r.Method != http.MethodGet {
w.WriteHeader(http.StatusNotFound)
return
}
if e := r.URL.Query().Get("error"); e != "" {
http.Redirect(w, r, s.cfg.UI.Path+"/?login_error="+url.QueryEscape(e), http.StatusFound)
return
}
cookie, err := r.Cookie(uiStateCookie)
if err != nil {
proxy.WriteJSONError(w, 400, "oidc_state", "missing OIDC state cookie")
return
}
st, err := s.verifyUIState(cookie.Value)
if err != nil || st.Expires < time.Now().Unix() || subtle.ConstantTimeCompare([]byte(st.State), []byte(r.URL.Query().Get("state"))) != 1 {
proxy.WriteJSONError(w, 400, "oidc_state", "invalid or expired OIDC state")
return
}
code := r.URL.Query().Get("code")
if code == "" {
proxy.WriteJSONError(w, 400, "oidc_code", "missing authorization code")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tok, err := s.auth.ExchangeOIDCCode(ctx, code, st.Redirect, s.cfg.UI.OIDC.ClientID, s.cfg.UI.OIDC.ClientSecret, st.Verifier)
if err != nil {
proxy.WriteJSONError(w, 502, "oidc_exchange", err.Error())
return
}
if _, err := s.auth.VerifyOIDCToken(ctx, tok.AccessToken); err != nil {
proxy.WriteJSONError(w, 401, "oidc_token", "OIDC access token is not valid for this gateway")
return
}
maxAge := int(tok.ExpiresIn)
if maxAge <= 0 || maxAge > 86400 {
maxAge = 3600
}
sessionID, err := s.sessions.Create(ctx, tok.AccessToken, time.Duration(maxAge)*time.Second)
if err != nil {
proxy.WriteJSONError(w, 503, "session_store", "could not create UI session")
return
}
http.SetCookie(w, &http.Cookie{Name: uiSessionCookie, Value: sessionID, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: s.uiSecureCookie(r), SameSite: http.SameSiteLaxMode})
csrf := randomURLToken(24)
http.SetCookie(w, &http.Cookie{Name: uiCSRFCookie, Value: csrf, Path: "/", MaxAge: maxAge, HttpOnly: false, Secure: s.uiSecureCookie(r), SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: uiStateCookie, Value: "", Path: s.cfg.UI.Path + "/", MaxAge: -1, HttpOnly: true, Secure: s.uiSecureCookie(r), SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, s.cfg.UI.Path+"/", http.StatusFound)
}
func (s *Server) uiRedirectURL(r *http.Request) string {
if s.cfg.UI.OIDC.RedirectURL != "" {
return s.cfg.UI.OIDC.RedirectURL
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + s.cfg.UI.Path + "/callback"
}
func (s *Server) uiSecureCookie(r *http.Request) bool { return s.cfg.UI.SecureCookies || r.TLS != nil }
func (s *Server) clearUICookies(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(uiSessionCookie); err == nil && c.Value != "" {
_ = s.sessions.Delete(r.Context(), c.Value)
}
for _, c := range []http.Cookie{
{Name: uiSessionCookie, Path: "/", MaxAge: -1, HttpOnly: true},
{Name: uiCSRFCookie, Path: "/", MaxAge: -1},
{Name: uiStateCookie, Path: s.cfg.UI.Path + "/", MaxAge: -1, HttpOnly: true},
} {
c.Secure = s.uiSecureCookie(r)
c.SameSite = http.SameSiteLaxMode
http.SetCookie(w, &c)
}
}
func (s *Server) signUIState(st oidcLoginState) (string, error) {
b, err := json.Marshal(st)
if err != nil {
return "", err
}
p := base64.RawURLEncoding.EncodeToString(b)
m := hmac.New(sha256.New, []byte(s.cfg.UI.SessionSecret))
_, _ = m.Write([]byte(p))
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil
}
func (s *Server) verifyUIState(v string) (oidcLoginState, error) {
parts := strings.Split(v, ".")
if len(parts) != 2 {
return oidcLoginState{}, errors.New("bad state")
}
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return oidcLoginState{}, err
}
m := hmac.New(sha256.New, []byte(s.cfg.UI.SessionSecret))
_, _ = m.Write([]byte(parts[0]))
if !hmac.Equal(sig, m.Sum(nil)) {
return oidcLoginState{}, errors.New("bad state signature")
}
b, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return oidcLoginState{}, err
}
var out oidcLoginState
err = json.Unmarshal(b, &out)
return out, err
}
func randomURLToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) checkUICSRF(r *http.Request, sessionCookieAuth bool) bool {
if !sessionCookieAuth || r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
return true
}
c, err := r.Cookie(uiCSRFCookie)
if err != nil || c.Value == "" || subtle.ConstantTimeCompare([]byte(c.Value), []byte(r.Header.Get("X-CSRF-Token"))) != 1 {
return false
}
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
return false
}
}
return true
}
func (s *Server) uiAPI(w http.ResponseWriter, r *http.Request, id auth.Identity, sessionCookieAuth bool) {
if !s.cfg.UI.Enabled {
proxy.WriteJSONError(w, 404, "not_found", "web UI disabled")
return
}
if !s.checkUICSRF(r, sessionCookieAuth) {
proxy.WriteJSONError(w, 403, "csrf", "invalid CSRF token")
return
}
if r.URL.Path == "/gateway/ui-api/session" {
scopes := make([]string, 0, len(id.Scopes))
for x := range id.Scopes {
scopes = append(scopes, x)
}
sort.Strings(scopes)
writeJSON(w, 200, map[string]any{"tenant": id.Tenant, "subject": id.Subject, "application": id.Application, "auth_type": id.AuthType, "actor": id.Actor(), "admin": id.IsAdmin(), "scopes": scopes, "client_ip": id.ClientIP})
return
}
if !id.IsAdmin() {
proxy.WriteJSONError(w, 403, "forbidden", "gateway:admin scope required")
return
}
switch {
case r.URL.Path == "/gateway/ui-api/live" && r.Method == http.MethodGet:
writeJSON(w, 200, s.live.Snapshot())
case r.URL.Path == "/gateway/ui-api/live/stream" && r.Method == http.MethodGet:
s.uiLiveStream(w, r)
case r.URL.Path == "/gateway/ui-api/infrastructure" && r.Method == http.MethodGet:
if s.infrastructure == nil {
proxy.WriteJSONError(w, 503, "infrastructure_unavailable", "infrastructure live view is unavailable")
return
}
writeJSON(w, 200, s.infrastructure.Snapshot())
case r.URL.Path == "/gateway/ui-api/infrastructure/stream" && r.Method == http.MethodGet:
s.uiInfrastructureStream(w, r)
case r.URL.Path == "/gateway/ui-api/policy-simulator" && r.Method == http.MethodPost:
var in policySimulationRequest
if err := decodeJSON(r, &in, 1<<20); err != nil {
proxy.WriteJSONError(w, 400, "bad_request", err.Error())
return
}
result, err := s.simulatePolicy(r.Context(), in)
if err != nil {
proxy.WriteJSONError(w, 400, "simulation_failed", err.Error())
return
}
writeJSON(w, 200, result)
case r.URL.Path == "/gateway/ui-api/overview" && r.Method == http.MethodGet:
st := s.sched.Stats(r.Context())
writeJSON(w, 200, map[string]any{
"scheduler": st,
"scheduler_config": map[string]any{"global_concurrency": s.cfg.Scheduler.GlobalConcurrency, "max_queue": s.cfg.Scheduler.MaxQueue, "max_queue_per_actor": s.cfg.Scheduler.MaxQueuePerActor, "queue_timeout": s.cfg.Scheduler.QueueTimeout.Value().String(), "mode": "in-memory", "routing": s.cfg.Routing, "model_capabilities": s.cfg.ModelCapabilities},
"workers": s.workers.Snapshots(), "usage": s.usage.Global(), "tenants": s.usage.LocalTenants(), "uptime_seconds": time.Since(s.startedAt).Seconds(),
"storage": map[string]any{"mode": "local-persistent", "data_dir": s.cfg.Storage.DataDir, "usage_journal": s.cfg.Usage.JournalDir},
"model_aliases": s.aliasSnapshot(), "model_access": s.modelAccessSnapshot(), "reliability": s.cfg.Reliability,
"service_classes": s.cfg.ServiceClasses, "auto_tuning": s.cfg.AutoTuning,
"warm_models": func() any {
if s.warm != nil {
return s.warm.Status()
}
return map[string]any{"enabled": false}
}(),
"alerts": func() any {
if s.alerts != nil {
return s.alerts.Status()
}
return map[string]any{"enabled": false}
}(),
"opentelemetry": map[string]any{"enabled": s.cfg.OpenTelemetry.Enabled, "endpoint": s.cfg.OpenTelemetry.Endpoint, "service_name": s.cfg.OpenTelemetry.ServiceName, "sample_ratio": s.cfg.OpenTelemetry.SampleRatio, "exported_spans": func() uint64 {
if s.otel != nil {
return s.otel.Exported()
}
return 0
}(), "failed_spans": func() uint64 {
if s.otel != nil {
return s.otel.Failed()
}
return 0
}(), "dropped_spans": func() uint64 {
if s.otel != nil {
return s.otel.Dropped()
}
return 0
}()},
})
case r.URL.Path == "/gateway/ui-api/recent" && r.Method == http.MethodGet:
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > s.cfg.UI.RecentEvents {
limit = min(1000, s.cfg.UI.RecentEvents)
}
writeJSON(w, 200, map[string]any{"events": s.usage.Recent(limit), "scope": "persistent-journal"})
case r.URL.Path == "/gateway/ui-api/usage/rollups" && r.Method == http.MethodGet:
granularity := r.URL.Query().Get("granularity")
if granularity == "" {
granularity = "daily"
}
if granularity != "daily" && granularity != "monthly" {
proxy.WriteJSONError(w, 400, "bad_granularity", "granularity must be daily or monthly")
return
}
dimension := r.URL.Query().Get("dimension")
if dimension == "" {
dimension = "global"
}
switch dimension {
case "global", "tenant", "actor", "application", "model", "worker":
default:
proxy.WriteJSONError(w, 400, "bad_dimension", "dimension must be global, tenant, actor, application, model, or worker")
return
}
name := r.URL.Query().Get("name")
if dimension != "global" && name == "" {
proxy.WriteJSONError(w, 400, "missing_name", "name is required for the selected dimension")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
writeJSON(w, 200, map[string]any{"granularity": granularity, "dimension": dimension, "name": name, "points": s.usage.Series(granularity, dimension, name, limit), "retention": s.usage.RetentionStatus()})
case r.URL.Path == "/gateway/ui-api/autotune" && r.Method == http.MethodGet:
s.uiAutoTuneGet(w, r)
case r.URL.Path == "/gateway/ui-api/autotune/start" && r.Method == http.MethodPost:
s.uiAutoTuneStart(w, r, id)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/autotune/"):
s.uiAutoTuneAction(w, r, id)
case r.URL.Path == "/gateway/ui-api/warm-models" && r.Method == http.MethodGet:
s.uiWarmModelsGet(w, r)
case r.URL.Path == "/gateway/ui-api/warm-models" && r.Method == http.MethodPut:
s.uiWarmModelsSave(w, r, id)
case r.URL.Path == "/gateway/ui-api/warm-models" && r.Method == http.MethodDelete:
s.uiWarmModelsReset(w, r, id)
case r.URL.Path == "/gateway/ui-api/warm-models/reconcile" && r.Method == http.MethodPost:
s.uiWarmModelsReconcile(w, r, id)
case r.URL.Path == "/gateway/ui-api/alerts" && r.Method == http.MethodGet:
s.uiAlertsGet(w, r)
case r.URL.Path == "/gateway/ui-api/alerts/test" && r.Method == http.MethodPost:
s.uiAlertsTest(w, r, id)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/workers/"):
s.uiWorkerRuntime(w, r, id)
case r.URL.Path == "/gateway/ui-api/models" && r.Method == http.MethodGet:
writeJSON(w, 200, map[string]any{"inventories": s.workers.Inventories(r.Context())})
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/model-aliases"):
s.uiModelAliases(w, r, id)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/model-access"):
s.uiModelAccess(w, r, id)
case r.URL.Path == "/gateway/ui-api/placement" && r.Method == http.MethodGet:
s.uiPlacementGet(w, r)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/placement/"):
s.uiPlacementRule(w, r, id)
case r.URL.Path == "/gateway/ui-api/models/pull" && r.Method == http.MethodPost:
s.uiModelPull(w, r)
case r.URL.Path == "/gateway/ui-api/models/action" && r.Method == http.MethodPost:
s.uiModelAction(w, r)
case r.URL.Path == "/gateway/ui-api/jobs" && r.Method == http.MethodGet:
s.uiJobs(w, r)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/batches"):
s.uiBatchJobs(w, r, id)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/jobs/") && strings.HasSuffix(r.URL.Path, "/cancel") && r.Method == http.MethodPost:
jobID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/jobs/"), "/cancel")
if jobID == "" || !s.jobs.cancelJob(jobID) {
proxy.WriteJSONError(w, 409, "not_running", "job is not running")
return
}
s.log.Info("job cancellation requested", "request_id", jobID, "admin_subject", id.Subject, "admin_auth_type", id.AuthType)
writeJSON(w, 202, map[string]any{"status": "cancelling", "id": jobID})
case r.URL.Path == "/gateway/ui-api/operations" && r.Method == http.MethodGet:
writeJSON(w, 200, map[string]any{"operations": s.ops.list()})
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/operations/") && strings.HasSuffix(r.URL.Path, "/cancel") && r.Method == http.MethodPost:
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/operations/"), "/cancel")
if !s.ops.cancelOperation(id) {
proxy.WriteJSONError(w, 409, "not_running", "operation is not running")
return
}
writeJSON(w, 202, map[string]any{"status": "cancelling"})
case r.URL.Path == "/gateway/ui-api/policies" && r.Method == http.MethodGet:
overrides, err := s.policies.List(r.Context())
if err != nil {
proxy.WriteJSONError(w, 503, "policy_store", err.Error())
return
}
writeJSON(w, 200, map[string]any{"baseline": s.cfg.Scheduler.Policies, "overrides": overrides})
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/policies/"):
tenant, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/policies/"))
if err != nil || tenant == "" || len(tenant) > 256 {
proxy.WriteJSONError(w, 400, "bad_tenant", "invalid tenant")
return
}
s.uiPolicy(w, r, tenant)
case r.URL.Path == "/gateway/ui-api/api-keys" && r.Method == http.MethodGet:
storage := "in-memory"
if s.auth.HasPersistentRuntimeStore() {
storage = "persistent-file"
}
writeJSON(w, 200, map[string]any{"keys": s.auth.APIKeys(), "storage": storage})
case r.URL.Path == "/gateway/ui-api/api-keys" && r.Method == http.MethodPost:
s.uiAPIKeyCreate(w, r, id)
case strings.HasPrefix(r.URL.Path, "/gateway/ui-api/api-keys/") && r.Method == http.MethodDelete:
s.uiAPIKeyDelete(w, r, id)
case r.URL.Path == "/gateway/ui-api/config" && r.Method == http.MethodGet:
writeJSON(w, 200, s.redactedConfig())
case r.URL.Path == "/gateway/ui-api/config" && r.Method == http.MethodPut:
s.uiConfigSave(w, r, id)
case r.URL.Path == "/gateway/ui-api/config" && r.Method == http.MethodDelete:
s.uiConfigReset(w, r, id)
case r.URL.Path == "/gateway/ui-api/storage" && r.Method == http.MethodGet:
writeJSON(w, 200, s.storageStatus())
case r.URL.Path == "/gateway/ui-api/storage/flush" && r.Method == http.MethodPost:
s.uiStorageFlush(w, r)
case r.URL.Path == "/gateway/ui-api/storage/compact" && r.Method == http.MethodPost:
s.uiStorageCompact(w, r)
case r.URL.Path == "/gateway/ui-api/storage/backup" && r.Method == http.MethodGet:
s.uiStorageBackup(w, r)
default:
proxy.WriteJSONError(w, 404, "not_found", "unknown UI endpoint")
}
}
func (s *Server) uiWarmModelsGet(w http.ResponseWriter, r *http.Request) {
if s.warm == nil {
writeJSON(w, 200, map[string]any{"enabled": false, "policies": map[string]any{}, "baseline": map[string]any{}, "actions": []any{}, "eviction_suggestions": []any{}})
return
}
writeJSON(w, 200, s.warm.Status())
}
func (s *Server) uiWarmModelsSave(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.warm == nil {
proxy.WriteJSONError(w, 503, "warm_models_unavailable", "warm model manager is unavailable")
return
}
var in struct {
Policies map[string]config.WarmModelPolicy `json:"policies"`
}
if err := decodeJSON(r, &in, 256<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_warm_models", err.Error())
return
}
if in.Policies == nil {
in.Policies = map[string]config.WarmModelPolicy{}
}
if err := s.warm.SetPolicies(in.Policies); err != nil {
proxy.WriteJSONError(w, 400, "bad_warm_models", err.Error())
return
}
s.log.Info("warm model policies saved", "rules", len(in.Policies), "admin_subject", actor.Subject)
writeJSON(w, 200, s.warm.Status())
}
func (s *Server) uiWarmModelsReset(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.warm == nil {
proxy.WriteJSONError(w, 503, "warm_models_unavailable", "warm model manager is unavailable")
return
}
if err := s.warm.Reset(); err != nil {
proxy.WriteJSONError(w, 503, "warm_models_store", err.Error())
return
}
s.log.Info("warm model policies reset", "admin_subject", actor.Subject)
writeJSON(w, 200, s.warm.Status())
}
func (s *Server) uiWarmModelsReconcile(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.warm == nil {
proxy.WriteJSONError(w, 503, "warm_models_unavailable", "warm model manager is unavailable")
return
}
s.warm.Reconcile(context.Background())
s.log.Info("warm model reconciliation requested", "admin_subject", actor.Subject)
writeJSON(w, 202, s.warm.Status())
}
func (s *Server) uiAlertsGet(w http.ResponseWriter, r *http.Request) {
if s.alerts == nil {
writeJSON(w, 200, map[string]any{"enabled": false, "active": []any{}, "history": []any{}, "deliveries": []any{}})
return
}
writeJSON(w, 200, s.alerts.Status())
}
func (s *Server) uiAlertsTest(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.alerts == nil {
proxy.WriteJSONError(w, 503, "alerts_unavailable", "alerts manager is unavailable")
return
}
var in struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &in, 16<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_webhook", err.Error())
return
}
if err := s.alerts.TestWebhook(strings.TrimSpace(in.Name)); err != nil {
proxy.WriteJSONError(w, 502, "webhook_test", err.Error())
return
}
s.log.Info("alert webhook test sent", "webhook", in.Name, "admin_subject", actor.Subject)
writeJSON(w, 200, map[string]any{"sent": true})
}
func (s *Server) uiAutoTuneGet(w http.ResponseWriter, r *http.Request) {
if s.autoTune == nil {
writeJSON(w, 200, map[string]any{"enabled": false, "profiles": []any{}, "applied": map[string]any{}, "config": s.cfg.AutoTuning})
return
}
writeJSON(w, 200, map[string]any{"enabled": s.cfg.AutoTuning.Enabled, "profiles": s.autoTune.List(), "applied": s.autoTune.Applied(), "config": s.cfg.AutoTuning})
}
func (s *Server) uiAutoTuneStart(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.autoTune == nil || !s.cfg.AutoTuning.Enabled {
proxy.WriteJSONError(w, 409, "autotune_disabled", "auto tuning is disabled")
return
}
var in autotune.StartRequest
if err := decodeJSON(r, &in, 64<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_autotune", err.Error())
return
}
p, err := s.autoTune.Start(context.Background(), in)
if err != nil {
proxy.WriteJSONError(w, 400, "bad_autotune", err.Error())
return
}
s.log.Info("auto-tune benchmark started", "profile_id", p.ID, "worker", p.Worker, "model", p.Model, "admin_subject", actor.Subject)
writeJSON(w, http.StatusAccepted, map[string]any{"profile": p})
}
func (s *Server) uiAutoTuneAction(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.autoTune == nil {
proxy.WriteJSONError(w, 404, "autotune_unavailable", "auto tuning is unavailable")
return
}
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/autotune/"), "/")
if rest == "reset" && r.Method == http.MethodPost {
var in struct {
Worker string `json:"worker"`
Model string `json:"model"`
}
if err := decodeJSON(r, &in, 16<<10); err != nil || strings.TrimSpace(in.Worker) == "" || strings.TrimSpace(in.Model) == "" {
proxy.WriteJSONError(w, 400, "bad_autotune_reset", "worker and model are required")
return
}
if err := s.autoTune.Reset(in.Worker, in.Model); err != nil {
proxy.WriteJSONError(w, 503, "autotune_store", err.Error())
return
}
if err := s.workers.ResetModelConcurrency(in.Worker, in.Model); err != nil {
proxy.WriteJSONError(w, 400, "worker_config", err.Error())
return
}
s.log.Info("auto-tune override reset", "worker", in.Worker, "model", in.Model, "admin_subject", actor.Subject)
writeJSON(w, 200, map[string]any{"status": "reset", "worker": in.Worker, "model": in.Model})
return
}
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[0] == "" {
proxy.WriteJSONError(w, 404, "not_found", "unknown auto-tune action")
return
}
profileID, action := parts[0], parts[1]
switch {
case action == "cancel" && r.Method == http.MethodPost:
if !s.autoTune.Cancel(profileID) {
proxy.WriteJSONError(w, 409, "not_running", "benchmark is not running")
return
}
s.log.Info("auto-tune benchmark cancellation requested", "profile_id", profileID, "admin_subject", actor.Subject)
writeJSON(w, http.StatusAccepted, map[string]any{"status": "cancelling", "id": profileID})
case action == "apply" && r.Method == http.MethodPost:
p, err := s.autoTune.Apply(profileID)
if err != nil {
proxy.WriteJSONError(w, 409, "autotune_apply", err.Error())
return
}
if err := s.workers.SetModelConcurrency(p.Worker, p.Model, p.RecommendedConcurrency); err != nil {
_ = s.autoTune.Reset(p.Worker, p.Model)
proxy.WriteJSONError(w, 400, "worker_config", err.Error())
return
}
s.log.Info("auto-tune recommendation applied", "profile_id", profileID, "worker", p.Worker, "model", p.Model, "concurrency", p.RecommendedConcurrency, "admin_subject", actor.Subject)
writeJSON(w, 200, map[string]any{"profile": p})
default:
proxy.WriteJSONError(w, 404, "not_found", "unknown auto-tune action")
}
}
func (s *Server) uiWorkerRuntime(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
rest := strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/workers/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) < 2 {
proxy.WriteJSONError(w, 404, "not_found", "unknown worker action")
return
}
name, err := url.PathUnescape(parts[0])
if err != nil || name == "" {
proxy.WriteJSONError(w, 400, "bad_worker", "invalid worker")
return
}
if _, ok := s.workers.URLFor(name); !ok {
proxy.WriteJSONError(w, 404, "worker_not_found", "worker not found")
return
}
switch {
case len(parts) == 2 && parts[1] == "maintenance" && r.Method == http.MethodPost:
var in struct {
Mode string `json:"mode"`
}
if err := decodeJSON(r, &in, 16<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_worker_mode", err.Error())
return
}
if err := s.workers.SetMaintenance(name, in.Mode); err != nil {
proxy.WriteJSONError(w, 400, "bad_worker_mode", err.Error())
return
}
if s.workerStateStore != nil {
if strings.EqualFold(strings.TrimSpace(in.Mode), "active") {
err = s.workerStateStore.Delete(r.Context(), name)
} else {
err = s.workerStateStore.Put(r.Context(), name, strings.ToLower(strings.TrimSpace(in.Mode)))
}
if err != nil {
proxy.WriteJSONError(w, 503, "worker_state_store", err.Error())
return
}
}
s.log.Info("worker maintenance changed", "worker", name, "mode", in.Mode, "admin_subject", actor.Subject)
if s.warm != nil {
s.warm.Wake()
}
writeJSON(w, 200, map[string]any{"worker": name, "mode": in.Mode})
case len(parts) == 3 && parts[1] == "circuit" && parts[2] == "reset" && r.Method == http.MethodPost:
if err := s.workers.CircuitReset(name); err != nil {
proxy.WriteJSONError(w, 400, "circuit_reset", err.Error())
return
}
s.log.Info("worker circuit reset", "worker", name, "admin_subject", actor.Subject)
if s.warm != nil {
s.warm.Wake()
}
writeJSON(w, 200, map[string]any{"worker": name, "circuit_state": "closed"})
default:
proxy.WriteJSONError(w, 404, "not_found", "unknown worker action")
}
}
func (s *Server) uiLiveStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
proxy.WriteJSONError(w, 500, "streaming_unavailable", "response writer does not support streaming")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("X-Content-Type-Options", "nosniff")
send := func() error {
b, err := json.Marshal(s.live.Snapshot())
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", b); err != nil {
return err
}
flusher.Flush()
return nil
}
changed := s.live.Changed()
if err := send(); err != nil {
return
}
// Coalesce bursts of queue/progress changes. The browser animation runs at
// display refresh rate, so sending more than four metadata snapshots per
// second adds network/JSON work without making the pulse map smoother.
flushTicker := time.NewTicker(250 * time.Millisecond)
heartbeat := time.NewTicker(15 * time.Second)
defer flushTicker.Stop()
defer heartbeat.Stop()
dirty := false
for {
select {
case <-r.Context().Done():
return
case <-changed:
changed = s.live.Changed()
dirty = true
case <-flushTicker.C:
if dirty {
if err := send(); err != nil {
return
}
dirty = false
}
case <-heartbeat.C:
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
func (s *Server) uiInfrastructureStream(w http.ResponseWriter, r *http.Request) {
if s.infrastructure == nil {
proxy.WriteJSONError(w, 503, "infrastructure_unavailable", "infrastructure live view is unavailable")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
proxy.WriteJSONError(w, 500, "streaming_unavailable", "response writer does not support streaming")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("X-Content-Type-Options", "nosniff")
send := func() error {
b, err := json.Marshal(s.infrastructure.Snapshot())
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", b); err != nil {
return err
}
flusher.Flush()
return nil
}
changed := s.infrastructure.Changed()
if err := send(); err != nil {
return
}
flushTicker := time.NewTicker(250 * time.Millisecond)
heartbeat := time.NewTicker(15 * time.Second)
defer flushTicker.Stop()
defer heartbeat.Stop()
dirty := false
for {
select {
case <-r.Context().Done():
return
case <-changed:
changed = s.infrastructure.Changed()
dirty = true
case <-flushTicker.C:
if dirty {
if err := send(); err != nil {
return
}
dirty = false
}
case <-heartbeat.C:
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
func (s *Server) uiAPIKeyCreate(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
var in struct {
Name string `json:"name"`
Tenant string `json:"tenant"`
Subject string `json:"subject"`
Application string `json:"application"`
Scopes []string `json:"scopes"`
AllowedModels []string `json:"allowed_models"`
DeniedModels []string `json:"denied_models"`
ServiceClass string `json:"service_class"`
}
if err := decodeJSON(r, &in, 64<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_api_key", err.Error())
return
}
if cls := strings.TrimSpace(in.ServiceClass); cls != "" {
if _, ok := s.cfg.ServiceClasses.Classes[cls]; !ok {
proxy.WriteJSONError(w, 400, "bad_service_class", "unknown service class")
return
}
}
info, secret, err := s.auth.CreateAPIKey(auth.APIKeyCreate{Name: in.Name, Tenant: in.Tenant, Subject: in.Subject, Application: in.Application, Scopes: in.Scopes, AllowedModels: in.AllowedModels, DeniedModels: in.DeniedModels, ServiceClass: in.ServiceClass})
if err != nil {
proxy.WriteJSONError(w, 400, "bad_api_key", err.Error())
return
}
s.log.Info("API key created", "key_id", info.ID, "key_name", info.Name, "tenant", info.Tenant, "admin_subject", actor.Subject, "admin_auth_type", actor.AuthType)
writeJSON(w, http.StatusCreated, map[string]any{"key": info, "secret": secret, "warning": "This secret is shown only once. Only its SHA-256 hash is stored persistently."})
}
func (s *Server) uiAPIKeyDelete(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
raw := strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/api-keys/")
id, err := url.PathUnescape(raw)
if err != nil || id == "" || strings.Contains(id, "/") || len(id) > 128 {
proxy.WriteJSONError(w, 400, "bad_api_key_id", "invalid API key id")
return
}
info, ok, err := s.auth.DeleteAPIKey(id)
if err != nil {
proxy.WriteJSONError(w, 503, "api_key_store", err.Error())
return
}
if !ok {
proxy.WriteJSONError(w, 404, "api_key_not_found", "persistent API key not found")
return
}
s.log.Info("API key deleted", "key_id", info.ID, "key_name", info.Name, "tenant", info.Tenant, "admin_subject", actor.Subject, "admin_auth_type", actor.AuthType)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) uiModelPull(w http.ResponseWriter, r *http.Request) {
var in struct{ Worker, Model string }
if err := decodeJSON(r, &in, 64<<10); err != nil || in.Worker == "" || in.Model == "" {
proxy.WriteJSONError(w, 400, "bad_request", "worker and model are required")
return
}
u, ok := s.workers.URLFor(in.Worker)
if !ok {
proxy.WriteJSONError(w, 404, "worker_not_found", "unknown worker")
return
}
op := s.ops.startPull(u, in.Worker, in.Model)
writeJSON(w, 202, op)
}
func (s *Server) uiModelAction(w http.ResponseWriter, r *http.Request) {
var in struct{ Action, Worker, Model string }
if err := decodeJSON(r, &in, 64<<10); err != nil || in.Worker == "" || in.Model == "" {
proxy.WriteJSONError(w, 400, "bad_request", "action, worker and model are required")
return
}
if in.Action != "stop" && in.Action != "delete" {
proxy.WriteJSONError(w, 400, "bad_action", "supported actions are stop and delete")
return
}
u, ok := s.workers.URLFor(in.Worker)
if !ok {
proxy.WriteJSONError(w, 404, "worker_not_found", "unknown worker")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := s.ops.modelAction(ctx, u, in.Action, in.Model); err != nil {
proxy.WriteJSONError(w, 502, "ollama_error", err.Error())
return
}
writeJSON(w, 200, map[string]any{"status": "ok"})
}
func (s *Server) uiPlacementGet(w http.ResponseWriter, r *http.Request) {
placements := s.workers.PlacementSnapshots()
installed := make(map[string]map[string]bool, len(placements))
loaded := make(map[string]map[string]bool, len(placements))
modelSet := map[string]bool{}
invErrors := map[string]string{}
for _, p := range placements {
iset := map[string]bool{}
for _, model := range p.InstalledModels {
iset[model] = true
modelSet[model] = true
}
installed[p.Worker] = iset
lset := map[string]bool{}
for _, model := range p.LoadedModels {
lset[model] = true
modelSet[model] = true
}
loaded[p.Worker] = lset
if p.InventoryError != "" {
invErrors[p.Worker] = p.InventoryError
}
}
models := make([]string, 0, len(modelSet))
for model := range modelSet {
models = append(models, model)
}
sort.Strings(models)
rows := make([]map[string]any, 0, len(models))
for _, model := range models {
cells := map[string]any{}
for _, p := range placements {
decision, _ := s.workers.PlacementDecision(p.Worker, model)
cells[p.Worker] = map[string]any{
"allowed": decision.Allowed,
"source": decision.Source,
"pattern": decision.Pattern,
"exact_override": decision.ExactOverride,
"installed": installed[p.Worker][model],
"loaded": loaded[p.Worker][model],
}
}
rows = append(rows, map[string]any{"model": model, "workers": cells})
}
writeJSON(w, 200, map[string]any{"workers": placements, "models": rows, "inventory_errors": invErrors, "semantics": map[string]any{"rule_order": "most-specific-match; deny wins ties", "fallback_allow_all": true, "prefix_wildcard": "trailing * only"}})
}
func (s *Server) uiPlacementRule(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.placementStore == nil {
proxy.WriteJSONError(w, 503, "placement_store", "persistent model placement store unavailable")
return
}
rest := strings.TrimPrefix(r.URL.Path, "/gateway/ui-api/placement/")
isModelAction := strings.HasSuffix(rest, "/model")
if isModelAction {
rest = strings.TrimSuffix(rest, "/model")
}
workerName, err := url.PathUnescape(strings.Trim(rest, "/"))
if err != nil || workerName == "" || strings.Contains(workerName, "/") || len(workerName) > 256 {
proxy.WriteJSONError(w, 400, "bad_worker", "invalid worker name")
return
}
if _, ok := s.workers.URLFor(workerName); !ok {
proxy.WriteJSONError(w, 404, "worker_not_found", "unknown worker")
return
}
if isModelAction {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
s.uiPlacementModelAction(w, r, actor, workerName)
return
}
switch r.Method {
case http.MethodPut:
var rule config.ModelPlacementRule
if err := decodeJSON(r, &rule, 128<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_placement", err.Error())
return
}
rule = normalizePlacementForUI(rule)
if err := config.ValidateModelPlacementRule(rule); err != nil {
proxy.WriteJSONError(w, 400, "bad_placement", err.Error())
return
}
if err := s.placementStore.Put(r.Context(), workerName, rule); err != nil {
proxy.WriteJSONError(w, 503, "placement_store", err.Error())
return
}
if err := s.workers.SetPlacement(workerName, rule, true); err != nil {
proxy.WriteJSONError(w, 500, "placement_runtime", err.Error())
return
}
s.log.Info("model placement override saved", "worker", workerName, "mode", rule.Mode, "allowed", len(rule.AllowedModels), "denied", len(rule.DeniedModels), "admin_subject", actor.Subject)
if s.warm != nil {
s.warm.Wake()
}
writeJSON(w, 200, map[string]any{"worker": workerName, "rule": rule, "override": true, "applied": true})
case http.MethodDelete:
if err := s.placementStore.Delete(r.Context(), workerName); err != nil {
proxy.WriteJSONError(w, 503, "placement_store", err.Error())
return
}
if err := s.workers.ResetPlacement(workerName); err != nil {
proxy.WriteJSONError(w, 500, "placement_runtime", err.Error())
return
}
s.log.Info("model placement override reset", "worker", workerName, "admin_subject", actor.Subject)
if s.warm != nil {
s.warm.Wake()
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Server) uiPlacementModelAction(w http.ResponseWriter, r *http.Request, actor auth.Identity, workerName string) {
var in struct {
Model string `json:"model"`
Action string `json:"action"` // allow | deny | inherit
}
if err := decodeJSON(r, &in, 32<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_placement_action", err.Error())
return
}
in.Model = strings.TrimSpace(in.Model)
if in.Model == "" || strings.Contains(in.Model, "*") || len(in.Model) > 512 {
proxy.WriteJSONError(w, 400, "bad_model", "model must be a non-empty exact model name")
return
}
if in.Action != "allow" && in.Action != "deny" && in.Action != "inherit" {
proxy.WriteJSONError(w, 400, "bad_action", "action must be allow, deny, or inherit")
return
}
var baseline config.ModelPlacementRule
var effective config.ModelPlacementRule
for _, p := range s.workers.PlacementSnapshots() {
if p.Worker == workerName {
baseline, effective = p.Baseline, p.Effective
break
}
}
effective = normalizePlacementForUI(effective)
effective.AllowedModels = removePlacementExact(effective.AllowedModels, in.Model)
effective.DeniedModels = removePlacementExact(effective.DeniedModels, in.Model)
switch in.Action {
case "allow":
effective.AllowedModels = append(effective.AllowedModels, in.Model)
case "deny":
effective.DeniedModels = append(effective.DeniedModels, in.Model)
}
effective = normalizePlacementForUI(effective)
baseline = normalizePlacementForUI(baseline)
if placementRulesEqual(effective, baseline) {
if err := s.placementStore.Delete(r.Context(), workerName); err != nil {
proxy.WriteJSONError(w, 503, "placement_store", err.Error())
return
}
if err := s.workers.ResetPlacement(workerName); err != nil {
proxy.WriteJSONError(w, 500, "placement_runtime", err.Error())
return
}
} else {
if err := s.placementStore.Put(r.Context(), workerName, effective); err != nil {
proxy.WriteJSONError(w, 503, "placement_store", err.Error())
return
}
if err := s.workers.SetPlacement(workerName, effective, true); err != nil {
proxy.WriteJSONError(w, 500, "placement_runtime", err.Error())
return
}
}
s.log.Info("model placement exact rule changed", "worker", workerName, "model", in.Model, "action", in.Action, "admin_subject", actor.Subject)
if s.warm != nil {
s.warm.Wake()
}
decision, _ := s.workers.PlacementDecision(workerName, in.Model)
writeJSON(w, 200, map[string]any{"worker": workerName, "model": in.Model, "action": in.Action, "decision": decision, "rule": effective})
}
func normalizePlacementForUI(r config.ModelPlacementRule) config.ModelPlacementRule {
if strings.TrimSpace(r.Mode) == "" {
r.Mode = "allow_all"
}
r.Mode = strings.TrimSpace(r.Mode)
norm := func(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, x := range in {
x = strings.TrimSpace(x)
if x == "" || seen[x] {
continue
}
seen[x] = true
out = append(out, x)
}
sort.Strings(out)
return out
}
r.AllowedModels = norm(r.AllowedModels)
r.DeniedModels = norm(r.DeniedModels)
return r
}
func removePlacementExact(in []string, model string) []string {
out := in[:0]
for _, x := range in {
if strings.TrimSpace(x) != model {
out = append(out, x)
}
}
return out
}
func placementRulesEqual(a, b config.ModelPlacementRule) bool {
a, b = normalizePlacementForUI(a), normalizePlacementForUI(b)
if a.Mode != b.Mode || len(a.AllowedModels) != len(b.AllowedModels) || len(a.DeniedModels) != len(b.DeniedModels) {
return false
}
for i := range a.AllowedModels {
if a.AllowedModels[i] != b.AllowedModels[i] {
return false
}
}
for i := range a.DeniedModels {
if a.DeniedModels[i] != b.DeniedModels[i] {
return false
}
}
return true
}
func (s *Server) uiJobs(w http.ResponseWriter, r *http.Request) {
entries := s.jobs.list()
byID := make(map[string]jobEntry, len(entries))
for _, j := range entries {
byID[j.ID] = j
}
snap := s.live.Snapshot()
jobs := make([]jobView, 0, len(entries))
for _, req := range snap.Requests {
j, ok := byID[req.ID]
if !ok {
continue
}
jobs = append(jobs, jobView{Request: req, Cancellable: true, Cancelling: j.Cancelling})
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs, "scope": "in-memory-process"})
}
func (s *Server) uiPolicy(w http.ResponseWriter, r *http.Request, tenant string) {
switch r.Method {
case http.MethodPut:
var p config.TenantPolicy
if err := decodeJSON(r, &p, 64<<10); err != nil {
proxy.WriteJSONError(w, 400, "bad_policy", err.Error())
return
}
if p.TenantWeight <= 0 || p.ActorWeight <= 0 || p.ActorCreditsPerMinute < 0 || p.ActorBurstCredits < 0 || p.TenantCreditsPerMinute < 0 || p.TenantBurstCredits < 0 {
proxy.WriteJSONError(w, 400, "bad_policy", "weights must be > 0 and credit values must be >= 0")
return
}
if err := s.policies.Put(r.Context(), tenant, p); err != nil {
proxy.WriteJSONError(w, 503, "policy_store", err.Error())
return
}
writeJSON(w, 200, map[string]any{"tenant": tenant, "policy": p})
case http.MethodDelete:
if err := s.policies.Delete(r.Context(), tenant); err != nil {
proxy.WriteJSONError(w, 503, "policy_store", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func decodeJSON(r *http.Request, out any, maxBytes int64) error {
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, maxBytes))
dec.DisallowUnknownFields()
return dec.Decode(out)
}
func (s *Server) policyFor(ctx context.Context, tenant string) config.TenantPolicy {
if s.policies != nil {
if p, ok, err := s.policies.Get(ctx, tenant); err == nil && ok {
return s.normalizePolicy(p)
}
if _, exact := s.cfg.Scheduler.Policies[tenant]; !exact {
if p, ok, err := s.policies.Get(ctx, "*"); err == nil && ok {
return s.normalizePolicy(p)
}
}
}
return s.cfg.Policy(tenant)
}
func (s *Server) normalizePolicy(p config.TenantPolicy) config.TenantPolicy {
if p.TenantWeight <= 0 {
p.TenantWeight = s.cfg.Scheduler.DefaultTenantWeight
}
if p.ActorWeight <= 0 {
p.ActorWeight = s.cfg.Scheduler.DefaultActorWeight
}
return p
}
func (s *Server) redactedConfig() map[string]any {
b, _ := json.Marshal(s.cfg)
var out map[string]any
_ = json.Unmarshal(b, &out)
out["model_aliases"] = s.aliasSnapshot()
out["model_access"] = s.modelAccessSnapshot()
if authMap, ok := out["auth"].(map[string]any); ok {
if keys, ok := authMap["api_keys"].([]any); ok {
for _, raw := range keys {
if key, ok := raw.(map[string]any); ok && key["key"] != nil {
key["key"] = "<redacted>"
}
}
}
}
if uiMap, ok := out["ui"].(map[string]any); ok {
if _, ok := uiMap["session_secret"]; ok {
uiMap["session_secret"] = "<redacted>"
}
if oidc, ok := uiMap["oidc"].(map[string]any); ok {
if _, ok := oidc["client_secret"]; ok {
oidc["client_secret"] = "<redacted>"
}
}
}
if alertsMap, ok := out["alerts"].(map[string]any); ok {
if hooks, ok := alertsMap["webhooks"].([]any); ok {
for _, raw := range hooks {
if hook, ok := raw.(map[string]any); ok {
if secret, exists := hook["secret"]; exists && strings.TrimSpace(fmt.Sprint(secret)) != "" {
hook["secret"] = "<redacted>"
}
}
}
}
}
if otelMap, ok := out["opentelemetry"].(map[string]any); ok {
if headers, ok := otelMap["headers"].(map[string]any); ok {
for k := range headers {
headers[k] = "<redacted>"
}
}
}
if convMap, ok := out["conversations"].(map[string]any); ok {
if secret, exists := convMap["encryption_key"]; exists && strings.TrimSpace(fmt.Sprint(secret)) != "" {
convMap["encryption_key"] = "<redacted>"
}
}
return out
}
func (s *Server) restoreRedactedConfigSecrets(candidate *config.Config) {
if candidate == nil {
return
}
candidate.Auth.APIKeys = append([]config.APIKeyConfig(nil), s.cfg.Auth.APIKeys...)
if candidate.UI.SessionSecret == "" || candidate.UI.SessionSecret == "<redacted>" {
candidate.UI.SessionSecret = s.cfg.UI.SessionSecret
}
if candidate.UI.OIDC.ClientSecret == "" || candidate.UI.OIDC.ClientSecret == "<redacted>" {
candidate.UI.OIDC.ClientSecret = s.cfg.UI.OIDC.ClientSecret
}
if candidate.Conversations.EncryptionKey == "" || candidate.Conversations.EncryptionKey == "<redacted>" {
candidate.Conversations.EncryptionKey = s.cfg.Conversations.EncryptionKey
}
for i := range candidate.Alerts.Webhooks {
if candidate.Alerts.Webhooks[i].Secret != "" && candidate.Alerts.Webhooks[i].Secret != "<redacted>" {
continue
}
name := candidate.Alerts.Webhooks[i].Name
if i < len(s.cfg.Alerts.Webhooks) && s.cfg.Alerts.Webhooks[i].Name == name {
candidate.Alerts.Webhooks[i].Secret = s.cfg.Alerts.Webhooks[i].Secret
continue
}
for _, old := range s.cfg.Alerts.Webhooks {
if old.Name == name {
candidate.Alerts.Webhooks[i].Secret = old.Secret
break
}
}
}
if candidate.OpenTelemetry.Headers == nil {
candidate.OpenTelemetry.Headers = map[string]string{}
}
for k, v := range candidate.OpenTelemetry.Headers {
if v == "<redacted>" {
if old, ok := s.cfg.OpenTelemetry.Headers[k]; ok {
candidate.OpenTelemetry.Headers[k] = old
}
}
}
}
func (s *Server) uiConfigSave(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.configStore == nil {
proxy.WriteJSONError(w, 503, "config_store", "persistent configuration store unavailable")
return
}
var raw map[string]any
if err := decodeJSON(r, &raw, 4<<20); err != nil {
proxy.WriteJSONError(w, 400, "bad_config", err.Error())
return
}
b, err := json.Marshal(raw)
if err != nil {
proxy.WriteJSONError(w, 400, "bad_config", err.Error())
return
}
candidate, err := config.ParseBytes(b)
if err != nil {
// The UI never exposes plaintext bootstrap secrets. Reparse after
// restoring fields that are intentionally immutable through this editor.
var tmp config.Config
if json.Unmarshal(b, &tmp) == nil {
s.restoreRedactedConfigSecrets(&tmp)
b, _ = json.Marshal(tmp)
candidate, err = config.ParseBytes(b)
}
}
if err != nil {
proxy.WriteJSONError(w, 400, "bad_config", err.Error())
return
}
// Secrets are never exposed by the JSON editor. Restore redacted values
// from the active bootstrap/effective config before persisting.
s.restoreRedactedConfigSecrets(candidate)
// Tests and programmatic embedders may construct Config without applying
// defaults; compare against a default-normalized bootstrap storage value.
expectedStorage := s.cfg.Storage
if expectedStorage.WorkerStateFile == "" {
expectedStorage.WorkerStateFile = "worker-state.json"
}
if expectedStorage.AutoTuneFile == "" {
expectedStorage.AutoTuneFile = "auto-tune.json"
}
if expectedStorage.WarmModelsFile == "" {
expectedStorage.WarmModelsFile = "warm-models.json"
}
if expectedStorage.AlertsFile == "" {
expectedStorage.AlertsFile = "alerts.json"
}
if expectedStorage.ConversationsFile == "" {
expectedStorage.ConversationsFile = "conversations.enc.json"
}
if expectedStorage.BatchJobsFile == "" {
expectedStorage.BatchJobsFile = "batch-jobs.json"
}
if expectedStorage.BatchJobsDir == "" {
expectedStorage.BatchJobsDir = "batch"
}
if candidate.Storage != expectedStorage {
proxy.WriteJSONError(w, 400, "storage_bootstrap_only", "storage settings are bootstrap-only; change them in the startup config")
return
}
if err := candidate.Validate(); err != nil {
proxy.WriteJSONError(w, 400, "bad_config", err.Error())
return
}
if err := s.configStore.Save(candidate); err != nil {
proxy.WriteJSONError(w, 503, "config_store", err.Error())
return
}
s.log.Info("persistent configuration saved", "path", s.configStore.Path(), "admin_subject", actor.Subject, "admin_auth_type", actor.AuthType)
writeJSON(w, 200, map[string]any{"saved": true, "path": s.configStore.Path(), "restart_required": true, "message": "Configuration persisted. Restart the gateway to activate all changes."})
}
func (s *Server) uiConfigReset(w http.ResponseWriter, r *http.Request, actor auth.Identity) {
if s.configStore == nil {
proxy.WriteJSONError(w, 503, "config_store", "persistent configuration store unavailable")
return
}
if err := s.configStore.Delete(); err != nil {
proxy.WriteJSONError(w, 503, "config_store", err.Error())
return
}
s.log.Info("persistent configuration override deleted", "path", s.configStore.Path(), "admin_subject", actor.Subject, "admin_auth_type", actor.AuthType)
writeJSON(w, 200, map[string]any{"deleted": true, "restart_required": true, "message": "Persistent override removed. Restart to return to the bootstrap configuration."})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}