feat(keys): role-based gateway keys with admin management UI and per-user model scope

This commit is contained in:
root
2026-08-09 10:01:40 +08:00
parent dec03238dd
commit 3408c9cb1f
10 changed files with 901 additions and 82 deletions

View File

@ -15,6 +15,7 @@ import (
"net/url"
"strings"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
@ -23,28 +24,20 @@ var uiFS embed.FS
// Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI.
type Gateway struct {
core *core.Core
apiKeys map[string]bool
ui http.Handler
stats *Stats
core *core.Core
ui http.Handler
stats *Stats
}
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
keys := map[string]bool{}
for _, k := range gatewayKeys {
if k != "" {
keys[k] = true
}
}
sub, err := fs.Sub(uiFS, "ui")
if err != nil {
return nil, err
}
return &Gateway{
core: c,
apiKeys: keys,
ui: http.FileServer(http.FS(sub)),
stats: NewStats(3000),
core: c,
ui: http.FileServer(http.FS(sub)),
stats: NewStats(3000),
}, nil
}
@ -77,6 +70,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleStatusAPI(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 == "/login":
g.handleLogin(w, r)
case r.URL.Path == "/api/login":
@ -105,10 +100,6 @@ func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) {
func (g *Gateway) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(g.apiKeys) == 0 {
next.ServeHTTP(w, r)
return
}
key := ""
if h := r.Header.Get("Authorization"); h != "" {
parts := strings.SplitN(h, " ", 2)
@ -124,7 +115,8 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
key = c.Value
}
}
if !g.apiKeys[key] {
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
@ -133,21 +125,36 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
next.ServeHTTP(w, r.WithContext(withKey(r.Context(), key)))
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role)))
})
}
// keyCtxKey is the context key carrying the authenticated gateway key.
type keyCtxKey struct{}
// authCtx carries the authenticated gateway key and its role.
type authCtx struct {
key string
role string
}
func withKey(ctx context.Context, key string) context.Context {
return context.WithValue(ctx, keyCtxKey{}, key)
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 {
k, _ := ctx.Value(keyCtxKey{}).(string)
return k
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).
@ -232,9 +239,9 @@ 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;
function apply(){var t=I[LANG]||I.zh;document.querySelector('[data-i=title]').textContent=t.title;
document.querySelector('[data-i=sub]').textContent=t.sub;
document.querySelector('[data-i=ph]').placeholder=t.ph;
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';}
@ -265,7 +272,7 @@ func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json")
return
}
if !g.apiKeys[body.Key] {
if _, ok := g.core.FindKey(body.Key); !ok {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
return
}
@ -286,6 +293,9 @@ func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
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"`
@ -308,9 +318,13 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
keys := make([]string, 0, len(g.apiKeys))
for k := range g.apiKeys {
keys = append(keys, k)
ks := make([]config.GWKey, 0, len(g.core.ListKeys()))
for _, k := range g.core.ListKeys() {
ks = append(ks, config.GWKey{
Key: k.Key,
Role: k.Role,
Name: k.Name,
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"default_model": g.core.DefaultModel(),
@ -318,7 +332,7 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
"sources": g.core.Registry().Status(),
"adapters": g.core.ListAdapters(),
"base_url": "http://" + host + "/v1",
"gateway_keys": keys,
"gateway_keys": ks,
})
}