mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。
UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。
覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。
过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。
角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。
不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。
有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。
插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。
roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。
**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。
写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。
readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。
readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。
去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。
生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
(cherry picked from commit 5eef5ad635)
678 lines
23 KiB
Go
678 lines
23 KiB
Go
// 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) (*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/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"):
|
|
g.apiV1Routes(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/ui-inject" || strings.HasPrefix(r.URL.Path, "/api/ui-inject/"):
|
|
g.handlePluginUI(w, r)
|
|
case r.URL.Path == "/api/plugins/billing/rules":
|
|
// Checked BEFORE the generic /api/plugins/ prefix: the plugin router
|
|
// treats anything after the name as a plugin sub-resource and would
|
|
// 404 on "rules" instead of reaching the billing editor.
|
|
g.handleBillingRules(w, r)
|
|
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
|
|
// Before the generic plugin router: these paths belong to a PLUGIN, not to
|
|
// the plugin manager, and "http" is a valid sub-resource name otherwise.
|
|
g.handlePluginHTTP(w, r)
|
|
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
|
|
g.handlePluginsAPI(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(withAuthNamed(r.Context(), key, rec.Role, rec.Name)))
|
|
})
|
|
}
|
|
|
|
// authCtx carries the authenticated gateway key, its role, and the key's
|
|
// configured NAME.
|
|
//
|
|
// The name is here because plugin HTTP endpoints authorise by key name rather
|
|
// than by secret, and looking the name up from the key at the point of use would
|
|
// mean reading config on every request. It is "" for a key configured without
|
|
// one, which such an endpoint can never match.
|
|
type authCtx struct {
|
|
key string
|
|
role string
|
|
name string
|
|
}
|
|
|
|
type authCtxKeyT struct{}
|
|
|
|
func withAuth(ctx context.Context, key, role string) context.Context {
|
|
return withAuthNamed(ctx, key, role, "")
|
|
}
|
|
|
|
// withAuthNamed is withAuth plus the key's configured name.
|
|
func withAuthNamed(ctx context.Context, key, role, name string) context.Context {
|
|
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name})
|
|
}
|
|
|
|
// reqKeyName returns the authenticated key's configured name, or "" if it has
|
|
// none. Plugin endpoint authorisation is written against this, not against the
|
|
// secret: a secret is rotated without notice, so a rule keyed on it would
|
|
// silently stop matching.
|
|
func reqKeyName(ctx context.Context) string {
|
|
if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok {
|
|
return a.name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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 = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>登录 · ModelRouter</title>
|
|
<style>
|
|
:root{--bg:#f4f6fb;--card:#ffffff;--line:#e2e6ef;--fg:#1c2333;--muted:#6b7390;--accent:#3f6ef5;--accent-h:#2f5ae0;--err:#e5484d}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;background:linear-gradient(160deg,#f4f6fb 0%%,#e8edf8 100%%);color:var(--fg);display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:36px 34px;width:340px;box-shadow:0 12px 40px rgba(31,45,90,.10)}
|
|
.brand{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
|
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#3f6ef5,#6a8ffb);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px}
|
|
h1{font-size:18px;margin:0;letter-spacing:.2px}
|
|
.sub{color:var(--muted);font-size:13px;margin:4px 0 22px}
|
|
label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
|
|
input{width:100%%;background:#fbfcfe;border:1px solid var(--line);border-radius:8px;padding:10px 12px;font:inherit;color:var(--fg);outline:none;transition:border .15s,box-shadow .15s}
|
|
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(63,110,245,.15)}
|
|
button{margin-top:16px;width:100%%;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:11px;font:inherit;font-weight:600;cursor:pointer;transition:background .15s}
|
|
button:hover{background:var(--accent-h)}
|
|
#msg{color:var(--err);margin-top:12px;min-height:18px;font-size:13px}
|
|
.foot{margin-top:18px;text-align:center;font-size:12px;color:var(--muted)}
|
|
.foot a{color:var(--muted);text-decoration:none;border-bottom:1px dashed var(--line)}
|
|
</style></head>
|
|
<body><div class="card">
|
|
<div class="brand"><div class="logo">M</div><div>
|
|
<h1 data-i="title">ModelRouter</h1>
|
|
<div class="sub" data-i="sub">统一 LLM 网关 · 登录</div></div></div>
|
|
<input id="key" type="password" data-i-ph="ph" placeholder="Gateway API Key" autocomplete="off">
|
|
<button onclick="login()" data-i="btn">登录</button>
|
|
<div id="msg"></div>
|
|
<div class="foot"><a data-i="lang" href="?lang=en&continue=%[1]s">English</a></div>
|
|
</div>
|
|
<script>
|
|
var LANG='%[3]s';
|
|
var CONT='%[2]s';
|
|
var I={zh:{title:'ModelRouter',sub:'统一 LLM 网关 · 登录',ph:'输入网关 API Key',btn:'登录',lang:'English'},
|
|
en:{title:'ModelRouter',sub:'Unified LLM Gateway · Sign in',ph:'Enter gateway API key',btn:'Sign in',lang:'中文'}};
|
|
function apply(){var t=I[LANG]||I.zh;document.querySelector('[data-i=title]').textContent=t.title;
|
|
document.querySelector('[data-i=sub]').textContent=t.sub;
|
|
var ph=document.querySelector('#key');if(ph)ph.placeholder=t.ph;
|
|
document.querySelector('[data-i=btn]').textContent=t.btn;
|
|
document.querySelector('[data-i=lang]').textContent=t.lang;
|
|
document.documentElement.lang=LANG==='zh'?'zh':'en';}
|
|
document.querySelector('[data-i=lang]').onclick=function(e){e.preventDefault();
|
|
location.href='/login?lang='+(LANG==='zh'?'en':'zh')+'&continue='+encodeURIComponent(CONT);};
|
|
async function login(){
|
|
var key=document.getElementById('key').value.trim();
|
|
if(!key){document.getElementById('msg').textContent=(LANG==='zh'?'请输入 Key':'Enter a key');return;}
|
|
var r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
|
|
var j=await r.json().catch(function(){return{};});
|
|
if(r.ok){location.href=CONT||'/';}
|
|
else{document.getElementById('msg').textContent=(j.error&&j.error.message)||(LANG==='zh'?'登录失败':'Login failed');}
|
|
}
|
|
document.getElementById('key').addEventListener('keydown',function(e){if(e.key==='Enter')login();});
|
|
apply();
|
|
</script></body></html>`
|
|
|
|
// handleLoginAPI validates the gateway key and issues a session cookie,
|
|
// guarded by per-IP + global brute-force throttling (see loginGuard).
|
|
func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
|
return
|
|
}
|
|
now := time.Now().Unix()
|
|
if !g.loginAllow(clientIP(r), now) {
|
|
// Don't reveal whether the key was right: answer the same 401 the
|
|
// failure path uses, but skip the expensive key lookup.
|
|
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
|
|
return
|
|
}
|
|
var body struct {
|
|
Key string `json:"key"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
g.loginRecord(clientIP(r), false, now)
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json")
|
|
return
|
|
}
|
|
if _, ok := g.core.FindKey(body.Key); !ok {
|
|
g.loginRecord(clientIP(r), false, now)
|
|
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
|
|
return
|
|
}
|
|
g.loginRecord(clientIP(r), true, now)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "gw_key",
|
|
Value: body.Key,
|
|
Path: "/",
|
|
MaxAge: 86400 * 30,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
// clientIP returns the caller's IP for rate limiting. Behind a trusted
|
|
// reverse proxy (nginx) the X-Real-IP header is preferred; otherwise the
|
|
// remote address is used with the port stripped.
|
|
func clientIP(r *http.Request) string {
|
|
if x := r.Header.Get("X-Real-IP"); x != "" {
|
|
return x
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
models := g.core.Registry().ModelList()
|
|
if allow := g.allowedModels(r.Context()); allow != nil {
|
|
models = intersectModels(models, allow)
|
|
}
|
|
type modelObj struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
}
|
|
objs := make([]modelObj, 0, len(models))
|
|
for _, m := range models {
|
|
objs = append(objs, modelObj{ID: m, Object: "model"})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"object": "list",
|
|
"data": objs,
|
|
})
|
|
}
|
|
|
|
// ensureProbe triggers a live source probe at most once every 30s.
|
|
// The probe runs asynchronously so a slow/stuck upstream never blocks the
|
|
// status response — reachability data is eventually-consistent and the UI
|
|
// polls /api/stats every 3s anyway, so the next refresh picks it up.
|
|
func (g *Gateway) ensureProbe(ctx context.Context) {
|
|
g.probeMu.Lock()
|
|
due := time.Since(g.lastProbe) > 30*time.Second
|
|
if due {
|
|
g.lastProbe = time.Now()
|
|
}
|
|
g.probeMu.Unlock()
|
|
if !due {
|
|
return
|
|
}
|
|
go func() {
|
|
probeCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
g.core.Registry().ProbeAll(probeCtx)
|
|
}()
|
|
}
|
|
|
|
// handleResetHealth (admin) clears the per-source backoff state so a fixed
|
|
// upstream or an edited AUTO priority chain becomes schedulable immediately.
|
|
func (g *Gateway) handleResetHealth(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
|
return
|
|
}
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
g.core.ResetHealth()
|
|
g.stats.AppendAudit("config", map[string]interface{}{"action": "reset_health", "key": keyID(reqKey(r.Context()))})
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|
g.ensureProbe(r.Context())
|
|
host := r.Host
|
|
if host == "" {
|
|
host = g.core.Listen()
|
|
}
|
|
if strings.HasPrefix(host, ":") {
|
|
host = "127.0.0.1" + host
|
|
}
|
|
// gateway_keys: return only the current authenticated user's key so the
|
|
// connection snippet on the home page always shows the correct key
|
|
// (previously every user saw the first admin key in the list).
|
|
myKey := reqKey(r.Context())
|
|
ks := make([]config.GWKey, 0, len(g.core.ListKeys()))
|
|
for _, k := range g.core.ListKeys() {
|
|
if k.Key == myKey {
|
|
ks = append(ks, config.GWKey{
|
|
Key: k.Key,
|
|
Role: k.Role,
|
|
Name: k.Name,
|
|
})
|
|
break
|
|
}
|
|
}
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
baseURL := g.core.PublicBaseURL()
|
|
if baseURL == "" {
|
|
baseURL = scheme + "://" + host + "/v1"
|
|
}
|
|
models := g.core.Registry().ModelList()
|
|
if reqRole(r.Context()) != "admin" {
|
|
models = g.scopedModelList(r.Context(), models)
|
|
}
|
|
resp := map[string]interface{}{
|
|
"default_model": g.core.DefaultModel(),
|
|
"models": models,
|
|
"base_url": baseURL,
|
|
"gateway_keys": ks,
|
|
}
|
|
if reqRole(r.Context()) == "admin" {
|
|
sts := g.core.Registry().Status()
|
|
recent := g.stats.SourceRecent(300)
|
|
avgs := g.stats.SourceAverages(300)
|
|
for i := range sts {
|
|
if v, ok := recent[sts[i].Name]; ok {
|
|
sts[i].RecentOK = v[0]
|
|
sts[i].RecentErr = v[1]
|
|
}
|
|
if a, ok := avgs[sts[i].Name]; ok {
|
|
sts[i].AvgFirstByteMs = a.AvgFirstByteMs
|
|
sts[i].AvgTokPerS = a.AvgTokPerS
|
|
}
|
|
}
|
|
resp["sources"] = sts
|
|
resp["adapters"] = g.core.ListAdapters()
|
|
// Elastic Lua pool sizing: created/idle/in_use vs the ceiling, plus the
|
|
// current grow/shrink steps, so the algorithm is inspectable in the UI
|
|
// instead of being a black box.
|
|
resp["adapter_pools"] = g.core.VM().PoolStats()
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// scopedModelList filters the full model set to only those allowed by the
|
|
// request's gateway key (used for users with a restricted model scope).
|
|
func (g *Gateway) scopedModelList(ctx context.Context, full []string) []string {
|
|
allow := g.allowedModels(ctx)
|
|
if allow == nil {
|
|
return full
|
|
}
|
|
out := make([]string, 0, len(allow))
|
|
for _, m := range allow {
|
|
if m.Model == "" {
|
|
continue
|
|
}
|
|
out = append(out, m.Model)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, errType, msg string) {
|
|
writeJSON(w, code, map[string]interface{}{
|
|
"error": map[string]interface{}{"type": errType, "message": msg},
|
|
})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("[gateway] write json: %v", err)
|
|
}
|
|
}
|