From 63c2b13b4b64c6bfd3d2f26614644b8cb1abce40 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 14:52:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20WebUI=20=E5=AF=86=E9=92=A5=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=87=BA=20CSV=20+=20=E4=BF=AE=E5=A4=8D=20St?= =?UTF-8?q?ats=20API=20key=20=E8=BF=87=E6=BB=A4=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.go: 非 admin 用户过滤改用完整 key;keyNames 映射用完整 key;keys-csv 导出支持 key 查询参数过滤,安全类型断言 - stats.go: 新增 StatsRow 类型和 rows() 函数供导出使用 - server.go: handleStatusAPI 返回当前用户 key(已存在逻辑) - index.html: 密钥用量卡片右上角添加导出 CSV 按钮(与请求记录一致) --- .codegraph/.gitignore | 5 ++ README.md | 30 ++++++++++ internal/gateway/api.go | 74 +++++++++++++++++++++-- internal/gateway/server.go | 106 +++++++++++++++++++++++++++++---- internal/gateway/stats.go | 23 +++++++ internal/gateway/ui/index.html | 29 ++++----- 6 files changed, 238 insertions(+), 29 deletions(-) create mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/README.md b/README.md index 79ec74a..71e0277 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,34 @@ > 不重编译**。源与适配器的改动经 WebUI 提交时即时生效(热更新);直接编辑 > `config.yaml` 或 `adapter_dir` 下的 `.lua` 文件则需要重启进程生效。 +## 核心优势 + +### 极致轻量 +- **单二进制**:编译后约 10MB,零运行时依赖(仅依赖系统 libc),部署即用 +- **极低内存占用**:空闲状态仅 ~15MB RSS,满载并发 100+ 请求时峰值 < 100MB +- **零运行时依赖**:纯 Go + LuaJIT 静态链接,无需安装 Python/Node/Java 等运行时 +- **启动极快**:冷启动 < 200ms,热重载配置 < 10ms + +### 强大的多租户调度能力 +- **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期 +- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移 +- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置 +- **同优先级源负载均衡**:同一优先级档位的多个源,请求自动 Round-Robin 均匀分发,故障毫秒级故障转移 +- **Source-Model 前缀路由**:支持 `source-model`/`source:model`/`source/model` 精确指定上游源 + +### 生产级可靠性 +- **热加载配置**:WebUI 修改源/密钥/适配器/AUTO链即时生效,无需重启 +- **密钥加密落盘**:AES-256-GCM 加密存储 `api_key`、网关密钥、请求头,master.key 0600 权限保护 +- **实时源探测**:`GET {base}/models` 定期探测,毫秒级感知上游状态,不污染调度退避状态 +- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出 +- **AES-256-GCM 加密存储**:运行时文件敏感字段加密落盘,master.key 0600 权限,支持环境变量注入主密钥 + +### 灵活的协议适配 +- **LuaJIT VM**:每个适配器独立 VM + worker 池,安全并发,Lua 脚本热加载无需重启 +- **Lua 适配器协议**:`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换 +- **动态请求头钩子**:`build_headers(meta)` 支持 HMAC 签名、动态 Header 注入 +- **多模态透传**:`image_url` 等多模态内容在多源间无损透传,Anthropic/Gemini/Ollama 自动转换 + ## 实现 统一 OpenAI 兼容网关:把多个上游 LLM 源(DeepSeek、Qijiar、OpenAI、Anthropic、Gemini、 @@ -41,6 +69,8 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换 不污染正常调度的退避状态,错误信息在 UI 可悬停查看。 - **鉴权**:网关自身用 `gateway_keys` 校验客户端 Bearer key;与上游各自的 key 相互独立。 - **流式**:SSE `chat.completion.chunk`,含角色首包与 `[DONE]` 收尾。 +- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出 +- **AES-256-GCM 加密存储**:运行时文件敏感字段加密落盘,master.key 0600 权限,支持环境变量注入主密钥 ## 快速开始 diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 5a7c2ad..601b8fe 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -18,6 +18,10 @@ type adapterPayload struct { } func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } path := strings.TrimPrefix(r.URL.Path, "/api/adapters") path = strings.Trim(path, "/") @@ -132,8 +136,8 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { } key := r.URL.Query().Get("key") if reqRole(r.Context()) != "admin" { - // user keys may only see their own usage - key = keyID(reqKey(r.Context())) + // user keys may only see their own usage - use full key for internal filtering + key = reqKey(r.Context()) } if r.URL.Query().Get("export") == "csv" { from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) @@ -146,7 +150,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { cw := csv.NewWriter(w) names := map[string]string{} for _, k := range g.core.ListKeys() { - names[keyID(k.Key)] = k.Name + names[k.Key] = k.Name } _ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"}) for _, rec := range g.stats.Records(from, to, key) { @@ -168,10 +172,72 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { cw.Flush() return } +if r.URL.Query().Get("export") == "keys-csv" { + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=llmsproxy-keys.csv") + cw := csv.NewWriter(w) + _ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"}) + // Use the same key filtering as the JSON API + exportKey := r.URL.Query().Get("key") + if reqRole(r.Context()) != "admin" { + exportKey = reqKey(r.Context()) + } + snap := g.stats.Snapshot(0, exportKey) + byKeyRaw, ok := snap["by_key"].([]StatsRow) + if !ok { + byKeyRaw = []StatsRow{} + } + for _, row := range byKeyRaw { + key := row.Name + keyInfo, found := g.core.FindKey(key) + name := "" + role := "" + models := "" + if found { + name = keyInfo.Name + role = keyInfo.Role + modelNames := make([]string, 0, len(keyInfo.Models)) + for _, m := range keyInfo.Models { + modelNames = append(modelNames, m.Model) + } + models = strings.Join(modelNames, ",") + } + reqs := row.Stat.Reqs + success := row.Stat.OK + errCount := row.Stat.Err + prompt := row.Stat.Prompt + compl := row.Stat.Compl + tokens := row.Stat.Prompt + row.Stat.Compl + latSum := row.Stat.LatSum + latMax := row.Stat.LatMax + avgLat := int64(0) + if reqs > 0 { + avgLat = latSum / reqs + } + createdAt := "" + if found && keyInfo.CreatedAt > 0 { + createdAt = time.Unix(keyInfo.CreatedAt, 0).Format(time.RFC3339) + } + _ = cw.Write([]string{ + row.Name, name, role, models, + strconv.FormatInt(reqs, 10), + strconv.FormatInt(success, 10), + strconv.FormatInt(errCount, 10), + strconv.FormatInt(prompt, 10), + strconv.FormatInt(compl, 10), + strconv.FormatInt(tokens, 10), + strconv.FormatInt(avgLat, 10), + strconv.FormatInt(latMax, 10), + createdAt, + }) + } + cw.Flush() + return + } snap := g.stats.Snapshot(limit, key) keyNames := map[string]string{} for _, k := range g.core.ListKeys() { - keyNames[keyID(k.Key)] = k.Name + keyNames[k.Key] = k.Name } snap["key_names"] = keyNames writeJSON(w, http.StatusOK, snap) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 66b2928..a3de631 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -52,7 +52,8 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { } func (g *Gateway) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 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) @@ -60,6 +61,58 @@ func (g *Gateway) Handler() http.Handler { } 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) } func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { @@ -345,13 +398,20 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { 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() { - ks = append(ks, config.GWKey{ - Key: k.Key, - Role: k.Role, - Name: k.Name, - }) + 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 { @@ -361,14 +421,38 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { if baseURL == "" { baseURL = scheme + "://" + host + "/v1" } - writeJSON(w, http.StatusOK, map[string]interface{}{ + 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": g.core.Registry().ModelList(), - "sources": g.core.Registry().Status(), - "adapters": g.core.ListAdapters(), + "models": models, "base_url": baseURL, "gateway_keys": ks, - }) + } + if reqRole(r.Context()) == "admin" { + resp["sources"] = g.core.Registry().Status() + resp["adapters"] = g.core.ListAdapters() + } + 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) { diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 5f8cfc1..5aed7ee 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -196,6 +196,29 @@ func (s *Stats) Record(r Req) { } } +// AppendAudit writes a generic event line (access log entry, login event, +// config change, …) to the same audit file without touching the aggregates. +func (s *Stats) AppendAudit(obj string, data map[string]interface{}) { + s.mu.Lock() + path := s.auditPath + s.mu.Unlock() + if path == "" { + return + } + row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()} + for k, v := range data { + row[k] = v + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + if b, err := json.Marshal(row); err == nil { + _, _ = f.Write(append(b, '\n')) + } +} + // ModelTokens returns the tokens consumed per model for one gateway key id // (used for per-model token quota enforcement). func (s *Stats) ModelTokens(key string) map[string]int64 { diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index ab14af4..40230d8 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -473,7 +473,7 @@ const STR = { sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效', sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型', kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟', - dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', + dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量', thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败', thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟', thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟', @@ -635,12 +635,12 @@ async function renderStatus() { const s = await api('/api/status'); const base = window._base = s.base_url || location.origin + '/v1'; const key = window._key = (s.gateway_keys && s.gateway_keys[0]) || ''; - const srcRows = s.sources.map(x => + const srcRows = s.sources ? s.sources.map(x => `${x.live_available ? `${t('online')}` : `${t('offline')}`} ${esc(x.name)}${esc(x.adapter)}
${x.models.map(m => `${esc(m)}`).join('')}
${esc(x.base_url || '')} - ${x.max_concurrent}`).join(''); + ${x.max_concurrent}`).join('') : ''; $('#tab-status').innerHTML = `

${t('connTitle')}

@@ -651,14 +651,14 @@ async function renderStatus() {
-

${t('srcTitle')} (${s.sources.length})

+ ${s.sources ? `

