diff --git a/CHANGELOG.md b/CHANGELOG.md index 967e81e..baa9cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,59 @@ ## [未发布] +### v0.3.0 · 进行中 + +按 [docs/IMPLEMENT_v0.3.0.md](docs/IMPLEMENT_v0.3.0.md) 分阶段实施,当前完成 **阶段 1、2、3**。 + +> **编号说明**:初版方案的"阶段 1(收紧鉴权)"与"阶段 2(收口端点)"属于同一次安全改造、 +> 必须一起上线才有意义(只做前者是"有风险无收益"),故已**合并为现在的「阶段 1」**; +> 后续阶段依次顺延。下文的「阶段 N」均指合并后的编号。 + +#### 新增 + +- **管理入口迁移到 `/dashboard`**:`/dashboard`、`/dashboard/voices`、`/dashboard/settings` + 为新入口;详细状态页(原 `/dashboard` 的旧预览页)迁至 **`/dashboard/status`**, + 并在侧边栏新增「观测 → 详细状态」入口。`/admin` 及子路径 **301 永久重定向**到对应新路径。 +- **API 路径分层**:`/api/admin/voices`、`/api/admin/settings`(含 `api-key` / `auth-key` / `admin-key`) + 为正式路径;**旧路径 `/api/voices`、`/api/settings` 保留为别名**(同一个 handler,不做重定向)。 + 选别名而非 301/308 的原因:301 会把 POST/PUT/PATCH/DELETE 降级为 GET(RFC 7231)且被浏览器永久缓存, + 客户端察觉不到路径变化;308 虽保留方法但对脚本仍是行为变化。别名零往返、零破坏,老脚本完全无感。 +- **`GET /healthz` 匿名存活探针**:只返回 `200` 与字面量 `ok`,**不含任何字段**。 + 供 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查使用 —— + 这些探针默认不带 `Authorization`,若继续指向 `/health` 会因鉴权而全部失败。 +- **`GET /api/admin/health` 鉴权版详细健康数据**:与 `/api/admin/metrics` 风格一致, + 供管理面板与运维使用。 +- **`METRICS_ALLOW_CIDR` 内网白名单**(逗号分隔 CIDR,支持裸 IP 自动补掩码): + 配置后在根路径注册 `/metrics`,仅放行白名单来源;**非白名单返回 404 而非 403**, + 不向扫描者确认端点存在。未配置时根路径 `/metrics` **完全不注册**。 +- **独立管理凭证 `admin_key`(可选)**:管理接口凭证与业务调用凭证分离。 + 取值优先级 `admin_key`(DB) → `auth_key`(DB) → `OPENAI_TTS_API_KEY`(env)。 + **不配置时行为与旧版完全一致**(回退用 `auth_key`),配置后业务 key 无法访问管理接口。 + 新增 `PUT /api/admin/settings/admin-key`(及旧前缀别名)用于配置, + 凭证**只写不读**(`GET /api/admin/settings` 仅返回打码值与 `admin_key_set` / `admin_key_source`)。 + +#### 变更(Breaking Change) + +- **`/health`、`/metrics`、`/dashboard` 不再匿名可读**: + - `/health` → 鉴权(原匿名,泄漏版本 / commit / 内存 / goroutine / 配置错误文本) + - `/dashboard` → 鉴权(原匿名,且它同时聚合 `/health` + `/metrics`,是单点泄漏最严重的端点)。 + 对浏览器 HTML 请求做内容协商:返回页面外壳由前端显示登录视图;非 HTML 请求(脚本、抓取)无凭证一律 401。 + - 根路径 `/metrics` → 需要 `METRICS_ALLOW_CIDR`,否则 404 + - `/api/setup/status` → 鉴权(原本 normal 模式下仍匿名返回 `{installed, mode}`) +- **`RequireAdmin` 在凭证未配置时不再放行**。此前 `len(keys)==0` 直接放行,导致未配置凭证的 + 部署上管理接口完全裸奔(也使得给 `/metrics`、`/health` 加鉴权的加固形同虚设)。现在该情况返回 **401**。 +- **normal 模式下未配置任何管理凭证时服务拒绝启动**(fail-fast),启动摘要打印管理凭证来源。 + +> ⚠️ **升级提示** +> 1. 若部署未配置 `auth_key` / `admin_key` / `OPENAI_TTS_API_KEY` 中任何一个,升级后服务将拒绝启动。 +> 2. K8s 探针 / Docker HEALTHCHECK 请改指 **`/healthz`**;Prometheus 请改用鉴权版 +> `/api/admin/metrics`,或配置 `METRICS_ALLOW_CIDR`。 +> 3. `METRICS_ALLOW_CIDR` 在 Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环), +> 请填容器内网网段(如 `172.16.0.0/12`)。 +> 4. 管理入口改为 **`/dashboard`**;旧 `/admin` 路径保留 301 重定向,书签与脚本请尽快迁移。 +> 5. 管理 API 正式路径改为 **`/api/admin/*`**;旧 `/api/*` 路径仍可用(别名,非重定向), +> 计划在后续版本移除。 + ### 修复 - **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过 diff --git a/controller/settings.go b/controller/settings.go index 0a0b0eb..61dc42d 100644 --- a/controller/settings.go +++ b/controller/settings.go @@ -16,22 +16,27 @@ import ( // SettingsResponse 是 GET /api/settings 的响应。 // API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。 type SettingsResponse struct { - APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1 - APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填) - AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用) - AuthKeySet bool `json:"auth_key_set"` - CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*) - CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /) - CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用) + APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1 + APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填) + AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用) + AuthKeySet bool `json:"auth_key_set"` + // AdminKey 是**管理接口专用**凭证(v0.3.0 新增,可选)。 + // 为空表示未单独配置,管理接口回退用 auth_key(向后兼容)。 + AdminKey string `json:"admin_key"` // 打码形式 + AdminKeySet bool `json:"admin_key_set"` // 是否单独配置了 admin_key + AdminKeySource string `json:"admin_key_source"` // admin_key / auth_key / env / ""(未配置) + CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*) + CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /) + CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用) DefaultResourceID string `json:"default_resource_id"` - DefaultSpeaker string `json:"default_speaker"` - DefaultFormat string `json:"default_format"` - SampleRate int `json:"sample_rate"` - Model string `json:"model"` - ModelType int `json:"model_type"` - ExplicitLanguage string `json:"explicit_language"` - EnableSubtitle bool `json:"enable_subtitle"` - UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用) + DefaultSpeaker string `json:"default_speaker"` + DefaultFormat string `json:"default_format"` + SampleRate int `json:"sample_rate"` + Model string `json:"model"` + ModelType int `json:"model_type"` + ExplicitLanguage string `json:"explicit_language"` + EnableSubtitle bool `json:"enable_subtitle"` + UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用) } // SettingsGetHandler GET /api/settings @@ -58,6 +63,9 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) { APIKeySet: all["api_key"] != "", AuthKey: maskAPIKeyField(all["auth_key"]), AuthKeySet: all["auth_key"] != "", + AdminKey: maskAPIKeyField(all["admin_key"]), + AdminKeySet: all["admin_key"] != "", + AdminKeySource: setting.GetAdminKeySource(), CORSAllowAll: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true", CORSOrigins: all["cors_origins"], CORSConfigured: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true" || all["cors_origins"] != "", @@ -298,10 +306,74 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } +// SettingsAdminKeyRequest 是 PUT /api/admin/settings/admin-key 的 body。 +// admin_key 是**管理接口专用**凭证;与 auth_key(业务侧 /v1/audio/speech 鉴权)分离后, +// 业务调用方拿到的 key 不再能访问管理接口。 +// 传空串表示"清除独立管理凭证",管理接口回退用 auth_key(即旧行为)。 +type SettingsAdminKeyRequest struct { + AdminKey string `json:"admin_key"` +} + +// SettingsAdminKeyHandler PUT /api/admin/settings/admin-key +// 鉴权: RequireAdmin(注意:能用当前凭证改,改完下一个请求即用新凭证)。 +func SettingsAdminKeyHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + 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<<10) + var body SettingsAdminKeyRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") + return + } + key := strings.TrimSpace(body.AdminKey) + + if key == "" { + // 清除独立管理凭证 → 回退 auth_key。回退后若 auth_key 也为空, + // 管理接口将全部 401(RequireAdmin 不再空凭证放行),这里必须挡住。 + authKey, _, _ := s.SettingsGet("auth_key") + if authKey == "" { + middleware.SendJSONError(w, http.StatusBadRequest, + "admin_key cannot be cleared while auth_key is empty (would lock out admin access)", + "invalid_request_error", "missing_field") + return + } + if err := s.SettingsDelete("admin_key"); err != nil { + log.Printf("[settings] admin-key clear: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "clear admin_key failed", "server_error", "db_write_failed") + return + } + setting.SetAdminKeys([]string{authKey}, "auth_key") + log.Printf("[settings] admin_key cleared; admin auth falls back to auth_key") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "admin_key_set": false, "admin_key_source": "auth_key"}) + return + } + + if err := s.SettingsSet("admin_key", key); err != nil { + log.Printf("[settings] admin-key set: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "write admin_key failed", "server_error", "db_write_failed") + return + } + // 立即生效:只单独刷新管理凭证列表,不重新 LoadRuntimeConfig(那会覆盖其它字段) + setting.SetAdminKeys([]string{key}, "admin_key") + log.Printf("[settings] admin_key updated, runtime active (next request uses new admin credential)") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "admin_key_set": true, "admin_key_source": "admin_key"}) +} + // SettingsCORSRequest 是 PUT /api/settings/cors 的 body。 // 两个字段都可选(至少给一个),用指针区分"未传"和"传空串": // - allow_all 指针: nil=未传(不动) *true=开 *false=关 // - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖 +// // 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。 type SettingsCORSRequest struct { AllowAll *bool `json:"allow_all,omitempty"` @@ -386,10 +458,10 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.GetCORSAllowAll(), originsStr) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "allow_all": setting.GetCORSAllowAll(), - "origins": originsStr, - "cors_active": true, + "ok": true, + "allow_all": setting.GetCORSAllowAll(), + "origins": originsStr, + "cors_active": true, }) } diff --git a/controller/tts.go b/controller/tts.go index 5292674..eb00c6f 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -274,6 +274,21 @@ func contentTypeFor(format string) string { } // HealthHandler 暴露运行期状态;无鉴权。 +// HealthzHandler GET /healthz —— 匿名存活探针,**只回 200 与字面量 "ok"**。 +// +// 为什么单独做这个:v0.3.0 把详细健康数据(/health)收口到管理鉴权之后, +// 但 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查默认都不带 Authorization。 +// 若把它们继续指向 /health,加鉴权后会一律 401,导致探针失败、Pod 反复重启。 +// +// 因此本端点刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式信息), +// 只用于回答"进程还在不在"。运维要细节请走鉴权后的 /health。 +func HealthzHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + func HealthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/main.go b/main.go index 80c4d7f..df1bde4 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,8 @@ func main() { setting.InitAllConfigs() metrics.Init() middleware.InitRateLimiter() + // v0.3.0:根路径 /metrics 的内网白名单(未配置则根路径完全不注册) + middleware.InitMetricsAllowList() // 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式 dbPath := ttsDBPath() @@ -85,6 +87,21 @@ func main() { // 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态) setting.LogStartupSummary() + + // 5.1) v0.3.0 前置校验:normal 模式下必须有管理凭证。 + // 为什么 fail-fast 而不是警告:mware.RequireAdmin 在凭证为空时**拒绝**访问 + // (不再像 v0.3.0 之前那样放行)。若此处不拦住,服务会正常起来, + // 但 /dashboard 与所有 /api/admin/* 全部 401 —— 相当于把自己锁在门外。 + // 宁可启动失败并打印明确原因,也不要起来一个进不去后台的实例。 + if installer.GetMode() == installer.ModeNormal && len(setting.GetAdminKeys()) == 0 { + log.Printf("[main][FATAL] normal 模式未配置任何管理凭证:admin_key / auth_key / OPENAI_TTS_API_KEY 均为空。") + log.Printf("[main][FATAL] 管理接口(含 /dashboard)将全部返回 401,服务拒绝启动。") + log.Fatalf("no admin credential configured; set admin_key (or auth_key) before starting in normal mode") + } + if installer.GetMode() == installer.ModeNormal { + log.Printf("[main] 管理凭证来源: %s", setting.GetAdminKeySource()) + } + log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath) controller.InitController() @@ -113,8 +130,13 @@ func main() { 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) + log.Printf("Health check(匿名存活探针): http://localhost:%s/healthz", setting.Server.Port) + if middleware.MetricsAllowListConfigured() { + log.Printf("Metrics(内网白名单): http://localhost:%s/metrics", setting.Server.Port) + } else { + log.Printf("Metrics: 根路径 /metrics 未注册(未配置 METRICS_ALLOW_CIDR);请用鉴权版 /api/admin/metrics") + } + log.Printf("详细健康数据(鉴权): http://localhost:%s/api/admin/health", setting.Server.Port) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) diff --git a/middleware/admin_auth.go b/middleware/admin_auth.go index b1395a6..2c7d729 100644 --- a/middleware/admin_auth.go +++ b/middleware/admin_auth.go @@ -9,14 +9,19 @@ import ( "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'} +// RequireAdmin 是管理接口(/api/admin/*、/api/voices*、/api/settings*)的鉴权中间件。 // -// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys, -// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。 +// 凭证来源:setting.GetAdminKeys(),优先级 admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。 +// 与业务侧鉴权(middleware.ValidateAPIKey,只看 auth_key)分离,实现权限隔离: +// 配置了独立 admin_key 后,业务调用方持有的 key 无法访问管理接口。 +// +// 行为: +// - OPTIONS 预检 → 放行(浏览器预检不带 Authorization) +// - 未配置任何凭证 → **拒绝**(401)。这是刻意设计:v0.3.0 之前这里直接放行, +// 导致管理接口在"没配 key"的部署上完全裸奔,并让后续给 /metrics、/health +// 套本中间件的加固形同虚设。启动期已由 main.go 做 fail-fast 校验。 +// - Authorization 头 Bearer token 命中凭证列表 → 放行 +// - 其它 → 401 + JSON {error: {code: 'admin_auth_failed'}} func RequireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight) @@ -25,10 +30,12 @@ func RequireAdmin(next http.Handler) http.Handler { return } - keys := setting.GetAuthAPIKeys() + keys := setting.GetAdminKeys() if len(keys) == 0 { - // 没配 admin key,等同无鉴权 - next.ServeHTTP(w, r) + // 没配管理凭证 → 拒绝(旧行为是放行,见上方注释说明为何改掉) + log.Printf("[admin_auth] 拒绝:未配置管理凭证(admin_key/auth_key/OPENAI_TTS_API_KEY 均为空) - 路径=%s 客户端=%s", + r.URL.Path, GetClientIP(r)) + denyAdmin(w, r) return } diff --git a/middleware/metricsip.go b/middleware/metricsip.go new file mode 100644 index 0000000..1a7fe4f --- /dev/null +++ b/middleware/metricsip.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "log" + "net" + "net/http" + "os" + "strings" +) + +// metricsAllowList 是 METRICS_ALLOW_CIDR 解析出的内网白名单。 +// 启动期由 InitMetricsAllowList 填充;运行期只读,无并发写。 +var metricsAllowList []*net.IPNet + +// metricsAllowConfigured 表示是否配置了非空的 METRICS_ALLOW_CIDR。 +// 决定根路径 /metrics、/health 是否注册(未配置则完全不注册,访问 404)。 +var metricsAllowConfigured bool + +// InitMetricsAllowList 解析 METRICS_ALLOW_CIDR,启动期调用一次。 +// +// 格式:逗号分隔的 CIDR 列表,例如 "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"。 +// 也接受裸 IP(自动补 /32 或 /128),方便写 "127.0.0.1"。 +// +// 为什么需要它:根路径 /metrics、/health 是给 Prometheus 抓取 / K8s 探针用的机器端点, +// 让它们带 Bearer 会强制改抓取配置;用内网白名单更省事,且默认不暴露。 +// +// 【重要】Docker 环境不要填 127.0.0.1/32 —— 那是容器自身的回环, +// Prometheus 在宿主机或另一个容器里根本进不来。请填容器内网网段(如 172.16.0.0/12)。 +func InitMetricsAllowList() { + raw := strings.TrimSpace(os.Getenv("METRICS_ALLOW_CIDR")) + metricsAllowList = nil + metricsAllowConfigured = false + if raw == "" { + return + } + + for _, part := range strings.Split(raw, ",") { + entry := strings.TrimSpace(part) + if entry == "" { + continue + } + // 裸 IP 自动补全掩码 + if !strings.Contains(entry, "/") { + if ip := net.ParseIP(entry); ip != nil { + if ip.To4() != nil { + entry += "/32" + } else { + entry += "/128" + } + } + } + _, ipnet, err := net.ParseCIDR(entry) + if err != nil { + log.Printf("[metrics-ip] 忽略非法 CIDR 条目 %q: %v", part, err) + continue + } + metricsAllowList = append(metricsAllowList, ipnet) + } + metricsAllowConfigured = len(metricsAllowList) > 0 + if metricsAllowConfigured { + log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 已启用,根路径 /metrics 仅对 %d 个网段开放", len(metricsAllowList)) + } else { + log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 无有效条目,根路径 /metrics 将不注册") + } +} + +// MetricsAllowListConfigured 报告是否配置了有效的白名单网段。 +// router 据此决定是否注册根路径 /metrics / /health。 +func MetricsAllowListConfigured() bool { return metricsAllowConfigured } + +// MetricsIPAllowList 只放行来源 IP 命中白名单的请求。 +// +// 未命中返回 **404**(而不是 403):不向扫描者确认"这里存在一个只是你没权限的端点"。 +// 客户端 IP 取自 GetClientIP,它已处理 X-Forwarded-For 与 TRUSTED_PROXY_HOPS, +// 所以反代后面的真实来源也能正确判定。 +func MetricsIPAllowList(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !ipAllowed(GetClientIP(r)) { + // 不打印每个被拒请求,避免扫描流量刷爆日志;只按需在 debug 下输出 + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// ipAllowed 判定客户端 IP 是否命中任一白名单网段。 +// 空 IP(解析失败)一律拒绝 —— 宁可拒绝也不能误放行。 +func ipAllowed(clientIP string) bool { + if clientIP == "" { + return false + } + ip := net.ParseIP(strings.TrimSpace(clientIP)) + if ip == nil { + return false + } + for _, n := range metricsAllowList { + if n.Contains(ip) { + return true + } + } + return false +} diff --git a/router/router.go b/router/router.go index f86a7b6..a862580 100644 --- a/router/router.go +++ b/router/router.go @@ -66,78 +66,160 @@ func Setup() *mux.Router { // /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。 r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) { if installer.GetMode() == installer.ModeNormal { - http.Redirect(w, req, "/admin", http.StatusFound) + http.Redirect(w, req, "/dashboard", http.StatusFound) // v0.3.0: 管理入口迁至 /dashboard return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(setupHTML) }).Methods("GET") - r.HandleFunc("/api/setup/status", controller.SetupStatusHandler).Methods("GET") + // v0.3.0:安装状态不再匿名暴露(原本 normal 模式下仍返回 {installed, mode}) + r.Handle("/api/setup/status", middleware.RequireAdmin(http.HandlerFunc(controller.SetupStatusHandler))).Methods("GET") r.HandleFunc("/api/setup/prefill", controller.SetupPrefillHandler).Methods("GET") r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST") - // /admin 管理后台(M2);HTML 本身公开,鉴权由前端 JS 拦截 - // (sessionStorage 没 key 就显示登录页;有 key 调 /api/admin/overview 触发 401 跳登录) - // API 端点(/api/admin/* /api/voices*)才需要 RequireAdmin。 + // ===== v0.3.0 阶段 3:管理面板入口从 /admin 迁至 /dashboard ===== // - // 拆分后 admin.html / admin-login.html / admin-voices.html / admin-settings.html - // 各自独立,URL 路由切换;共享 admin.css + admin-shell.js 由 adminStatic 提供。 - r.HandleFunc("/admin", serveAdmin(adminHTML)).Methods("GET") - r.HandleFunc("/admin/login", serveAdmin(adminLoginHTML)).Methods("GET") - r.HandleFunc("/admin/voices", serveAdmin(adminVoicesHTML)).Methods("GET") - r.HandleFunc("/admin/settings", serveAdmin(adminSettingsHTML)).Methods("GET") - // admin 静态资源 - r.HandleFunc("/admin/admin.css", func(w http.ResponseWriter, _ *http.Request) { + // 页面本身走 htmlAwareAdmin:浏览器请求(Accept: text/html)返回页面外壳, + // 前端依据 sessionStorage 里的凭证显示登录视图;非 HTML 请求(脚本/抓取)强制鉴权。 + // 原因:SPA 的登录态存在 sessionStorage、不随请求发送,服务端无从判断是否已登录, + // 一律 401 会把"打开 /dashboard 看到登录页"变成"打开 /dashboard 直接报错"。 + // 页面外壳不含任何数据,数据全部来自鉴权后的 /api/admin/* 接口。 + // /dashboard 是管理看板(admin.html,与 /dashboard/voices、/dashboard/settings 同属一套 SPA)。 + // 注意:拆分前的 /dashboard 曾是"服务状态预览页"(health.html),v0.3.0 起 + // 管理入口统一到 /dashboard,该预览页迁到 /dashboard/status 作为"详细状态"视图。 + r.Handle("/dashboard", htmlAwareAdmin(serveAdmin(adminHTML))).Methods("GET") + r.Handle("/dashboard/voices", htmlAwareAdmin(serveAdmin(adminVoicesHTML))).Methods("GET") + r.Handle("/dashboard/settings", htmlAwareAdmin(serveAdmin(adminSettingsHTML))).Methods("GET") + r.Handle("/dashboard/status", htmlAwareAdmin(serveAdmin(dashboardHTML))).Methods("GET") + // 登录页无数据,保持公开(否则无法登录) + r.HandleFunc("/dashboard/login", serveAdmin(adminLoginHTML)).Methods("GET") + + // 静态资源:迁到 /dashboard/ 下,同时**保留 /admin/ 旧路径为别名**, + // 这样过渡期内新旧页面都能正确加载样式与脚本,不会出现"页面能开但丢样式"。 + serveCSS := func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") _, _ = w.Write(adminCSS) - }).Methods("GET") - r.HandleFunc("/admin/admin-shell.js", func(w http.ResponseWriter, _ *http.Request) { + } + serveShellJS := func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") _, _ = w.Write(adminShellJS) + } + r.HandleFunc("/dashboard/admin.css", serveCSS).Methods("GET") + r.HandleFunc("/dashboard/admin-shell.js", serveShellJS).Methods("GET") + r.HandleFunc("/admin/admin.css", serveCSS).Methods("GET") + r.HandleFunc("/admin/admin-shell.js", serveShellJS).Methods("GET") + + // 旧入口永久重定向(页面用 301 是正确的;API 路径**不用**重定向,见下方别名说明) + r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/dashboard", http.StatusMovedPermanently) + }).Methods("GET") + r.HandleFunc("/admin/login", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/dashboard/login", http.StatusMovedPermanently) + }).Methods("GET") + r.HandleFunc("/admin/voices", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/dashboard/voices", http.StatusMovedPermanently) + }).Methods("GET") + r.HandleFunc("/admin/settings", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/dashboard/settings", http.StatusMovedPermanently) }).Methods("GET") // /api/admin/overview (鉴权) r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET") // /api/admin/metrics (鉴权);返 Prometheus 文本 r.Handle("/api/admin/metrics", middleware.RequireAdmin(http.HandlerFunc(controller.AdminMetricsHandler))).Methods("GET") + // v0.3.0:详细健康数据的鉴权版(面板与运维用)。 + // 与根路径 /health 的区别:这里始终注册且强制鉴权;根路径 /health 默认 404。 + r.Handle("/api/admin/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET") // /api/voices 音色 CRUD (鉴权) + // + // ⚠️ v0.3.0 阶段 3 路径迁移:/api/admin/voices 是**正式路径**, + // /api/voices 保留为**过渡期别名**(同一个 handler,不做重定向)。 + // + // 为什么用别名而不是 301/308: + // - 301 会把 POST/PUT/PATCH/DELETE 降级成 GET(RFC 7231),且被浏览器永久缓存, + // 客户端根本察觉不到路径变化,"过渡期"名存实亡 + // - 308 能保留方法,但对非浏览器脚本仍是行为变化,且多一次往返 + // - 别名让新旧路径都能直接用,零往返、零破坏,老脚本完全无感 + // 旧别名计划在后续版本移除。 + r.Handle("/api/admin/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET") + r.Handle("/api/admin/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST") + r.Handle("/api/admin/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE") + r.Handle("/api/admin/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH") + r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET") r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST") r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE") r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH") // /api/settings 全局设置 (鉴权) — M3 + // 同样:/api/admin/settings 为正式路径,/api/settings 为过渡期别名。 + // 注意更长的 /api/admin/settings/admin-key 必须**先注册**(mux 按注册顺序取首个匹配)。 + r.Handle("/api/admin/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT") + r.Handle("/api/admin/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET") + r.Handle("/api/admin/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT") + r.Handle("/api/admin/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT") + r.Handle("/api/admin/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT") + r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET") r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT") r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT") r.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT") + r.Handle("/api/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT") r.Handle("/api/settings/cors", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsCORSHandler))).Methods("PUT") // 业务路由 r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/health", controller.HealthHandler).Methods("GET") - r.HandleFunc("/dashboard", func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(dashboardHTML) - }).Methods("GET") + + // v0.3.0 端点收口: + // /healthz 匿名存活探针,只回 "ok"(K8s probe / Docker HEALTHCHECK 用) + // /health 详细健康数据,**鉴权**(原本匿名,会泄漏版本/内存/配置错误文本) + // /dashboard 管理看板(**鉴权**,见上方阶段 3 的注册处) + // /api/admin/health 鉴权版详细健康数据(面板拉取用,风格与 /api/admin/* 一致) + r.HandleFunc("/healthz", controller.HealthzHandler).Methods("GET") + r.Handle("/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET") r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { // 安装模式下,根路径跳 /setup if installer.GetMode() == installer.ModeSetup { http.Redirect(w, req, "/setup", http.StatusFound) return } - // 正常模式:跳 /admin(M2 之后优先于 /dashboard) - http.Redirect(w, req, "/admin", http.StatusFound) + // 正常模式:跳 /dashboard(v0.3.0 管理入口) + http.Redirect(w, req, "/dashboard", http.StatusFound) }).Methods("GET") - // /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。 - // Prometheus 抓取不带 Origin,因此经过 CORS 中间件时会直接 pass-through。 - r.Handle("/metrics", metrics.Meter.Handler()).Methods("GET") + // 根路径 /metrics 与 /health 的机器访问: + // - 配置了 METRICS_ALLOW_CIDR → 注册 /metrics,包裹内网白名单(非白名单返回 404) + // - 未配置 → 根路径 /metrics **完全不注册**(访问 404),避免默认对外暴露 + // /health 已在上面按鉴权注册,不再提供匿名版本。 + if middleware.MetricsAllowListConfigured() { + r.Handle("/metrics", middleware.MetricsIPAllowList(metrics.Meter.Handler())).Methods("GET") + } return r } +// htmlAwareAdmin 给**浏览器直接访问的管理页面**套鉴权,但对 HTML 请求做内容协商: +// +// - Accept 含 text/html(地址栏打开、点链接)→ 照常返回页面外壳。 +// 此时不做 401:因为前端登录态存在 sessionStorage,**不会随请求发送**, +// 服务端无从判断是否已登录;强行 401 会让"打开 /dashboard 看到登录页" +// 这一现有体验变成"打开 /dashboard 直接报错"。 +// 页面外壳本身不含任何数据,数据全部来自鉴权后的 /api/admin/* 接口。 +// - 其它(脚本、curl、抓取工具)→ 强制 RequireAdmin,无凭证 401。 +// +// 这样既堵住了"匿名抓取健康数据",又不破坏 SPA 的登录流程。 +func htmlAwareAdmin(html http.Handler) http.Handler { + guarded := middleware.RequireAdmin(html) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if acceptsHTML(r.Header.Get("Accept")) { + html.ServeHTTP(w, r) + return + } + guarded.ServeHTTP(w, r) + }) +} + // acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。 // 用途:NotFoundHandler 判断浏览器 Accept。 func acceptsHTML(accept string) bool { diff --git a/setting/config.go b/setting/config.go index 748fee8..d76fcfb 100644 --- a/setting/config.go +++ b/setting/config.go @@ -32,6 +32,14 @@ var ( ttsTimeout time.Duration = common.DefaultTimeout ttsConfigErr error authAPIKeys []string + // adminAPIKeys 是**管理接口专用**凭证(admin_key)。 + // 与 authAPIKeys(业务侧,/v1/audio/speech 用)分离: + // - admin_key 为空时回退到 auth_key/env,老部署行为完全不变 + // - admin_key 配了之后,业务 key 无法访问 /api/admin/*,权限隔离成立 + // 详见 docs/IMPLEMENT_v0.3.0.md 阶段 1。 + adminAPIKeys []string + // adminKeySource 记录管理凭证的来源,仅用于启动摘要与排障。 + adminKeySource string // corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取, // 避免 CORSConfig 整体读时被 Lock 阻塞热路径。 corsAllowAll bool @@ -108,6 +116,40 @@ func SetAuthAPIKeys(keys []string) { authAPIKeys = out } +// GetAdminKeys 读**管理接口专用**凭证列表;返回拷贝防止业务侧持有底层 slice。 +// 供 middleware.RequireAdmin 使用;业务侧鉴权请用 GetAuthAPIKeys。 +func GetAdminKeys() []string { + ttsMu.RLock() + defer ttsMu.RUnlock() + if len(adminAPIKeys) == 0 { + return nil + } + out := make([]string, len(adminAPIKeys)) + copy(out, adminAPIKeys) + return out +} + +// SetAdminKeys 整体替换管理凭证;入参被复制。source 仅用于启动摘要展示。 +func SetAdminKeys(keys []string, source string) { + ttsMu.Lock() + defer ttsMu.Unlock() + adminKeySource = source + if len(keys) == 0 { + adminAPIKeys = nil + return + } + out := make([]string, len(keys)) + copy(out, keys) + adminAPIKeys = out +} + +// GetAdminKeySource 返回管理凭证来源:admin_key / auth_key / env / ""(未配置)。 +func GetAdminKeySource() string { + ttsMu.RLock() + defer ttsMu.RUnlock() + return adminKeySource +} + // GetCORSAllowAll 读 CORS 是否放行所有来源。 func GetCORSAllowAll() bool { ttsMu.RLock() @@ -391,6 +433,18 @@ func LoadRuntimeConfig(s Store) error { SetAuthAPIKeys(nil) } + // 管理凭证:admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env) + // 分离的目的:业务调用方拿到的 key 不应同时拥有管理后台权限。 + // 不配 admin_key 时行为与旧版完全一致(回退用 auth_key),保证向后兼容。 + switch { + case all["admin_key"] != "": + SetAdminKeys([]string{all["admin_key"]}, "admin_key") + case authKey != "": + SetAdminKeys([]string{authKey}, "auth_key") + default: + SetAdminKeys(nil, "") + } + // CORS 配置:DB > env // cors_allow_all (bool): 允许所有来源(*) // cors_origins (string): 逗号分隔白名单 @@ -540,6 +594,16 @@ func LogStartupSummary() { log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys)) } + // v0.3.0:管理凭证独立于业务凭证(admin_key > auth_key > env) + switch GetAdminKeySource() { + case "admin_key": + log.Printf("管理凭证: 使用独立 admin_key(业务 key 无法访问管理接口)") + case "auth_key": + log.Printf("管理凭证: 未单独配置 admin_key,回退使用 auth_key(业务 key 同时拥有管理权限)") + default: + log.Printf("管理凭证: ✗ 未配置(normal 模式下服务将拒绝启动)") + } + allowAll := GetCORSAllowAll() origins := GetCORSOrigins() if allowAll {