Compare commits
3
Commits
main
...
753de8ca9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
753de8ca9f | ||
|
|
d22e5f0801 | ||
|
|
c4269086fa |
@@ -4,6 +4,45 @@
|
|||||||
|
|
||||||
## [未发布]
|
## [未发布]
|
||||||
|
|
||||||
|
### v0.3.0 · 进行中
|
||||||
|
|
||||||
|
按 [docs/IMPLEMENT_v0.3.0.md](docs/IMPLEMENT_v0.3.0.md) 分阶段实施,当前完成 **阶段 1、阶段 2**。
|
||||||
|
|
||||||
|
#### 新增
|
||||||
|
|
||||||
|
- **`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`(及旧前缀别名 `/api/settings/admin-key`)用于配置,
|
||||||
|
凭证**只写不读**(`GET /api/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`)。
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
- **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过
|
- **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过
|
||||||
|
|||||||
+91
-19
@@ -16,22 +16,27 @@ import (
|
|||||||
// SettingsResponse 是 GET /api/settings 的响应。
|
// SettingsResponse 是 GET /api/settings 的响应。
|
||||||
// API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。
|
// API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。
|
||||||
type SettingsResponse struct {
|
type SettingsResponse struct {
|
||||||
APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1
|
APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1
|
||||||
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
|
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
|
||||||
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
|
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
|
||||||
AuthKeySet bool `json:"auth_key_set"`
|
AuthKeySet bool `json:"auth_key_set"`
|
||||||
CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*)
|
// AdminKey 是**管理接口专用**凭证(v0.3.0 新增,可选)。
|
||||||
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /)
|
// 为空表示未单独配置,管理接口回退用 auth_key(向后兼容)。
|
||||||
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用)
|
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"`
|
DefaultResourceID string `json:"default_resource_id"`
|
||||||
DefaultSpeaker string `json:"default_speaker"`
|
DefaultSpeaker string `json:"default_speaker"`
|
||||||
DefaultFormat string `json:"default_format"`
|
DefaultFormat string `json:"default_format"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
ModelType int `json:"model_type"`
|
ModelType int `json:"model_type"`
|
||||||
ExplicitLanguage string `json:"explicit_language"`
|
ExplicitLanguage string `json:"explicit_language"`
|
||||||
EnableSubtitle bool `json:"enable_subtitle"`
|
EnableSubtitle bool `json:"enable_subtitle"`
|
||||||
UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用)
|
UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsGetHandler GET /api/settings
|
// SettingsGetHandler GET /api/settings
|
||||||
@@ -58,6 +63,9 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
APIKeySet: all["api_key"] != "",
|
APIKeySet: all["api_key"] != "",
|
||||||
AuthKey: maskAPIKeyField(all["auth_key"]),
|
AuthKey: maskAPIKeyField(all["auth_key"]),
|
||||||
AuthKeySet: 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",
|
CORSAllowAll: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true",
|
||||||
CORSOrigins: all["cors_origins"],
|
CORSOrigins: all["cors_origins"],
|
||||||
CORSConfigured: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true" || 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})
|
_ = 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。
|
// SettingsCORSRequest 是 PUT /api/settings/cors 的 body。
|
||||||
// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串":
|
// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串":
|
||||||
// - allow_all 指针: nil=未传(不动) *true=开 *false=关
|
// - allow_all 指针: nil=未传(不动) *true=开 *false=关
|
||||||
// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖
|
// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖
|
||||||
|
//
|
||||||
// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。
|
// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。
|
||||||
type SettingsCORSRequest struct {
|
type SettingsCORSRequest struct {
|
||||||
AllowAll *bool `json:"allow_all,omitempty"`
|
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)
|
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")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"allow_all": setting.GetCORSAllowAll(),
|
"allow_all": setting.GetCORSAllowAll(),
|
||||||
"origins": originsStr,
|
"origins": originsStr,
|
||||||
"cors_active": true,
|
"cors_active": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,21 @@ func contentTypeFor(format string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HealthHandler 暴露运行期状态;无鉴权。
|
// 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) {
|
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
# Volcano-Engine-TTS-UI v0.3.0 阶段性实施步骤书
|
||||||
|
|
||||||
|
> **文件名**:`IMPLEMENT_v0.3.0.md`
|
||||||
|
> **基线**:`develop`
|
||||||
|
> **版本目标**:v0.3.0 —— 路由鉴权架构升级、健康/指标端点收口、管理入口统一到 `/dashboard`
|
||||||
|
> **关联文档**:[UPSTREAM_ADAPTER_GUIDE.md](UPSTREAM_ADAPTER_GUIDE.md)(上游适配器重构,与本改造正交,互不干扰)
|
||||||
|
>
|
||||||
|
> 本文是 v0.3.0 的**唯一实施依据**,由初版方案按实际代码基线校正而成。
|
||||||
|
> 校正说明见 [附录 A](#附录-a与初版方案的差异)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 改造目标
|
||||||
|
|
||||||
|
| # | 目标 | 验收标准 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **收口健康/指标端点**:`/metrics`、`/health`、`/dashboard` 不再匿名可读 | 未携带管理凭证时不可读到任何健康数据 |
|
||||||
|
| 2 | **补前置缺口**:`RequireAdmin` 在凭证未配置时不再静默放行 | 未配置凭证时管理接口拒绝访问,且启动期有明确信号 |
|
||||||
|
| 3 | **权限隔离**:业务调用凭证不得访问管理接口 | 用 TTS 调用 key 访问 `/api/admin/*` 返回 401 |
|
||||||
|
| 4 | 管理入口统一:`/admin` → `/dashboard`,旧路径 301 兼容 | 访问 `/admin` 301 到 `/dashboard` |
|
||||||
|
| 5 | API 路由分层:`/api/public/*`、`/api/user/*`、`/api/admin/*` | 三条前缀各自挂对应中间件,`/api/user/*` 为桩 |
|
||||||
|
| 6 | 存量管理接口迁移到 `/api/admin/*`,旧路径继续可用 | 新旧路径行为一致,老脚本零改动 |
|
||||||
|
| 7 | 两套用量查询占位接口 | `/api/user/usage`(管理鉴权)、`/v1/dashboard/billing/usage`(Bearer) |
|
||||||
|
| 8 | 文档 / 示例配置 / Docker 注释同步更新,标注 Breaking Change | README、`.env.example`、`docker-compose.yml`、CHANGELOG 一致 |
|
||||||
|
| 9 | **不改动** `adapter/`、`store/` 核心业务模型,**不破坏** `/v1/audio/speech` | TTS 业务接口回归通过 |
|
||||||
|
|
||||||
|
### 重要约束
|
||||||
|
|
||||||
|
- v0.3.0 **仍是单用户模式**;`/api/user/*` 仅骨架 + 桩实现,完整多用户能力放 v0.4.0。
|
||||||
|
- **不引入 Cookie 会话**(理由见 [附录 B](#附录-b为什么不做-cookie-会话))。管理鉴权继续使用
|
||||||
|
`Authorization: Bearer <key>`,前端继续用 `sessionStorage` 保存凭证。
|
||||||
|
- **每个阶段结束必须可编译、可运行、可本地回归**,拒绝大爆炸式一次性修改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现状基线(开工前必须知道的事实)
|
||||||
|
|
||||||
|
本项目的真实鉴权现状与初版方案的假设**不同**,照着初版做会返工。以下是核对过代码的结论:
|
||||||
|
|
||||||
|
| 事项 | 真实现状 | 代码位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 管理鉴权中间件 | **只有 `RequireAdmin`**,Bearer 请求头校验。**不存在 `AdminSessionMiddleware`** | `middleware/admin_auth.go:20` |
|
||||||
|
| 凭证存储 | 前端 `sessionStorage['ttsAdminKey']`,**无 Cookie** | `router/admin-shell.js:10-15` |
|
||||||
|
| 凭证来源 | 数据库 `auth_key` → 回退环境变量 `OPENAI_TTS_API_KEY` | `setting/config.go:384-392` |
|
||||||
|
| 凭证复用 | **同一个 key 既登录后台、又调 TTS 接口**(本次要拆开) | `middleware/auth.go` + `middleware/admin_auth.go` |
|
||||||
|
| 指标实现 | 自研 `telemetry` 包,**未引入 `prometheus/client_golang`**,无 `promhttp` | `router/router.go:136` |
|
||||||
|
| 客户端 IP | 已有 `middleware.GetClientIP`,支持 XFF + `TRUSTED_PROXY_HOPS` | `middleware/ratelimit.go:196` |
|
||||||
|
| CORS | 非白名单 Origin 在中间件层直接 403 | `middleware/cors.go:138-147` |
|
||||||
|
|
||||||
|
### 1.1 匿名可读端点(本次要收口的全部)
|
||||||
|
|
||||||
|
| 端点 | 泄漏内容 | 代码位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /dashboard` | **HTML 看板,同时拉 `/health` + `/metrics`**,聚合度最高 | `router/router.go:120` |
|
||||||
|
| `GET /metrics` | Prometheus 全量指标(流量、模型名、错误分布、计费字符数) | `router/router.go:136` |
|
||||||
|
| `GET /health` | 版本 / commit / 运行时长 / 内存 / goroutine + 配置错误文本 | `router/router.go:119` |
|
||||||
|
| `GET /api/setup/status` | normal 模式下仍返回 `{installed, mode}` | `router/router.go:75` |
|
||||||
|
|
||||||
|
> `/api/setup/prefill` 在 normal 模式返回 404(handler 内自检),**不可达**,无需处理。
|
||||||
|
|
||||||
|
### 1.2 已有但必须保留的机制
|
||||||
|
|
||||||
|
- `InstallGuard`:安装模式下按白名单放行(`/setup`、`/api/setup`、`/health`、`/metrics`),
|
||||||
|
安装阶段无数据库、无凭证,**不得套用 `RequireAdmin`**。`middleware/installguard.go:19`
|
||||||
|
- 现有 `/api/admin/metrics` 已有 `RequireAdmin`,返回同样的 Prometheus 文本(保留复用)。
|
||||||
|
- 现有静态资源路由 `/admin/admin.css`、`/admin/admin-shell.js`(`router/router.go:90-97`),
|
||||||
|
迁移时必须同步处理,否则页面丢样式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 分阶段实施
|
||||||
|
|
||||||
|
### 阶段 1|收紧 `RequireAdmin`(前置缺口,最小改动)
|
||||||
|
|
||||||
|
**为什么先做这一步**:`RequireAdmin` 当前在凭证列表为空时**直接放行**(`middleware/admin_auth.go:29-33`)。
|
||||||
|
不修这个,后面给 `/metrics`、`/health` 套 `RequireAdmin` **等于没套**。
|
||||||
|
|
||||||
|
**改动范围**:`middleware/admin_auth.go`、`middleware/auth.go`、`setting/config.go`、`main.go`
|
||||||
|
|
||||||
|
#### 1.1 空凭证不再放行
|
||||||
|
|
||||||
|
将 `len(keys) == 0 → next.ServeHTTP` 改为拒绝 + 显式日志:
|
||||||
|
|
||||||
|
```go
|
||||||
|
keys := setting.GetAdminKeys()
|
||||||
|
if len(keys) == 0 {
|
||||||
|
log.Printf("[admin_auth] 拒绝访问:未配置管理凭证(admin_key / auth_key 均为空) - 路径=%s", r.URL.Path)
|
||||||
|
denyAdmin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 新增独立 `admin_key`(权限隔离)
|
||||||
|
|
||||||
|
- `store`:新增可选的设置项 `admin_key`(**无需数据库迁移**,`settings` 是键值表)。
|
||||||
|
- `setting`:新增 `GetAdminKeys() []string`,取值优先级:
|
||||||
|
|
||||||
|
```
|
||||||
|
admin_key(DB) → 回退 auth_key(DB) → 回退 OPENAI_TTS_API_KEY(env)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `RequireAdmin` 改用 `GetAdminKeys()`;**`middleware.ValidateAPIKey`(`/v1/audio/speech` 用)保持只看 `auth_key`**。
|
||||||
|
- 效果:配了 `admin_key` 后,业务调用凭证**无法**访问管理接口,隔离成立;不配则行为与旧版完全一致(向后兼容)。
|
||||||
|
|
||||||
|
#### 1.3 启动期校验(fail-fast)
|
||||||
|
|
||||||
|
在 `main.go` normal 模式分支加入:管理凭证为空时**拒绝启动**并打印清晰原因
|
||||||
|
(与现有配置损坏 fail-fast 风格一致)。
|
||||||
|
|
||||||
|
> ⚠️ **破坏性提示**:若你的部署目前未配置任何凭证,本阶段后服务将无法启动。
|
||||||
|
> 这是刻意的——此前那种状态下管理接口是完全裸奔的。请先配好 `auth_key`(或 `admin_key`)再升级。
|
||||||
|
|
||||||
|
#### 1.4 ✅ 阶段 1 回归
|
||||||
|
|
||||||
|
- [ ] `go build ./... && go vet ./...` 通过
|
||||||
|
- [ ] 配置 `auth_key` 后:`/api/admin/overview` 带正确 key 返回 200,错误 key 返回 401
|
||||||
|
- [ ] 不配置任何凭证时启动:进程拒绝启动并给出明确日志
|
||||||
|
- [ ] 配置独立 `admin_key` 后:用 `auth_key` 访问 `/api/admin/*` 返回 401
|
||||||
|
- [ ] `/v1/audio/speech` 用 `auth_key` 调用**不受影响**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 2|收口健康 / 指标端点(本版本核心目标)
|
||||||
|
|
||||||
|
**改动范围**:`router/router.go`、`controller/tts.go`、`middleware/`(新增 CIDR 白名单)、`setting/config.go`
|
||||||
|
|
||||||
|
#### 2.1 `/health` 拆分为两个端点
|
||||||
|
|
||||||
|
| 端点 | 鉴权 | 返回 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /healthz` | **无鉴权** | 仅 `200` + 字面量 `ok`,**不含任何字段** |
|
||||||
|
| `GET /health` | `RequireAdmin` | 现有完整 JSON(版本/内存/配置状态等) |
|
||||||
|
|
||||||
|
> ⚠️ **`/healthz` 必须与 `/health` 鉴权在同一次改动内落地**。分开部署会出现探针空窗,
|
||||||
|
> 导致 K8s liveness 失败、Pod 反复重启。
|
||||||
|
|
||||||
|
> 📌 探针迁移提醒:若现有 K8s `livenessProbe` / `readinessProbe` / Docker `HEALTHCHECK`
|
||||||
|
> 指向 `/health`,需同步改指 `/healthz`。README 中要写明。
|
||||||
|
|
||||||
|
#### 2.2 `/metrics` 收口 + 可选内网白名单
|
||||||
|
|
||||||
|
新环境变量 `METRICS_ALLOW_CIDR`(逗号分隔多个 CIDR):
|
||||||
|
|
||||||
|
- **已配置**:在根路径注册 `/metrics`,包裹 `MetricsIPAllowListMiddleware`;
|
||||||
|
非白名单 IP 返回 **404**(不返回 403,避免向扫描者确认端点存在)。
|
||||||
|
- **未配置**:根路径 `/metrics` **完全不注册**,访问 404。
|
||||||
|
- 两种情况都**始终**提供鉴权版 `GET /api/admin/metrics`(已存在,供面板使用)。
|
||||||
|
|
||||||
|
中间件实现要点:复用 `middleware.GetClientIP(r)`(已处理 XFF / `TRUSTED_PROXY_HOPS`),
|
||||||
|
用标准库 `net/netip` 解析 CIDR。
|
||||||
|
|
||||||
|
> ⚠️ **Docker 环境**:`METRICS_ALLOW_CIDR` 必须填**容器内网网段**(如 `172.16.0.0/12`),
|
||||||
|
> **不要填 `127.0.0.1/32`** —— 那是容器自身的回环,Prometheus 在宿主机或其他容器里进不来。
|
||||||
|
|
||||||
|
#### 2.3 `/dashboard` 与 `/api/setup/status` 收口
|
||||||
|
|
||||||
|
- `/dashboard`:套 `RequireAdmin`。它是本版本**单点泄漏最严重**的端点(一个 URL 暴露全部健康数据)。
|
||||||
|
- `/api/setup/status`:套 `RequireAdmin`。
|
||||||
|
- **前端状态提示**:`/dashboard` 的 HTML 外壳本身不敏感,前端登录判断逻辑保持现状
|
||||||
|
(`sessionStorage` 无 key → 显示登录视图)。**不在服务端做页面级跳转**,原因见 [附录 B](#附录-b为什么不做-cookie-会话)。
|
||||||
|
|
||||||
|
#### 2.4 ✅ 阶段 2 回归
|
||||||
|
|
||||||
|
- [ ] 未配置 `METRICS_ALLOW_CIDR`:根 `/metrics`、`/health` 返回 404
|
||||||
|
- [ ] 配置 `METRICS_ALLOW_CIDR=127.0.0.1/32`:本机可访问根 `/metrics`,其他 IP 返回 404
|
||||||
|
- [ ] `/healthz` 无鉴权可访问且**不含任何字段**
|
||||||
|
- [ ] `/health` 无凭证返回 401,带管理凭证返回完整 JSON
|
||||||
|
- [ ] `/dashboard` 无凭证返回 401
|
||||||
|
- [ ] `/api/setup/status` 无凭证返回 401
|
||||||
|
- [ ] 面板内 `/api/admin/metrics`、`/api/admin/health` 正常返回
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 3|路由分层与路径迁移
|
||||||
|
|
||||||
|
**改动范围**:`router/router.go`、`controller/`(新增两个桩 handler + 页面路由别名)
|
||||||
|
|
||||||
|
#### 3.1 分层路由注册
|
||||||
|
|
||||||
|
```go
|
||||||
|
apiPublic := r.PathPrefix("/api/public").Subrouter()
|
||||||
|
// 预留空壳,暂不注册业务
|
||||||
|
|
||||||
|
apiUser := r.PathPrefix("/api/user").Subrouter()
|
||||||
|
apiUser.Use(middleware.RequireAdmin) // 单用户模式下与 admin 等价,v0.4.0 再分化
|
||||||
|
|
||||||
|
apiAdmin := r.PathPrefix("/api/admin").Subrouter()
|
||||||
|
apiAdmin.Use(middleware.RequireAdmin)
|
||||||
|
```
|
||||||
|
|
||||||
|
将现有管理 handler 挂到 `apiAdmin`(**复用 handler 本体,不改业务逻辑**):
|
||||||
|
|
||||||
|
| 旧路径 | 新路径 |
|
||||||
|
|---|---|
|
||||||
|
| `GET/POST /api/voices` | `GET/POST /api/admin/voices` |
|
||||||
|
| `DELETE /api/voices/{name}` | `DELETE /api/admin/voices/{name}` |
|
||||||
|
| `PATCH /api/voices/{name}/toggle` | `PATCH /api/admin/voices/{name}/toggle` |
|
||||||
|
| `GET/PUT /api/settings` | `GET/PUT /api/admin/settings` |
|
||||||
|
| `PUT /api/settings/api-key` | `PUT /api/admin/settings/api-key` |
|
||||||
|
| `PUT /api/settings/auth-key` | `PUT /api/admin/settings/auth-key` |
|
||||||
|
| `PUT /api/settings/cors` | `PUT /api/admin/settings/cors` |
|
||||||
|
| `GET /api/admin/overview` | 位置不变 |
|
||||||
|
| `GET /api/admin/metrics` | 位置不变 |
|
||||||
|
|
||||||
|
#### 3.2 旧路径兼容:用**别名**,不要重定向
|
||||||
|
|
||||||
|
**旧路径指向同一个 handler,不做 301/308 跳转。**
|
||||||
|
|
||||||
|
理由(三者对比):
|
||||||
|
|
||||||
|
| 方案 | 问题 |
|
||||||
|
|---|---|
|
||||||
|
| 301 | 会把 POST/PUT/PATCH/DELETE **降级成 GET**(RFC 7231),且浏览器**永久缓存**,客户端察觉不到路径变化 |
|
||||||
|
| 308 | 保留方法和请求体,比 301 好;但多一次往返,对非浏览器脚本仍是行为变化 |
|
||||||
|
| **别名(采用)** | 两个路径直接可用,零往返、零破坏,老脚本完全无感 |
|
||||||
|
|
||||||
|
旧路径在当前版本**保留**,并在代码注释标注"过渡期别名,未来版本移除"。
|
||||||
|
|
||||||
|
#### 3.3 页面路由迁移
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 新入口
|
||||||
|
r.HandleFunc("/dashboard", middleware.RequireAdmin(serveAdmin(dashboardHTML))).Methods("GET")
|
||||||
|
r.HandleFunc("/dashboard/login", serveAdmin(adminLoginHTML)).Methods("GET")
|
||||||
|
r.HandleFunc("/dashboard/voices", middleware.RequireAdmin(serveAdmin(adminVoicesHTML))).Methods("GET")
|
||||||
|
r.HandleFunc("/dashboard/settings", middleware.RequireAdmin(serveAdmin(adminSettingsHTML))).Methods("GET")
|
||||||
|
|
||||||
|
// 静态资源:必须同步迁移,否则面板丢样式/脚本
|
||||||
|
r.HandleFunc("/dashboard/admin.css", ...) // 原 /admin/admin.css
|
||||||
|
r.HandleFunc("/dashboard/admin-shell.js", ...) // 原 /admin/admin-shell.js
|
||||||
|
|
||||||
|
// 旧入口 301 永久重定向(页面用 301 正确)
|
||||||
|
r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusMovedPermanently)
|
||||||
|
}).Methods("GET")
|
||||||
|
```
|
||||||
|
|
||||||
|
前端同步改动(`router/*.html`、`admin-shell.js`):
|
||||||
|
|
||||||
|
- 页面间跳转与链接:`/admin` → `/dashboard`,`/admin/login` → `/dashboard/login` 等
|
||||||
|
- API 调用地址迁移到 `/api/admin/*`
|
||||||
|
- 静态资源引用 `/admin/admin.css` → `/dashboard/admin.css`(同理 `admin-shell.js`)
|
||||||
|
- `admin-shell.js` 的 401 拦截逻辑改为跳 `/dashboard/login`
|
||||||
|
|
||||||
|
> 📌 注意:`/dashboard` 原本是**服务状态预览页**(`router/health.html`)。迁入后该页面的定位
|
||||||
|
> 变成"管理后台首页",健康/指标数据改由 `/api/admin/health`、`/api/admin/metrics` 提供。
|
||||||
|
> 若仍希望保留独立的只读状态页,请单独确认(本版本不提供匿名版本)。
|
||||||
|
|
||||||
|
#### 3.4 桩接口
|
||||||
|
|
||||||
|
| 端点 | 鉴权 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/user/usage` | `RequireAdmin` | 单用户模式返回全局用量统计(桩,v0.4.0 改为按用户过滤) |
|
||||||
|
| `GET /v1/dashboard/billing/usage` | Bearer 业务 key | 返回用量统计,对齐 OpenAI 返回格式 |
|
||||||
|
|
||||||
|
#### 3.5 ✅ 阶段 3 回归
|
||||||
|
|
||||||
|
- [ ] `/admin` 301 跳 `/dashboard`;`/dashboard` 各页面与静态资源正常加载(无 404、无样式丢失)
|
||||||
|
- [ ] 面板内音色增删改、全局设置、CORS 配置全部正常
|
||||||
|
- [ ] 旧路径 `/api/voices`、`/api/settings` **直接可用**(不是重定向),行为与新路径一致
|
||||||
|
- [ ] `GET /api/user/usage`、`GET /v1/dashboard/billing/usage` 正常返回
|
||||||
|
- [ ] 用业务 Bearer key 访问 `/api/admin/*` 返回 401
|
||||||
|
- [ ] 完整安装流程跑通,装完跳转 `/dashboard`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 4|文档、配置、发布
|
||||||
|
|
||||||
|
**改动范围**:`README.md`、`.env.example`、`docker-compose.yml`、`CHANGELOG.md`
|
||||||
|
|
||||||
|
1. **README.md**
|
||||||
|
- WebUI 入口改为 `/dashboard`;注明 `/admin` 为旧兼容重定向
|
||||||
|
- 端点表更新:`/metrics`、`/health` 默认 404;新增 `/healthz`、`METRICS_ALLOW_CIDR`、`admin_key` 说明
|
||||||
|
- 公网部署安全清单更新(整节重写,明确"哪些端点不鉴权、必须靠什么挡")
|
||||||
|
- 探针迁移提醒:K8s / Docker HEALTHCHECK 改指 `/healthz`
|
||||||
|
2. **`.env.example`**:新增 `METRICS_ALLOW_CIDR` 注释(含 Docker 网段警告)
|
||||||
|
3. **`docker-compose.yml`**:`METRICS_ALLOW_CIDR` 注释示例
|
||||||
|
4. **`CHANGELOG.md`**:v0.3.0 条目,明确列出 Breaking Change
|
||||||
|
|
||||||
|
#### Release Note · Breaking Change 清单
|
||||||
|
|
||||||
|
1. **`/metrics`、`/health`、`/dashboard` 不再匿名可读**。Prometheus 抓取请用
|
||||||
|
`/api/admin/metrics`(带 Bearer),或配置 `METRICS_ALLOW_CIDR`;
|
||||||
|
探针请改用 `/healthz`。
|
||||||
|
2. **未配置任何凭证时服务拒绝启动**(此前管理接口完全裸奔)。
|
||||||
|
3. **管理凭证与业务凭证可分离**:新增 `admin_key`;配了之后业务 key 无法访问管理接口。
|
||||||
|
4. **管理接口迁移到 `/api/admin/*`**;旧路径保留为**别名(非重定向)**,未来版本移除。
|
||||||
|
5. **管理入口迁移到 `/dashboard`**;`/admin` 301 永久重定向。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 阶段 5|全量回归测试(发布前必须全部通过)
|
||||||
|
|
||||||
|
> ⚠️ **本项目测试文件不入库**(`.gitignore` 的 `*_test.go`),CI 只能守住"编译 + vet",
|
||||||
|
> **守不住行为回归**。因此下面的手工回归是发布前的唯一防线,必须逐条执行并记录结果。
|
||||||
|
|
||||||
|
### ✨ 安装流程
|
||||||
|
1. 不设 `TTS_ADMIN_KEY` 启动 → 终端打印一次性密钥(带醒目警告)
|
||||||
|
2. 不带密钥 `POST /api/setup` → 401;带密钥 → 安装成功
|
||||||
|
3. 安装完成跳转 `/dashboard`;进程重启后旧一次性密钥失效
|
||||||
|
4. 已安装状态访问 `/setup` → 自动跳 `/dashboard`
|
||||||
|
|
||||||
|
### ✨ 管理面板
|
||||||
|
5. 未登录访问 `/dashboard` → 显示登录视图;登录后完整可用
|
||||||
|
6. 访问 `/admin` → 301 跳 `/dashboard`
|
||||||
|
7. 音色 CRUD、全局设置、CORS 修改全部正常
|
||||||
|
8. 仪表盘通过 `/api/admin/health`、`/api/admin/metrics` 渲染正常
|
||||||
|
9. 用量面板读取 `/api/user/usage` 正常
|
||||||
|
|
||||||
|
### ✨ 指标与健康端点
|
||||||
|
10. 不配 `METRICS_ALLOW_CIDR` → 根 `/metrics`、`/health` 返回 404
|
||||||
|
11. 配 `METRICS_ALLOW_CIDR=127.0.0.1/32` → 本机根路径可访问,其他 IP 404
|
||||||
|
12. `/healthz` 匿名可访问且**响应体不含任何业务字段**
|
||||||
|
|
||||||
|
### ✨ 业务接口
|
||||||
|
13. `/v1/audio/speech` OpenAI TTS 合成完全正常
|
||||||
|
14. `GET /v1/dashboard/billing/usage` 合法 key → 200;非法 key → 401
|
||||||
|
15. 业务 Bearer key 访问 `/api/admin/*` → 401(隔离生效)
|
||||||
|
|
||||||
|
### ✨ 存量兼容
|
||||||
|
16. 旧管理接口路径`/api/voices`、`/api/settings` 直接可用,行为与新路径一致
|
||||||
|
|
||||||
|
### ✨ 安全场景
|
||||||
|
17. 未配置凭证时服务拒绝启动
|
||||||
|
18. 匿名无法获取任何健康 / 指标数据
|
||||||
|
19. 旧 `tts.db` 直接复用升级,**无需数据库迁移**
|
||||||
|
|
||||||
|
### ✨ 本地自动化
|
||||||
|
20. `go build ./... && go vet ./...` 通过
|
||||||
|
21. `go test ./... -count=1` 全绿
|
||||||
|
(含抓出过 `VoiceList` 语义 bug 的那批集成测试,是本次改造最重要的安全网)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 发布 v0.3.0
|
||||||
|
|
||||||
|
1. 阶段 5 全部通过;Docker 镜像构建测试通过
|
||||||
|
2. `git tag v0.3.0` 并推送
|
||||||
|
3. 创建 Release,粘贴 Release Notes,**明确列出 5 条 Breaking Change**
|
||||||
|
4. 创建维护分支 `v0.3.x`,用于后续 bugfix 与安全补丁
|
||||||
|
5. `develop` 继续迭代 v0.4.0;上游适配器重构可并行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. v0.4.0 后续规划(仅规划,不在 v0.3.0 实现)
|
||||||
|
|
||||||
|
v0.3.0 已把**路由分层**和**权限隔离**打好;v0.4.0 只需填充业务逻辑。
|
||||||
|
|
||||||
|
1. `store` 新增 `users` 表,区分普通用户 / 管理员角色
|
||||||
|
2. 引入真正的会话机制(此时才需要 Cookie + CSRF,或采用 Bearer-per-user)
|
||||||
|
3. `/api/user/*` 从桩转真实业务:个人用量、个人 API 密钥管理、个人调用日志
|
||||||
|
4. `/api/admin/*` 实现用户 CRUD、配额管理;音色归属绑定用户
|
||||||
|
5. SPA 扩展个人中心、密钥管理页面
|
||||||
|
6. `/v1/dashboard/billing/usage` 按 API-Key 归属过滤统计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险注意事项
|
||||||
|
|
||||||
|
| # | 风险 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **`/healthz` 与 `/health` 加鉴权分批上线** → 探针空窗、Pod 重启循环 | 必须同一批改动内落地 |
|
||||||
|
| 2 | **未配凭证导致服务启动失败**(阶段 1 的破坏性结果) | 升级前先配 `auth_key`;README 与 Release Note 明确提示 |
|
||||||
|
| 3 | Docker 内 `METRICS_ALLOW_CIDR` 填 `127.0.0.1/32` | 必须填容器内网网段 |
|
||||||
|
| 4 | **禁止把 `RequireAdmin` 套到 `/setup`** | 安装阶段无数据库、无凭证,保留 `InstallGuard` |
|
||||||
|
| 5 | 旧路径若用重定向会导致 POST 降级 / 客户端无感 | 采用**别名**,不重定向 |
|
||||||
|
| 6 | 静态资源路由遗漏导致面板丢样式 | 迁移时同步 `/dashboard/admin.css`、`/dashboard/admin-shell.js` |
|
||||||
|
| 7 | **CI 跑不到测试**(测试文件不入库) | 每阶段结束本地执行 `go test ./... -count=1` |
|
||||||
|
| 8 | 误改 `adapter/`、`store/` 业务模型 | 本改造不涉及;改动仅限 router / middleware / controller / 前端 / 文档 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A|与初版方案的差异
|
||||||
|
|
||||||
|
| 初版方案 | 本版 | 原因 |
|
||||||
|
|---|---|---|
|
||||||
|
| 阶段 0 "重命名 `AdminSessionMiddleware`" | **删除该阶段** | 该中间件**不存在**;实为新建 Cookie 会话层,会把"低风险重构"变成破坏性变更 |
|
||||||
|
| 新增 `UserSessionMiddleware` | **不做** | 没有会话机制;`/dashboard` 页面请求不带 `sessionStorage`,服务端无从判断登录态 |
|
||||||
|
| 新增 `CSRFMiddleware` | **不做** | Bearer 认证免疫 CSRF(浏览器不会自动附带 `Authorization`);CORS 已在中间件层 403 非白名单 Origin |
|
||||||
|
| Cookie `HttpOnly` / `SameSite=Strict` | **不做** | 不使用 Cookie |
|
||||||
|
| `promhttp.Handler()` | `metrics.Meter.Handler()` | 项目未引入 `prometheus/client_golang` |
|
||||||
|
| `controller.AdminSPAPageHandler` / `AdminLoginPageHandler` | `serveAdmin(adminHTML)` 等 | 这两个 handler 不存在 |
|
||||||
|
| 漏点只列 `/metrics`、`/health` | 补入 **`/dashboard`**、`/api/setup/status` | `/dashboard` 聚合度最高;实测确认这两个也匿名可达 |
|
||||||
|
| 旧 API 路径 301 重定向 | **别名**,不重定向 | 301 会把 POST 降级为 GET 且被永久缓存 |
|
||||||
|
| 未提静态资源路由 | 明确 `/dashboard/admin.css` 等 | 遗漏会导致面板丢样式 |
|
||||||
|
| `RequireAdmin` 空凭证放行未处理 | **阶段 1 专门修复 + fail-fast** | 不修则后续所有鉴权改动形同虚设 |
|
||||||
|
|
||||||
|
### 初版保留不变的部分
|
||||||
|
|
||||||
|
分阶段 + 每阶段可编译可回归的原则、`InstallGuard` 保持不动、`/v1/dashboard/billing/usage`
|
||||||
|
走 Bearer、`/api/public` 与 `/api/user` 留桩、Docker CIDR 网段警告、v0.4.0 规划、发布流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 B|为什么不做 Cookie 会话
|
||||||
|
|
||||||
|
1. **不解决问题**:目标是"健康数据不外泄",用现有 `RequireAdmin` 即可 100% 达成,
|
||||||
|
不需要会话层。
|
||||||
|
2. **服务端拿不到 SPA 登录态**:前端凭证存 `sessionStorage`,**不随请求发送**,
|
||||||
|
所以服务端无法判断 `/dashboard` 页面请求是否已登录。初版要的"未登录访问 `/dashboard`
|
||||||
|
自动跳登录页"在服务端**做不到**——除非引入 Cookie,而这正是初版的前提缺失之处。
|
||||||
|
3. **CSRF 需求随之消失**:Cookie 才需要 CSRF 防护;Bearer + `sessionStorage` 天然免疫。
|
||||||
|
不做 Cookie 就不必实现 token 发放与轮换,也避开同源 SPA 下 `SameSite=Strict` 的坑。
|
||||||
|
4. **破坏现有客户端**:现有用 `Authorization: Bearer` 调管理 API 的脚本会全部 401,
|
||||||
|
与"给外部脚本过渡期"的目标冲突。
|
||||||
|
5. **真正的权限问题另有其解**:当前"一个 key 既是管理密码又是业务密钥"才是实质缺陷,
|
||||||
|
用新增可选 `admin_key` 即可分离,成本约 20 行,无需引入会话。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本:v2 · 校正基线:`develop` @ `49f57e4`(2026-10-04)*
|
||||||
|
*初版:v1(外部产出)· 校正原因见附录 A*
|
||||||
@@ -35,6 +35,8 @@ func main() {
|
|||||||
setting.InitAllConfigs()
|
setting.InitAllConfigs()
|
||||||
metrics.Init()
|
metrics.Init()
|
||||||
middleware.InitRateLimiter()
|
middleware.InitRateLimiter()
|
||||||
|
// v0.3.0:根路径 /metrics 的内网白名单(未配置则根路径完全不注册)
|
||||||
|
middleware.InitMetricsAllowList()
|
||||||
|
|
||||||
// 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式
|
// 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式
|
||||||
dbPath := ttsDBPath()
|
dbPath := ttsDBPath()
|
||||||
@@ -85,6 +87,21 @@ func main() {
|
|||||||
|
|
||||||
// 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态)
|
// 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态)
|
||||||
setting.LogStartupSummary()
|
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)
|
log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
|
||||||
|
|
||||||
controller.InitController()
|
controller.InitController()
|
||||||
@@ -113,8 +130,13 @@ func main() {
|
|||||||
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", 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("Admin WebUI: http://localhost:%s/admin", setting.Server.Port)
|
||||||
}
|
}
|
||||||
log.Printf("Health check: http://localhost:%s/health", setting.Server.Port)
|
log.Printf("Health check(匿名存活探针): http://localhost:%s/healthz", setting.Server.Port)
|
||||||
log.Printf("Metrics: http://localhost:%s/metrics", 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 {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Server failed to start: %v", err)
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
|
|||||||
+17
-10
@@ -9,14 +9,19 @@ import (
|
|||||||
"github.com/volcano-tts/tts-api/setting"
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequireAdmin 是 /admin 路由的鉴权中间件,复用 OPENAI_TTS_API_KEY。
|
// RequireAdmin 是管理接口(/api/admin/*、/api/voices*、/api/settings*)的鉴权中间件。
|
||||||
// 行为:
|
|
||||||
// - Auth.APIKeys 为空 → 所有请求放行(等同无鉴权)
|
|
||||||
// - Authorization 头 Bearer token 在列表中 → 放行
|
|
||||||
// - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'}
|
|
||||||
//
|
//
|
||||||
// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys,
|
// 凭证来源:setting.GetAdminKeys(),优先级 admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。
|
||||||
// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。
|
// 与业务侧鉴权(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 {
|
func RequireAdmin(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
|
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
|
||||||
@@ -25,10 +30,12 @@ func RequireAdmin(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := setting.GetAuthAPIKeys()
|
keys := setting.GetAdminKeys()
|
||||||
if len(keys) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// okHandler 是被保护的假 handler。
|
||||||
|
func okHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAdmin_EmptyCredentialDenies 凭证未配置时必须拒绝,不能放行。
|
||||||
|
//
|
||||||
|
// 这是 v0.3.0 阶段 1 的核心修复:v0.3.0 之前 len(keys)==0 直接放行,
|
||||||
|
// 导致"没配 key"的部署上管理接口完全裸奔,也让后续给 /metrics、/health
|
||||||
|
// 套 RequireAdmin 的加固形同虚设。
|
||||||
|
func TestRequireAdmin_EmptyCredentialDenies(t *testing.T) {
|
||||||
|
t.Cleanup(func() { setting.SetAdminKeys(nil, "") })
|
||||||
|
setting.SetAdminKeys(nil, "")
|
||||||
|
|
||||||
|
h := RequireAdmin(okHandler())
|
||||||
|
|
||||||
|
// 完全不带 Authorization
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest("GET", "/api/admin/overview", nil))
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("无凭证 + 未配置 admin credential: code=%d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带任意 Bearer token 也必须拒绝(列表为空,没有合法 token 可言)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
r2 := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||||
|
r2.Header.Set("Authorization", "Bearer anything")
|
||||||
|
h.ServeHTTP(w2, r2)
|
||||||
|
if w2.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("任意 token + 未配置 admin credential: code=%d, want 401", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAdmin_ConfiguredCredentialEnforced 配了凭证后:命中放行、错误拒绝。
|
||||||
|
func TestRequireAdmin_ConfiguredCredentialEnforced(t *testing.T) {
|
||||||
|
t.Cleanup(func() { setting.SetAdminKeys(nil, "") })
|
||||||
|
setting.SetAdminKeys([]string{"admin-secret"}, "admin_key")
|
||||||
|
|
||||||
|
h := RequireAdmin(okHandler())
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"正确凭证", "Bearer admin-secret", http.StatusOK},
|
||||||
|
{"错误凭证", "Bearer wrong-secret", http.StatusUnauthorized},
|
||||||
|
{"缺少 Bearer 前缀", "admin-secret", http.StatusUnauthorized},
|
||||||
|
{"空 Authorization", "", http.StatusUnauthorized},
|
||||||
|
{"仅空白 token", "Bearer ", http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||||
|
if c.authHeader != "" {
|
||||||
|
r.Header.Set("Authorization", c.authHeader)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != c.wantStatus {
|
||||||
|
t.Errorf("code=%d, want %d", w.Code, c.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAdmin_OptionsAlwaysAllowed OPTIONS 预检必须放行(浏览器预检不带 Authorization)。
|
||||||
|
func TestRequireAdmin_OptionsAlwaysAllowed(t *testing.T) {
|
||||||
|
t.Cleanup(func() { setting.SetAdminKeys(nil, "") })
|
||||||
|
setting.SetAdminKeys([]string{"admin-secret"}, "admin_key")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h := RequireAdmin(okHandler())
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest("OPTIONS", "/api/admin/overview", nil))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("OPTIONS: code=%d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminAndBusinessCredentialIsolation 权限隔离:
|
||||||
|
// 配置了独立 admin_key 后,业务凭证(auth_key)不得访问管理接口;
|
||||||
|
// 未配置 admin_key 时回退,业务凭证仍可管理(向后兼容)。
|
||||||
|
func TestAdminAndBusinessCredentialIsolation(t *testing.T) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
setting.SetAdminKeys(nil, "")
|
||||||
|
setting.SetAuthAPIKeys(nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
const businessKey = "business-key"
|
||||||
|
const adminKey = "admin-key"
|
||||||
|
|
||||||
|
// 业务侧凭证固定为 businessKey(模拟 /v1/audio/speech 用的 key)
|
||||||
|
setting.SetAuthAPIKeys([]string{businessKey})
|
||||||
|
|
||||||
|
// --- 场景 A:配置了独立 admin_key(隔离生效) ---
|
||||||
|
setting.SetAdminKeys([]string{adminKey}, "admin_key")
|
||||||
|
h := RequireAdmin(okHandler())
|
||||||
|
|
||||||
|
wBiz := httptest.NewRecorder()
|
||||||
|
rBiz := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||||
|
rBiz.Header.Set("Authorization", "Bearer "+businessKey)
|
||||||
|
h.ServeHTTP(wBiz, rBiz)
|
||||||
|
if wBiz.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("隔离生效时,业务 key 访问管理接口: code=%d, want 401", wBiz.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
wAdmin := httptest.NewRecorder()
|
||||||
|
rAdmin := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||||
|
rAdmin.Header.Set("Authorization", "Bearer "+adminKey)
|
||||||
|
h.ServeHTTP(wAdmin, rAdmin)
|
||||||
|
if wAdmin.Code != http.StatusOK {
|
||||||
|
t.Errorf("隔离生效时,admin_key 访问管理接口: code=%d, want 200", wAdmin.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 场景 B:未配置 admin_key,回退用业务凭证(向后兼容) ---
|
||||||
|
setting.SetAdminKeys([]string{businessKey}, "auth_key")
|
||||||
|
|
||||||
|
wFallback := httptest.NewRecorder()
|
||||||
|
rFallback := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||||
|
rFallback.Header.Set("Authorization", "Bearer "+businessKey)
|
||||||
|
h.ServeHTTP(wFallback, rFallback)
|
||||||
|
if wFallback.Code != http.StatusOK {
|
||||||
|
t.Errorf("回退模式下,业务 key 应可管理: code=%d, want 200", wFallback.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowListFrom 把逗号分隔的 CIDR 串解析成白名单,供测试直接注入。
|
||||||
|
func allowListFrom(t *testing.T, spec string) []*net.IPNet {
|
||||||
|
t.Helper()
|
||||||
|
var out []*net.IPNet
|
||||||
|
for _, part := range strings.Split(spec, ",") {
|
||||||
|
entry := strings.TrimSpace(part)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, n, err := net.ParseCIDR(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("测试用例里的 CIDR 非法 %q: %v", entry, err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqFrom 构造带指定来源地址的请求。
|
||||||
|
func reqFrom(remoteAddr string) *http.Request {
|
||||||
|
r := httptest.NewRequest("GET", "/metrics", nil)
|
||||||
|
r.RemoteAddr = remoteAddr
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPAllowed 验证 CIDR 白名单判定:
|
||||||
|
// 命中放行、未命中拒绝,解析失败的 IP 一律拒绝(宁可拒绝也不误放行)。
|
||||||
|
func TestIPAllowed(t *testing.T) {
|
||||||
|
// 直接构造白名单,不依赖环境变量
|
||||||
|
metricsAllowList = allowListFrom(t, "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,::1/128")
|
||||||
|
metricsAllowConfigured = true
|
||||||
|
t.Cleanup(func() {
|
||||||
|
metricsAllowList = nil
|
||||||
|
metricsAllowConfigured = false
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"本机 IPv4 命中 /32", "127.0.0.1", true},
|
||||||
|
{"10.x 命中 /8", "10.1.2.3", true},
|
||||||
|
{"172.16.x 命中 /12", "172.16.5.9", true},
|
||||||
|
{"172.31.x 仍在 /12 内", "172.31.255.254", true},
|
||||||
|
{"IPv6 回环命中 /128", "::1", true},
|
||||||
|
{"公网 IP 不在白名单", "8.8.8.8", false},
|
||||||
|
{"172.32.x 超出 /12 范围", "172.32.0.1", false},
|
||||||
|
{"192.168.x 未配置", "192.168.1.1", false},
|
||||||
|
{"空 IP 拒绝", "", false},
|
||||||
|
{"非法 IP 拒绝", "not-an-ip", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := ipAllowed(c.ip); got != c.want {
|
||||||
|
t.Errorf("ipAllowed(%q) = %v, want %v", c.ip, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPAllowed_EmptyListRejectsAll 白名单为空时全部拒绝(未配置 = 不开放)。
|
||||||
|
func TestIPAllowed_EmptyListRejectsAll(t *testing.T) {
|
||||||
|
metricsAllowList = nil
|
||||||
|
metricsAllowConfigured = false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
metricsAllowList = nil
|
||||||
|
metricsAllowConfigured = false
|
||||||
|
})
|
||||||
|
|
||||||
|
if ipAllowed("127.0.0.1") {
|
||||||
|
t.Error("白名单为空时应拒绝所有 IP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetricsIPAllowList_Scope 白名单内放行、白名单外 404。
|
||||||
|
// 用 404 而不是 403,是为了不向扫描者确认"该端点存在,只是你没权限"。
|
||||||
|
func TestMetricsIPAllowList_Scope(t *testing.T) {
|
||||||
|
metricsAllowList = allowListFrom(t, "10.0.0.0/8")
|
||||||
|
t.Cleanup(func() { metricsAllowList = nil })
|
||||||
|
|
||||||
|
h := MetricsIPAllowList(okHandler())
|
||||||
|
|
||||||
|
// 命中白名单 → 放行到 next
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, reqFrom("10.1.1.1:1234"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("白名单内来源: code=%d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单外(RFC 5737 文档地址)→ 404,且不应触达 next
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w2, reqFrom("192.0.2.1:1234"))
|
||||||
|
if w2.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("白名单外来源: code=%d, want 404", w2.Code)
|
||||||
|
}
|
||||||
|
if w2.Body.String() == "ok" {
|
||||||
|
t.Error("白名单外来源不应触达被保护的 handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetricsIPAllowList_XForwardedForSpoof 伪造 X-Forwarded-For 不能绕过白名单。
|
||||||
|
//
|
||||||
|
// GetClientIP 的启发式模式(trustedProxyHops==0,默认)从 XFF 链**尾部**取第一个
|
||||||
|
// **公网** IP,刻意跳过私网跳 —— 这样攻击者无法用 "X-Forwarded-For: <内网IP>"
|
||||||
|
// 把自己伪装成白名单来源。本测试锁死这个安全属性。
|
||||||
|
func TestMetricsIPAllowList_XForwardedForSpoof(t *testing.T) {
|
||||||
|
metricsAllowList = allowListFrom(t, "10.0.0.0/8")
|
||||||
|
t.Cleanup(func() { metricsAllowList = nil })
|
||||||
|
|
||||||
|
h := MetricsIPAllowList(okHandler())
|
||||||
|
|
||||||
|
// 直连是私网(像反代),XFF 里塞一个内网 IP 想混进白名单
|
||||||
|
r := reqFrom("127.0.0.1:1234")
|
||||||
|
r.Header.Set("X-Forwarded-For", "10.9.9.9")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
// 启发式模式会跳过私网跳、落到直连地址 127.0.0.1(不在 10.0.0.0/8)→ 404。
|
||||||
|
// 关键断言:伪造的内网 XFF **没有**让它通过。
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
t.Errorf("伪造私网 XFF 不应绕过白名单: code=%d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向对照:XFF 填公网 IP 时,GetClientIP 会采用它,同样不在白名单 → 404
|
||||||
|
r2 := reqFrom("127.0.0.1:1234")
|
||||||
|
r2.Header.Set("X-Forwarded-For", "8.8.8.8")
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w2, r2)
|
||||||
|
if w2.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("公网来源不在白名单: code=%d, want 404", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-9
@@ -72,7 +72,8 @@ func Setup() *mux.Router {
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write(setupHTML)
|
_, _ = w.Write(setupHTML)
|
||||||
}).Methods("GET")
|
}).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/prefill", controller.SetupPrefillHandler).Methods("GET")
|
||||||
r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST")
|
r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST")
|
||||||
|
|
||||||
@@ -100,6 +101,9 @@ func Setup() *mux.Router {
|
|||||||
r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET")
|
r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET")
|
||||||
// /api/admin/metrics (鉴权);返 Prometheus 文本
|
// /api/admin/metrics (鉴权);返 Prometheus 文本
|
||||||
r.Handle("/api/admin/metrics", middleware.RequireAdmin(http.HandlerFunc(controller.AdminMetricsHandler))).Methods("GET")
|
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 (鉴权)
|
// /api/voices 音色 CRUD (鉴权)
|
||||||
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
|
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
|
||||||
@@ -112,15 +116,28 @@ func Setup() *mux.Router {
|
|||||||
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
|
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/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/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
|
||||||
|
// v0.3.0: 管理凭证(admin_key)独立配置端点。
|
||||||
|
// 同时注册新旧两个路径:/api/admin/settings/admin-key 是阶段 3 分层后的正式路径,
|
||||||
|
// /api/settings/admin-key 属于旧前缀,保留为别名(与阶段 3 的别名策略一致)。
|
||||||
|
r.Handle("/api/admin/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).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.Handle("/api/settings/cors", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsCORSHandler))).Methods("PUT")
|
||||||
|
|
||||||
// 业务路由
|
// 业务路由
|
||||||
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
|
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) {
|
// v0.3.0 端点收口:
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
// /healthz 匿名存活探针,只回 "ok"(K8s probe / Docker HEALTHCHECK 用)
|
||||||
_, _ = w.Write(dashboardHTML)
|
// /health 详细健康数据,**鉴权**(原本匿名,会泄漏版本/内存/配置错误文本)
|
||||||
}).Methods("GET")
|
// /dashboard 管理看板;**鉴权**(原本匿名,聚合了 /health + /metrics 的全部数据)
|
||||||
|
// /api/admin/health 鉴权版详细健康数据(面板拉取用,风格与 /api/admin/* 一致)
|
||||||
|
//
|
||||||
|
// 注意 /health 的鉴权是"内容协商"式的(见 htmlAwareAdmin):
|
||||||
|
// 浏览器直接地址栏访问时返回登录页而不是 401,保持原有 UX;
|
||||||
|
// 非 HTML 请求(脚本/抓取)不带凭证一律 401。数据安全由 API 层保证。
|
||||||
|
r.HandleFunc("/healthz", controller.HealthzHandler).Methods("GET")
|
||||||
|
r.Handle("/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET")
|
||||||
|
r.Handle("/dashboard", htmlAwareAdmin(serveAdmin(adminHTML))).Methods("GET")
|
||||||
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||||
// 安装模式下,根路径跳 /setup
|
// 安装模式下,根路径跳 /setup
|
||||||
if installer.GetMode() == installer.ModeSetup {
|
if installer.GetMode() == installer.ModeSetup {
|
||||||
@@ -131,13 +148,38 @@ func Setup() *mux.Router {
|
|||||||
http.Redirect(w, req, "/admin", http.StatusFound)
|
http.Redirect(w, req, "/admin", http.StatusFound)
|
||||||
}).Methods("GET")
|
}).Methods("GET")
|
||||||
|
|
||||||
// /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。
|
// 根路径 /metrics 与 /health 的机器访问:
|
||||||
// Prometheus 抓取不带 Origin,因此经过 CORS 中间件时会直接 pass-through。
|
// - 配置了 METRICS_ALLOW_CIDR → 注册 /metrics,包裹内网白名单(非白名单返回 404)
|
||||||
r.Handle("/metrics", metrics.Meter.Handler()).Methods("GET")
|
// - 未配置 → 根路径 /metrics **完全不注册**(访问 404),避免默认对外暴露
|
||||||
|
// /health 已在上面按鉴权注册,不再提供匿名版本。
|
||||||
|
if middleware.MetricsAllowListConfigured() {
|
||||||
|
r.Handle("/metrics", middleware.MetricsIPAllowList(metrics.Meter.Handler())).Methods("GET")
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
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 包的版本未导出。
|
// acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。
|
||||||
// 用途:NotFoundHandler 判断浏览器 Accept。
|
// 用途:NotFoundHandler 判断浏览器 Accept。
|
||||||
func acceptsHTML(accept string) bool {
|
func acceptsHTML(accept string) bool {
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ var (
|
|||||||
ttsTimeout time.Duration = common.DefaultTimeout
|
ttsTimeout time.Duration = common.DefaultTimeout
|
||||||
ttsConfigErr error
|
ttsConfigErr error
|
||||||
authAPIKeys []string
|
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 下读取,
|
// corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取,
|
||||||
// 避免 CORSConfig 整体读时被 Lock 阻塞热路径。
|
// 避免 CORSConfig 整体读时被 Lock 阻塞热路径。
|
||||||
corsAllowAll bool
|
corsAllowAll bool
|
||||||
@@ -108,6 +116,40 @@ func SetAuthAPIKeys(keys []string) {
|
|||||||
authAPIKeys = out
|
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 是否放行所有来源。
|
// GetCORSAllowAll 读 CORS 是否放行所有来源。
|
||||||
func GetCORSAllowAll() bool {
|
func GetCORSAllowAll() bool {
|
||||||
ttsMu.RLock()
|
ttsMu.RLock()
|
||||||
@@ -391,6 +433,18 @@ func LoadRuntimeConfig(s Store) error {
|
|||||||
SetAuthAPIKeys(nil)
|
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 配置:DB > env
|
||||||
// cors_allow_all (bool): 允许所有来源(*)
|
// cors_allow_all (bool): 允许所有来源(*)
|
||||||
// cors_origins (string): 逗号分隔白名单
|
// cors_origins (string): 逗号分隔白名单
|
||||||
@@ -540,6 +594,16 @@ func LogStartupSummary() {
|
|||||||
log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys))
|
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()
|
allowAll := GetCORSAllowAll()
|
||||||
origins := GetCORSOrigins()
|
origins := GetCORSOrigins()
|
||||||
if allowAll {
|
if allowAll {
|
||||||
|
|||||||
Reference in New Issue
Block a user