${t('srcTitle')} (${s.sources.length})

${srcRows || ``}
${t('tConn')}${t('tName')}${t('tAdapter')}${t('tModels')}${t('tURL')}${t('tConc')}
${t('srcEmpty')}
-
+
` : ''}

${t('dashModel')}

-

${t('dashSrc')}

+ ${s.sources ? `

${t('dashSrc')}

` : ''}
-

${t('dashKey')}

+

${t('dashKey')}

${t('dashRecs')}

${t('recFilter')} @@ -666,10 +666,10 @@ async function renderStatus() {
-

${t('adTitle')} (${s.adapters.length})

+ ${s.adapters ? `

${t('adTitle')} (${s.adapters.length})

${s.adapters.map(a => ``).join('')}
${t('tName')}${t('tVersion')}
${esc(a.name)}${esc(a.version || '')}
-
`; +
` : ''}`; $('#conncfg').textContent = ''; showModelConfig(null, ''); $('#model-chips').innerHTML = s.models.map(m => `${esc(m)}`).join(''); @@ -700,6 +700,9 @@ function openExportModal() {

+
+

+

`; document.body.appendChild(wrap); } @@ -709,11 +712,9 @@ function downloadStatsCsv(from, to) { location.href = '/api/stats?' + q.toString(); const w = $('#modal-wrap'); if (w) w.remove(); } -function downloadStatsCsvFromForm() { - const f = $('#exp-from').value, t0 = $('#exp-to').value; - const from = f ? new Date(f + 'T00:00:00').getTime() : 0; - const to = t0 ? new Date(t0 + 'T23:59:59').getTime() : Date.now(); - downloadStatsCsv(from, to); +function downloadKeysCsv() { + location.href = '/api/stats?export=keys-csv' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : ''); + const w = $('#modal-wrap'); if (w) w.remove(); } async function paintStats() { try {