feat(auth)!: v0.3.0 阶段一 鉴权收紧 + 健康/指标端点收口
本提交由初版方案的「阶段 1」与「阶段 2」合并而成。合并原因:两者是同一件事的两半—— 只收紧鉴权而不收口端点,结果是"有风险无收益"(未配凭证的旧部署可能起不来, 而 /health、/dashboard、/metrics 仍匿名可读)。 一、鉴权收紧(原阶段 1) - RequireAdmin 在凭证列表为空时不再放行。此前 len(keys)==0 直接 next.ServeHTTP, 导致未配凭证的部署上管理接口完全裸奔,也让后续给端点加鉴权的加固形同虚设。 现在该情况返回 401 并记录明确日志。 - 新增可选独立管理凭证 admin_key,取值优先级: admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。 middleware.ValidateAPIKey(业务侧 /v1/audio/speech)保持只看 auth_key, 于是配置 admin_key 后业务调用方持有的 key 无法访问管理接口,权限隔离成立; 不配置则回退 auth_key,老部署行为不变。 新增 PUT /api/admin/settings/admin-key 与旧前缀别名, 凭证只写不读(GET 仅返回打码值与 admin_key_set / admin_key_source)。 - normal 模式下管理凭证为空时启动期 fail-fast。RequireAdmin 改为拒绝后, 若此处不拦,服务会正常起来但 /dashboard 与全部 /api/admin/* 都是 401, 等于把自己锁在门外;宁可启动失败并打印明确原因。 二、端点收口(原阶段 2) - /health 改为鉴权(原匿名泄漏版本、commit、运行时长、内存、goroutine、配置错误文本) - /dashboard 改为鉴权,并对浏览器 HTML 请求做内容协商(Accept 含 text/html 时 返回页面外壳,由前端据 sessionStorage 显示登录视图;非 HTML 请求无凭证一律 401)。 不做无条件 401 的原因:SPA 登录态存在 sessionStorage、不随请求发送, 服务端无从判断是否已登录,强行 401 会把"打开看到登录页"变成"打开直接报错"。 页面外壳不含任何数据,数据全部来自鉴权后的 API。 - /api/setup/status 改为鉴权(原本 normal 模式下仍匿名返回 installed/mode) - 根路径 /metrics 默认完全不注册(访问 404);配置 METRICS_ALLOW_CIDR 后按内网 白名单开放,非白名单返回 404 而非 403,不向扫描者确认端点存在。 - 新增 GET /healthz 匿名存活探针,只回 200 与字面量 ok、不含任何字段,解决 "给 /health 加鉴权后 K8s 探针与 Docker HEALTHCHECK 会一律 401 导致 Pod 反复重启"; 新增 GET /api/admin/health 鉴权版详细健康数据。 IP 白名单中间件复用 GetClientIP(已处理 XFF 与 TRUSTED_PROXY_HOPS); main.go 启动日志同步改为指向 /healthz 与 /api/admin/health。 BREAKING CHANGE: 1. /health、/dashboard、/api/setup/status 不再匿名可读;根路径 /metrics 需配 METRICS_ALLOW_CIDR,否则 404。 2. 未配置 auth_key / admin_key / OPENAI_TTS_API_KEY 任一时,normal 模式下服务拒绝启动。 3. K8s 探针与 Docker HEALTHCHECK 必须改指 /healthz;Prometheus 请改用鉴权版 /api/admin/metrics 或配置 METRICS_ALLOW_CIDR(Docker 中勿填 127.0.0.1/32, 那是容器自身回环,应填容器内网网段)。 验证:go build / go vet / go test ./... -count=1 全绿(7 个包); 另用真实服务器端到端验证端点矩阵、内容协商、凭证隔离与 CIDR 白名单两种形态。
This commit is contained in:
@@ -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` 调过
|
||||
|
||||
@@ -20,6 +20,11 @@ type SettingsResponse struct {
|
||||
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 用)
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+107
-25
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user