// Package gateway exposes an OpenAI-compatible HTTP API over the provider // registry: POST /v1/chat/completions (SDK + SSE), POST /v1/images/generations, // GET /v1/models, protected by shared gateway API keys, plus a web UI and // management API for adapters and sources. package gateway import ( "context" "embed" "encoding/json" "fmt" "io/fs" "log" "net" "net/http" "net/url" "strings" "sync" "time" "llmsproxy/internal/config" "llmsproxy/internal/core" ) //go:embed ui/* var uiFS embed.FS // Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI. type Gateway struct { core *core.Core ui http.Handler stats *Stats probeMu sync.Mutex lastProbe time.Time // loginGuard throttles /api/login brute-force attempts: per-IP failure // counters with exponential backoff, plus a global cap so a distributed // spray cannot outrun the per-IP window. Guarded by loginMu. loginMu sync.Mutex loginFails map[string]*loginFail loginWin int64 // unix sec of current global window start loginGlob int // failures in the global window } // loginFail tracks consecutive failed logins from one source IP. type loginFail struct { count int until int64 // locked-out until this unix second (0 = not locked) lastAt int64 } const ( loginMaxFails = 5 // failures before lockout kicks in loginLockBase = 30 // first lockout: 30s loginLockCap = 30 * 60 // repeated lockouts cap at 30min loginDecay = 15 * 60 // counters decay after 15min of quiet loginGlobalMax = 100 // global failures per minute across all IPs loginGlobalWindow = 60 // global window length (sec) ) // loginAllow checks (and records) a login attempt for ip. It returns false // when the attempt must be rejected: the IP is in exponential lockout or the // global failure budget for the current window is exhausted. func (g *Gateway) loginAllow(ip string, now int64) bool { g.loginMu.Lock() defer g.loginMu.Unlock() if g.loginFails == nil { g.loginFails = map[string]*loginFail{} } // global window rollover if now-g.loginWin >= loginGlobalWindow { g.loginWin = now g.loginGlob = 0 } f := g.loginFails[ip] if f != nil && f.count > 0 && now-f.lastAt > loginDecay { delete(g.loginFails, ip) // quiet long enough: forgive and forget f = nil } if f != nil && f.until > now { return false // still locked out } if g.loginGlob >= loginGlobalMax { return false // global spray budget exhausted for this window } g.loginGlob++ return true } // loginRecord notes the outcome of one attempt: failures escalate toward an // exponential lockout; a success clears the IP's counter entirely. func (g *Gateway) loginRecord(ip string, ok bool, now int64) { g.loginMu.Lock() defer g.loginMu.Unlock() if ok { if g.loginFails != nil { delete(g.loginFails, ip) } return } f := g.loginFails[ip] if f == nil { f = &loginFail{} g.loginFails[ip] = f } f.count++ f.lastAt = now if f.count >= loginMaxFails { // lock duration doubles with each extra burst of failures, capped epoch := int64(f.count/loginMaxFails) - 1 dur := int64(loginLockBase) << min(epoch, 6) if dur > loginLockCap { dur = loginLockCap } f.until = now + dur } } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { sub, err := fs.Sub(uiFS, "ui") if err != nil { return nil, err } // The record ring is deliberately small: aggregates are built from the full // audit history at startup (see Stats.LoadAudit), so the ring only has to // cover the status page's 5-minute source windows and the dashboard's first // screen. Everything older is paged from disk by /api/stats/records. // A large ring here would put the ~25 MB of resident Req structs straight // back, which is exactly what the on-demand paging removed. st := NewStats(defaultRingSize) if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" { st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl") } return &Gateway{ core: c, ui: http.FileServer(http.FS(sub)), stats: st, }, nil } func (g *Gateway) Handler() http.Handler { // access log wraps all requests (auth, unauthenticated, login). inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // login entry point + login/logout API are the only unauthenticated routes if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" { g.routes(w, r) return } g.auth(http.HandlerFunc(g.routes)).ServeHTTP(w, r) }) return g.logAccess(inner) } // logAccess wraps an http.Handler so every response is written to the audit // file as an access event (method, path, status, latency, key). func (g *Gateway) logAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t0 := time.Now() sr := &statusRecorder{ResponseWriter: w} key := "" if h := r.Header.Get("Authorization"); h != "" { parts := strings.SplitN(h, " ", 2) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { key = parts[1] } } if key == "" { key = r.URL.Query().Get("api_key") } if key == "" { if c, err := r.Cookie("gw_key"); err == nil { key = c.Value } } next.ServeHTTP(sr, r) g.stats.AppendAudit("access", map[string]interface{}{ "method": r.Method, "path": r.URL.RequestURI(), "status": sr.code, "lat_ms": time.Since(t0).Milliseconds(), "key": keyID(key), }) }) } type statusRecorder struct { http.ResponseWriter code int } func (s *statusRecorder) WriteHeader(code int) { if s.code == 0 { s.code = code } s.ResponseWriter.WriteHeader(code) } func (s *statusRecorder) Write(b []byte) (int, error) { if s.code == 0 { s.code = http.StatusOK } return s.ResponseWriter.Write(b) } // Flush forwards to the underlying writer so SSE handlers can stream // incrementally through the access-log wrapper. Without this method the // w.(http.Flusher) assertion inside the streaming chat handlers fails (the // embedded ResponseWriter interface does not carry Flush into the method set), // and every chunk stays buffered until the response ends. func (s *statusRecorder) Flush() { if f, ok := s.ResponseWriter.(http.Flusher); ok { f.Flush() } } func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/v1/chat/completions": g.handleChat(w, r) case r.URL.Path == "/v1/images/generations": g.handleImage(w, r) case r.URL.Path == "/v1/models": g.handleModels(w, r) case r.URL.Path == "/api/adapters" || strings.HasPrefix(r.URL.Path, "/api/adapters/"): g.handleAdaptersAPI(w, r) case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"): g.handleSourcesAPI(w, r) case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"): g.handleSourceTemplatesAPI(w, r) case r.URL.Path == "/api/chat": g.handleChat(w, r) case r.URL.Path == "/api/status": g.handleStatusAPI(w, r) case r.URL.Path == "/api/status/reset": g.handleResetHealth(w, r) case r.URL.Path == "/api/stats/records": g.handleStatsRecordsAPI(w, r) case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"): g.handleStatsAPI(w, r) case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"): g.handleKeysAPI(w, r) case r.URL.Path == "/api/auto": g.handleAutoAPI(w, r) case r.URL.Path == "/login": g.handleLogin(w, r) case r.URL.Path == "/api/login": g.handleLoginAPI(w, r) case r.URL.Path == "/api/logout": g.handleLogoutAPI(w, r) default: g.serveUI(w, r) } } func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) { // serve index.html directly for the root path (FileServer would 301 it) if r.URL.Path == "/" || r.URL.Path == "/ui" { data, err := uiFS.ReadFile("ui/index.html") if err != nil { http.Error(w, "ui missing", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Write(data) return } g.ui.ServeHTTP(w, r) } func (g *Gateway) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := "" if h := r.Header.Get("Authorization"); h != "" { parts := strings.SplitN(h, " ", 2) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { key = parts[1] } } if key == "" { key = r.URL.Query().Get("api_key") } if key == "" { if c, err := r.Cookie("gw_key"); err == nil { key = c.Value } } rec, ok := g.core.FindKey(key) if !ok { if isAPIPath(r.URL.Path) { writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key") return } // browser navigation to UI pages -> login page http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound) return } next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role))) }) } // authCtx carries the authenticated gateway key and its role. type authCtx struct { key string role string } type authCtxKeyT struct{} func withAuth(ctx context.Context, key, role string) context.Context { return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role}) } // reqKey returns the authenticated gateway key id (masked suffix for display). func reqKey(ctx context.Context) string { if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { return a.key } return "" } // reqRole returns the authenticated key role ("admin" or "user"). func reqRole(ctx context.Context) string { if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { return a.role } return "user" } // keyID returns a short stable id for a gateway key (last 6 chars). func keyID(k string) string { if len(k) <= 6 { return k } return "***" + k[len(k)-6:] } // isAPIPath reports whether the request targets a JSON API endpoint that // should answer 401 instead of redirecting to the login page. func isAPIPath(p string) bool { return strings.HasPrefix(p, "/v1/") || strings.HasPrefix(p, "/api/") } // handleLogoutAPI clears the session cookie and redirects to the login page. func (g *Gateway) handleLogoutAPI(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "gw_key", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) http.Redirect(w, r, "/login", http.StatusFound) } // handleLogin serves the login page (unauthenticated). Supports a // ?continue= path to return to after a successful login and a ?lang=zh|en // toggle for i18n. func (g *Gateway) handleLogin(w http.ResponseWriter, r *http.Request) { continuePath := r.URL.Query().Get("continue") if continuePath == "" || !strings.HasPrefix(continuePath, "/") || strings.HasPrefix(continuePath, "//") { continuePath = "/" } lang := strings.ToLower(r.URL.Query().Get("lang")) if lang != "en" { lang = "zh" } // escape for embedding in HTML attribute and single-quoted JS string htmlCont := strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", ).Replace(continuePath) jsCont := strings.NewReplacer("\\", "\\\\", "'", "\\'", "\n", "\\n").Replace(continuePath) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, loginPageHTML, htmlCont, jsCont, lang) } const loginPageHTML = `