Files
sun 1e2e37ae97 feat(auth)!: v0.2.4 阶段一 鉴权收紧 + 健康/指标端点收口
本提交由初版方案的「阶段 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 白名单两种形态。
2026-10-04 01:17:06 +08:00

357 lines
13 KiB
Go

package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"runtime"
"strings"
"time"
"github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto"
"github.com/volcano-tts/tts-api/installer"
"github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/setting"
"github.com/volcano-tts/tts-api/store"
"github.com/volcano-tts/tts-api/telemetry"
"github.com/volcano-tts/tts-api/version"
)
var (
volcanoClient *volcano.HTTPClient
adapterRec volcano.MetricsRecorder = metrics.AdapterRecorder{}
)
func InitController() {
volcanoClient = volcano.NewHTTPClient()
}
func truncateForLog(b []byte, max int) string {
if len(b) > max {
return string(b[:max]) + fmt.Sprintf("...(truncated, total %d bytes)", len(b))
}
return string(b)
}
// resolveClientFormat 把 OpenAI 风格的 response_format 映射为最终输出格式;
// 不识别或未指定时回退到 setting.GetTTSOptions().Format。
func resolveClientFormat(reqFmt string) string {
switch strings.ToLower(reqFmt) {
case "mp3", "wav", "opus", "pcm", "aac", "flac":
if reqFmt == "opus" {
return "ogg_opus"
}
return strings.ToLower(reqFmt)
}
return setting.GetTTSOptions().Format
}
// OpenaiTTSHandler 是 /v1/audio/speech 的入口。
func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if r.Method != http.MethodPost {
log.Printf("警告: 错误的方法 - 方法=%s 期望=POST 路径=%s 客户端=%s",
r.Method, r.URL.Path, middleware.GetClientIP(r))
metrics.RequestTotal.Inc(telemetry.Labels{"status": "method_not_allowed", "format": "", "speaker": "", "model": ""})
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 安装模式双保险:即使 InstallGuard 中间件没拦住,这里也 503 + 引导跳转
if installer.GetMode() == installer.ModeSetup {
log.Printf("[tts] 安装模式下拒绝 /v1/audio/speech - 客户端=%s", middleware.GetClientIP(r))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"not installed","code":"install_required","redirect":"/setup"}`))
return
}
if !middleware.ValidateAPIKey(r) {
metrics.AuthFailed.Inc(telemetry.Labels{})
log.Printf("警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s",
r.URL.Path, middleware.GetClientIP(r), r.RemoteAddr)
middleware.SendJSONError(w, http.StatusUnauthorized, "Invalid API key provided.", "invalid_request_error", "invalid_api_key")
return
}
if err := setting.GetTTSConfigErr(); err != nil {
log.Printf("警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s",
err, r.URL.Path, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable")
return
}
r.Body = http.MaxBytesReader(w, r.Body, common.MaxRequestBodySize)
body, err := io.ReadAll(r.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
log.Printf("警告: 请求体过大 - 路径=%s 客户端=%s 限制=%d字节",
r.URL.Path, middleware.GetClientIP(r), common.MaxRequestBodySize)
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
log.Printf("警告: 读取请求体失败 - 路径=%s 客户端=%s 错误=%v",
r.URL.Path, middleware.GetClientIP(r), err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req dto.OpenAITTSRequest
if err := json.Unmarshal(body, &req); err != nil {
log.Printf("警告: JSON 解析失败 - 路径=%s 客户端=%s 错误=%v body前200字节=%q",
r.URL.Path, middleware.GetClientIP(r), err, truncateForLog(body, 200))
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Model != "" {
if len(req.Model) > common.MaxModelNameLength {
log.Printf("警告: Model 名过长 - 路径=%s 客户端=%s 长度=%d 限制=%d",
r.URL.Path, middleware.GetClientIP(r), len(req.Model), common.MaxModelNameLength)
http.Error(w, fmt.Sprintf("Model name too long (max %d characters)", common.MaxModelNameLength), http.StatusBadRequest)
return
}
if strings.ContainsAny(req.Model, "\x00\n\r\t") {
log.Printf("警告: Model 名含非法字符 - 路径=%s 客户端=%s model前50字节=%q",
r.URL.Path, middleware.GetClientIP(r), truncateForLog([]byte(req.Model), 50))
http.Error(w, "Model name contains invalid characters", http.StatusBadRequest)
return
}
}
if req.Input == "" {
log.Printf("警告: input 字段为空 - 路径=%s 客户端=%s", r.URL.Path, middleware.GetClientIP(r))
http.Error(w, "Input text is required", http.StatusBadRequest)
return
}
if len(req.Input) > common.MaxTextLength {
log.Printf("警告: input 文本过长 - 路径=%s 客户端=%s 长度=%d 限制=%d",
r.URL.Path, middleware.GetClientIP(r), len(req.Input), common.MaxTextLength)
http.Error(w, fmt.Sprintf("Input text too long (max %d characters)", common.MaxTextLength), http.StatusBadRequest)
return
}
speed := req.Speed
if speed <= 0 {
speed = common.DefaultSpeed
}
if speed < common.MinSpeed {
speed = common.MinSpeed
}
if speed > common.MaxSpeed {
speed = common.MaxSpeed
}
clientFormat := resolveClientFormat(req.ResponseFormat)
opts := setting.GetTTSOptions()
opts.Text = req.Input
// M3: voice 路由
// - voice 为空 → 走 LoadRuntimeConfig 解析过的 opts.Speaker (已是真 speaker ID,
// default_speaker 是 voice 名,LoadRuntimeConfig 查 voice 表后替换)
// - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model
// - 命中但 enabled=0 → 仍可用(用户显式传 voice 即覆盖 enabled 状态;若想禁用在 admin UI 关掉就行)
// - 未命中 → 400 "unknown voice: <name>"
if req.Voice != "" {
s := GetAdminStore()
if s == nil {
log.Printf("警告: voice=%s 路由但 store 未初始化 - 路径=%s", req.Voice, r.URL.Path)
middleware.SendJSONError(w, http.StatusServiceUnavailable,
"voice routing requires database; not initialized",
"configuration_error", "db_not_ready")
return
}
v, err := s.VoiceGetByName(req.Voice)
if err != nil {
if err == store.ErrNotFound {
log.Printf("警告: 未知 voice=%q - 路径=%s 客户端=%s", req.Voice, r.URL.Path, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusBadRequest,
fmt.Sprintf("unknown voice: '%s'", req.Voice),
"invalid_request_error", "unknown_voice")
return
}
log.Printf("警告: voice 查库失败 - 错误=%v voice=%s", err, req.Voice)
middleware.SendJSONError(w, http.StatusInternalServerError,
"voice lookup failed", "server_error", "db_read_failed")
return
}
// 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照)
if !v.Enabled {
log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusForbidden,
fmt.Sprintf("voice '%s' is disabled", req.Voice),
"invalid_request_error", "voice_disabled")
return
}
opts.Speaker = v.Speaker
opts.ResourceID = v.ResourceID
if v.Model != "" {
opts.Model = v.Model
}
log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s",
req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r))
}
ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout())
defer cancel()
result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec)
duration := time.Since(start)
finalLabels := telemetry.Labels{
"format": clientFormat,
// speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里
//(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测,
//但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。
"speaker": telemetry.SpeakerLabel(opts.Speaker),
"model": opts.Model,
}
if err != nil {
finalLabels["status"] = classifyStatus(err)
metrics.RequestTotal.Inc(finalLabels)
metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": finalLabels["status"], "format": clientFormat})
log.Printf("警告: TTS 合成失败 - 路径=%s 客户端=%s 文本长度=%d 耗时=%v 错误=%v",
r.URL.Path, middleware.GetClientIP(r), len(req.Input), duration, err)
middleware.SendJSONError(w, http.StatusInternalServerError, "TTS synthesis failed.", "server_error", "synthesis_failed")
return
}
finalLabels["status"] = "ok"
metrics.RequestTotal.Inc(finalLabels)
metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": "ok", "format": clientFormat})
w.Header().Set("Content-Type", contentTypeFor(result.Format))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData)))
w.Header().Set("X-Request-Id", result.ReqID)
w.WriteHeader(http.StatusOK)
if n, err := w.Write(result.AudioData); err != nil {
// header 已发,无法改 status code;只记日志供排查(常见:客户端中途断开 → broken pipe / connection reset)
log.Printf("警告: 响应写入失败 - 路径=%s 客户端=%s 已写=%d/%d 错误=%v",
r.URL.Path, middleware.GetClientIP(r), n, len(result.AudioData), err)
}
}
func classifyStatus(err error) string {
if ue, ok := err.(*volcano.UpstreamError); ok {
switch ue.Stage {
case "request":
return "request_error"
case "http":
return fmt.Sprintf("http_%d", ue.Code)
case "stream":
return "upstream_error"
case "wrap":
return "wrap_error"
}
}
return "internal_error"
}
func contentTypeFor(format string) string {
switch strings.ToLower(format) {
case "wav":
return "audio/wav"
case "mp3":
return "audio/mpeg"
case "ogg_opus", "opus":
return "audio/ogg"
case "pcm":
return "audio/L16"
case "aac":
return "audio/aac"
case "flac":
return "audio/flac"
}
return "application/octet-stream"
}
// 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")
// 安装模式下 /health 仍然 200,但通过 installed 字段让探针/运维识别
// (Kubernetes readiness probe 可以用 installed=false 决定是否放流量)
mode := installer.GetMode()
if mode == installer.ModeSetup {
w.WriteHeader(http.StatusOK) // 200,因为进程活着,只是还没初始化
} else if setting.GetTTSConfigErr() != nil {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
env := setting.CheckEnvironmentVariables()
allRequired := env["all_required_vars_set"].(bool)
status := "ok"
if mode == installer.ModeSetup {
status = "not_installed"
} else if !allRequired {
status = "configuration_error"
}
resp := dto.HealthResponse{
Status: status,
Service: "ByteDance TTS to OpenAI API Adapter",
Version: version.Version,
Commit: version.Commit,
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
StartTime: startTime.Format(time.RFC3339),
Memory: collectMemorySnapshot(),
ConfigStatus: dto.ConfigStatusResponse{
AllRequiredVarsSet: allRequired,
ConfigError: setting.GetTTSConfigErr() != nil,
Error: configErrorMessage(setting.GetTTSConfigErr()),
},
Installed: mode == installer.ModeNormal,
Mode: mode.String(),
}
json.NewEncoder(w).Encode(resp)
}
// configErrorMessage 把运行时配置错误(setting.GetTTSConfigErr())安全地转成可对外暴露的字符串。
// 仅在 normal 模式且有错时调用, error 为 nil 时返 "" (被 omitempty 跳过)。
func configErrorMessage(err error) string {
if err == nil {
return ""
}
return err.Error()
}
var startTime time.Time
func SetStartTime(t time.Time) { startTime = t }
func collectMemorySnapshot() map[string]interface{} {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
return map[string]interface{}{
"heap_alloc": ms.HeapAlloc,
"heap_inuse": ms.HeapInuse,
"goroutines": runtime.NumGoroutine(),
}
}