From 70865a3cf43a8aeab38b2946ae380ea0e63b26f2 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 29 Aug 2026 21:53:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20M2=20WebUI=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0(=E9=89=B4=E6=9D=83+=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98+=E9=9F=B3=E8=89=B2=20CRUD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /admin 管理后台,Vue3 + axios 单文件 SPA。 新增: - middleware/admin_auth.go: RequireAdmin 中间件 复用 OPENAI_TTS_API_KEY(Bearer),密钥为空等同无鉴权; 常量时间比较防计时攻击(用 common.SecureEqualString); 401 + WWW-Authenticate 头 + JSON 错误体。 - controller/admin.go: 5 个 admin handler - GET /api/admin/overview: 模式/db路径/lock/版本/运行时间/音色数/内存 - GET /api/admin/metrics: 鉴权版 Prometheus 文本(无鉴权版 /metrics 仍给 Prometheus) - GET /api/voices: 列表(含 disabled) - POST /api/voices: 新增,409 处理重复,400 处理字段不合法 - DELETE /api/voices/{name}: 删除,409 处理被 default_speaker 引用 - PATCH /api/voices/{name}/toggle: 启停 - router/admin.html: 单文件 SPA(18KB) 登录页(单 input) + 仪表盘(metrics cards) + 音色 tab(表格+新增+启停+删除); hash 路由切 tab;axios 拦截器自动加 Bearer,401 回登录页; sessionStorage 存 key(关浏览器失效)。 - common/constants.go: SecureEqualString 提取到公共包 controller/setup.go + middleware/admin_auth.go 共用,避免重复实现。 改造: - router/router.go: 挂 /admin(/admin HTML 公开,鉴权由前端 JS 拦截) + /api/admin/{overview,metrics} + /api/voices{,/{name},/{name}/toggle} 全部 API 端点套 RequireAdmin;正常模式 / → 302 /admin(M2 优先于 /dashboard)。 - main.go: 启动期调 SetAdminStore + SetMetricsTextWriter(避免 controller → metrics cycle) - metrics/metrics.go: AdapterRecorder 用 SpeakerLabel 替代明文 speaker (这是第 4 处 speaker 泄漏,顺带补上;前面 3 处在 51bd7ae 已修)。 - controller/setup.go: 用 common.SecureEqualString 替自己的实现(去重)。 测试(都被 .gitignore 排除,本地保留): - router/router_test.go: 更新 4 个 NotFound 测试路径(/admin 现是注册路由,不再 404) - telemetry/labels_test.go: 已有 SpeakerLabel/MaskSpeaker 测试(M2 复用) e2e 验证(本机跑通): - /admin 200 HTML(无鉴权,前端 JS 拦截) - /api/admin/overview 无 Bearer 401 - /api/admin/overview 错 Bearer 401 - /api/admin/overview 对 Bearer 200 + JSON - /api/voices GET 200 + 2 voices - /api/voices POST 201 + new voice - /api/voices/{name} DELETE 200 - /api/voices/{name}/toggle PATCH 200 + updated voice - 正常模式 / 302 /admin - 启动日志 BYTEDANCE_TTS_SPEAKER 仍打码 S_G8****naJ1(前次 fix 生效) 二进制大小: 16MB → ~17MB(admin.html +5 个 handler + 中间件) 未 push(用户要求与 speaker fix 51bd7ae 一起发,等服务器测完 M1 后一起验证) --- common/constants.go | 19 +++ controller/admin.go | 301 ++++++++++++++++++++++++++++++++ controller/setup.go | 17 +- main.go | 7 + metrics/metrics.go | 6 +- middleware/admin_auth.go | 73 ++++++++ router/admin.html | 359 +++++++++++++++++++++++++++++++++++++++ router/router.go | 57 +++++-- 8 files changed, 806 insertions(+), 33 deletions(-) create mode 100644 controller/admin.go create mode 100644 middleware/admin_auth.go create mode 100644 router/admin.html diff --git a/common/constants.go b/common/constants.go index 1dd4846..c68c52b 100644 --- a/common/constants.go +++ b/common/constants.go @@ -20,3 +20,22 @@ const ( MaxModelNameLength = 64 MaxRateLimiterEntries = 100000 ) + +// SecureEqualString 是常量时间字符串比较,防 token 计时攻击。 +// 长度先比对(避免短串早返回时泄漏长度信息),再遍历每个字节做 XOR 累加; +// diff 为 0 才返 true。用于 Bearer token、setup token 等敏感比较场景。 +func SecureEqualString(a, b string) bool { + if len(a) != len(b) { + // 先比对长度(避免短串早返回时泄漏长度信息) + // 但仍要遍历一遍避免优化器消除分支 + if len(a) > 0 { + _ = a[0] + } + return false + } + var diff byte + for i := 0; i < len(a); i++ { + diff |= a[i] ^ b[i] + } + return diff == 0 +} diff --git a/controller/admin.go b/controller/admin.go new file mode 100644 index 0000000..490a006 --- /dev/null +++ b/controller/admin.go @@ -0,0 +1,301 @@ +package controller + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/mux" + "github.com/volcano-tts/tts-api/installer" + "github.com/volcano-tts/tts-api/middleware" + "github.com/volcano-tts/tts-api/store" + "github.com/volcano-tts/tts-api/version" +) + +// SetAdminStore 注入 admin 控制器需要的 store;main 启动期调一次。 +// store 可能在自愈回退后为 nil,GetAdminStore 返 nil 时 controller 应返 503。 +var adminStore *store.Store + +// SetAdminStore 在 main 启动期调,设置 admin 用的 store 句柄。 +func SetAdminStore(s *store.Store) { adminStore = s } + +// GetAdminStore admin 控制器用,获取已注入的 store;nil 表示服务在 setup 模式。 +func GetAdminStore() *store.Store { return adminStore } + +// metricsTextWriter 是 admin 端点写 Prometheus 文本的回调, +// 由 main 启动期注入(避免 controller → metrics → controller 循环)。 +type metricsTextWriter func(w http.ResponseWriter) error + +var ( + metricsTextWriterMu sync.RWMutex + metricsTextWriterFn metricsTextWriter +) + +// SetMetricsTextWriter 注入 Prometheus 文本写入函数;main 启动期调一次。 +func SetMetricsTextWriter(fn metricsTextWriter) { + metricsTextWriterMu.Lock() + metricsTextWriterFn = fn + metricsTextWriterMu.Unlock() +} + +// AdminOverviewResponse 是 GET /api/admin/overview 的响应体。 +type AdminOverviewResponse struct { + Mode string `json:"mode"` + Installed bool `json:"installed"` + DBPath string `json:"db_path"` + LockPath string `json:"lock_path"` + Version string `json:"version"` + Commit string `json:"commit"` + UptimeSeconds int64 `json:"uptime_seconds"` + StartTime string `json:"start_time"` + VoiceCount int `json:"voice_count"` + VoiceEnabledCount int `json:"voice_enabled_count"` + Memory map[string]interface{} `json:"memory"` +} + +// AdminOverviewHandler GET /api/admin/overview +// 鉴权: RequireAdmin;store 为 nil 时仍可服务,但 voice 字段为 0。 +func AdminOverviewHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + resp := AdminOverviewResponse{ + Mode: installer.GetMode().String(), + Installed: installer.GetMode() == installer.ModeNormal, + Version: version.Version, + Commit: version.Commit, + StartTime: startTime.Format(time.RFC3339), + UptimeSeconds: int64(time.Since(startTime).Seconds()), + Memory: collectMemorySnapshot(), + } + + if s := GetAdminStore(); s != nil { + if p, err := s.Path(); err == nil { + resp.DBPath = p + } + resp.LockPath = installer.LockPath(resp.DBPath) + if n, err := s.VoiceCount(); err == nil { + resp.VoiceCount = n + } + if n, err := s.VoiceCountEnabled(); err == nil { + resp.VoiceEnabledCount = n + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("[admin] overview encode failed: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "encode failed", "server_error", "encode_failed") + } +} + +// AdminMetricsHandler GET /api/admin/metrics +// 鉴权: RequireAdmin;返 Prometheus 文本。 +func AdminMetricsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + metricsTextWriterMu.RLock() + fn := metricsTextWriterFn + metricsTextWriterMu.RUnlock() + if fn == nil { + // 启动期没注入,返 503 + 提示(不应该发生) + middleware.SendJSONError(w, http.StatusServiceUnavailable, + "metrics writer not initialized", "configuration_error", "metrics_not_ready") + return + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + if err := fn(w); err != nil { + log.Printf("[admin] metrics write: %v", err) + } +} + +// AdminVoicesListResponse 是 GET /api/voices 的响应。 +type AdminVoicesListResponse struct { + Voices []store.Voice `json:"voices"` + Total int `json:"total"` +} + +// AdminVoicesListHandler GET /api/voices +// 鉴权: RequireAdmin;返所有 voice(包含 disabled)。 +func AdminVoicesListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + vs, err := s.VoiceList(true) + if err != nil { + log.Printf("[admin] voice list: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "list voices failed", "server_error", "db_read_failed") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(AdminVoicesListResponse{Voices: vs, Total: len(vs)}) +} + +// AdminVoiceCreateRequest 是 POST /api/voices 的 body。 +type AdminVoiceCreateRequest struct { + Name string `json:"name"` + Speaker string `json:"speaker"` + ResourceID string `json:"resource_id"` + Model string `json:"model"` + Language string `json:"language"` + Description string `json:"description"` + Enabled *bool `json:"enabled,omitempty"` +} + +// AdminVoiceCreateHandler POST /api/voices +// 鉴权: RequireAdmin;store nil 时 503。 +func AdminVoiceCreateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + var body AdminVoiceCreateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") + return + } + + enabled := true + if body.Enabled != nil { + enabled = *body.Enabled + } + + v := store.Voice{ + Name: body.Name, + Speaker: body.Speaker, + ResourceID: body.ResourceID, + Model: body.Model, + Language: body.Language, + Description: body.Description, + Enabled: enabled, + } + id, err := s.VoiceInsert(v) + if err != nil { + switch err { + case store.ErrDuplicate: + middleware.SendJSONError(w, http.StatusConflict, + fmt.Sprintf("voice name %q already exists", v.Name), + "invalid_request_error", "voice_duplicate") + default: + log.Printf("[admin] voice insert: %v", err) + middleware.SendJSONError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "voice_invalid") + } + return + } + created, _ := s.VoiceGet(id) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(created) +} + +// AdminVoiceDeleteHandler DELETE /api/voices/{name} +func AdminVoiceDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + name := mux.Vars(r)["name"] + if name == "" { + middleware.SendJSONError(w, http.StatusBadRequest, "missing voice name", "invalid_request_error", "bad_request") + return + } + + v, err := s.VoiceGetByName(name) + if err != nil { + if err == store.ErrNotFound { + http.Error(w, "voice not found", http.StatusNotFound) + return + } + log.Printf("[admin] voice lookup: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "lookup failed", "server_error", "db_read_failed") + return + } + if err := s.VoiceDelete(v.ID); err != nil { + switch err { + case store.ErrInUse: + middleware.SendJSONError(w, http.StatusConflict, + fmt.Sprintf("voice %q is referenced by default_speaker; remove the default first", name), + "invalid_request_error", "voice_in_use") + case store.ErrNotFound: + http.Error(w, "voice not found", http.StatusNotFound) + default: + log.Printf("[admin] voice delete: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "delete failed", "server_error", "db_write_failed") + } + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "deleted": name}) +} + +// AdminVoiceToggleRequest 是 PATCH /api/voices/{name}/toggle 的 body。 +type AdminVoiceToggleRequest struct { + Enabled bool `json:"enabled"` +} + +// AdminVoiceToggleHandler PATCH /api/voices/{name}/toggle +func AdminVoiceToggleHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + name := mux.Vars(r)["name"] + if name == "" { + middleware.SendJSONError(w, http.StatusBadRequest, "missing voice name", "invalid_request_error", "bad_request") + return + } + v, err := s.VoiceGetByName(name) + if err != nil { + if err == store.ErrNotFound { + http.Error(w, "voice not found", http.StatusNotFound) + return + } + log.Printf("[admin] voice lookup: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "lookup failed", "server_error", "db_read_failed") + return + } + var body AdminVoiceToggleRequest + r.Body = http.MaxBytesReader(w, r.Body, 1<<10) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") + return + } + if err := s.VoiceToggleEnabled(v.ID, body.Enabled); err != nil { + log.Printf("[admin] voice toggle: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "toggle failed", "server_error", "db_write_failed") + return + } + updated, _ := s.VoiceGet(v.ID) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(updated) +} diff --git a/controller/setup.go b/controller/setup.go index 3e79f7c..e758f7c 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/setting" @@ -262,17 +263,5 @@ func validateSetupVoices(vs []SetupVoice) error { return nil } -// secureEqualString 是常量时间字符串比较,防止 token 计时攻击。 -func secureEqualString(a, b string) bool { - if len(a) != len(b) { - // 先比对长度(避免短串早返回时泄漏长度信息) - // 但仍要遍历一遍避免优化器消除分支 - _ = a[0] - return false - } - var diff byte - for i := 0; i < len(a); i++ { - diff |= a[i] ^ b[i] - } - return diff == 0 -} +// secureEqualString wraps common.SecureEqualString 保持向后兼容(原文件内已有调用)。 +func secureEqualString(a, b string) bool { return common.SecureEqualString(a, b) } diff --git a/main.go b/main.go index 0cec4e8..2251362 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,12 @@ func main() { } // 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用) controller.SetSetupState(st, dbPath) + // 注入 admin 控制器需要的 store + 指标文本写入器(M2) + controller.SetAdminStore(st) + controller.SetMetricsTextWriter(func(w http.ResponseWriter) error { + metrics.Meter.Handler().ServeHTTP(w, &http.Request{}) + return nil + }) log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath) controller.InitController() @@ -75,6 +81,7 @@ func main() { log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server") log.Printf("Listening on port: %s", setting.Server.Port) log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port) + log.Printf("Admin WebUI: http://localhost:%s/admin", setting.Server.Port) } log.Printf("Health check: http://localhost:%s/health", setting.Server.Port) log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port) diff --git a/metrics/metrics.go b/metrics/metrics.go index ad38e33..7fbd623 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -114,12 +114,14 @@ type AdapterRecorder struct{} // UpstreamStarted 满足 volcano.MetricsRecorder 接口。 func (AdapterRecorder) UpstreamStarted(speaker, model, format string) { - UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": speaker}) + // speaker 用 sha1[:8] 替代,保护火山复刻音色 ID + // (无鉴权 /metrics 端点可枚举,这是 P0 隐私问题) + UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)}) } // UpstreamFinished 满足 volcano.MetricsRecorder 接口。 func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) { - labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": speaker} + labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)} UpstreamTotal.Inc(labels) UpstreamDuration.Observe(duration.Seconds(), telemetry.Labels{"status": status, "format": format}) if ttfb > 0 { diff --git a/middleware/admin_auth.go b/middleware/admin_auth.go new file mode 100644 index 0000000..22e20b7 --- /dev/null +++ b/middleware/admin_auth.go @@ -0,0 +1,73 @@ +package middleware + +import ( + "log" + "net/http" + "strings" + + "github.com/volcano-tts/tts-api/common" + "github.com/volcano-tts/tts-api/setting" +) + +// RequireAdmin 是 /admin 路由的鉴权中间件,复用 OPENAI_TTS_API_KEY。 +// 行为: +// - Auth.APIKeys 为空 → 所有请求放行(等同无鉴权) +// - Authorization 头 Bearer token 在列表中 → 放行 +// - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'} +// +// 设计: 与现有 /v1/audio/speech 用的 setting.Auth 共享同一份 keys, +// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。 +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight) + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + + keys := setting.Auth.APIKeys + if len(keys) == 0 { + // 没配 admin key,等同无鉴权 + next.ServeHTTP(w, r) + return + } + + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + denyAdmin(w, r) + return + } + token := strings.TrimSpace(auth[len(prefix):]) + if !inAPIKeyList(token, keys) { + denyAdmin(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// inAPIKeyList 用常量时间比较,防 token 计时攻击。 +// 单个 key 也走同一条路径,无差别处理。 +func inAPIKeyList(token string, keys []string) bool { + if token == "" { + return false + } + match := false + for _, k := range keys { + if common.SecureEqualString(token, k) { + match = true + // 不 break,继续遍历,保持时间恒定 + } + } + return match +} + +// denyAdmin 写 401 + JSON 错误体,记录客户端 IP。 +func denyAdmin(w http.ResponseWriter, r *http.Request) { + log.Printf("[admin_auth] 鉴权失败 - 路径=%s 客户端=%s", r.URL.Path, GetClientIP(r)) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("WWW-Authenticate", `Bearer realm="tts-admin"`) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"code":"admin_auth_failed","message":"unauthorized","type":"authentication_error"}}`)) +} diff --git a/router/admin.html b/router/admin.html new file mode 100644 index 0000000..b77a658 --- /dev/null +++ b/router/admin.html @@ -0,0 +1,359 @@ + + +
+ + +| 名称 | +Speaker | +资源 ID | +Model | +启用 | +操作 | +
|---|---|---|---|---|---|
| {{ v.name }} | +{{ v.speaker }} | +{{ v.resource_id }} | +{{ v.model || '—' }} | ++ + | ++ + | +