chore: 转移前备份 — cleanup + project book
- 删 VULNERABILITY_REPORT.md(已审完,合并到 commit 历史) - 删 middleware/ratelimit_test.go(单测延后) - .gitignore 加 *_test.go 规则(防误提交) - README XFF 验证段措辞更新 - 新增 PROJECT_BOOK.md(v0.1 待评审) 基线备份:目录迁移到非云盘路径前的快照。
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
# Go test sources (prevent accidental commit; tests live outside the repo by policy)
|
||||||
|
*_test.go
|
||||||
|
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
# 火山 TTS 聚合平台 · 项目书(v0.1)
|
||||||
|
|
||||||
|
> 定位:个人自用(无甲方)· 单机部署 · 单二进制分发
|
||||||
|
> 基线代码:Volcano-Engine-TTS-UI(develop 分支,Go,约 2900 行)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 背景与动机
|
||||||
|
|
||||||
|
现有 `Volcano-Engine-TTS-UI` 是一个"火山引擎 TTS v3 → OpenAI 兼容接口"的适配器,已具备 `/v1/audio/speech`、多格式输出、鉴权限流、Prometheus 观测等能力。当前存在三个核心问题:
|
||||||
|
|
||||||
|
1. **配置全部堆在环境变量里**:API Key、资源 ID、音色、采样率……随音色增长会持续"溢出",无法维护。
|
||||||
|
2. **音色是静态的**:`voice`/`model` 请求字段被接收但忽略,只支持 env 里配死的单音色。
|
||||||
|
3. **首次启动没有入口**:无 UI、无数据库,配置只能靠手写 env。
|
||||||
|
|
||||||
|
### 1.2 项目目标
|
||||||
|
|
||||||
|
把该项目升级为**自用聚合平台**:
|
||||||
|
|
||||||
|
- 用 **SQLite** 接管所有运行时可变配置(全局设置 + 音色库),环境变量只保留 3 个左右引导参数;
|
||||||
|
- 提供**引导式安装 UI**:首次启动(未安装)进入 `/setup` 向导收集配置,写入数据库后即完成安装;
|
||||||
|
- 采用 **lock 文件**作为安装状态判据,支持"损坏自动回退、可重置安装";
|
||||||
|
- `/v1/audio/speech` 的 `voice` 参数**按数据库路由**,实现多音色动态切换;
|
||||||
|
- 保持**单二进制分发**(SQLite 用纯 Go 驱动,`//go:embed` 嵌入引导页)。
|
||||||
|
|
||||||
|
### 1.3 设计原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 数据库为唯一配置源 | 运行时的全局参数、音色全部读库,不做"库 + env 双轨" |
|
||||||
|
| 环境变量只做引导 | 仅保留 DB 路径、端口、初始化凭证 |
|
||||||
|
| lock 是安装门卫 | 存在 = 已安装;不存在 = 安装模式;损坏 = 备份 + 删 lock + 回退 |
|
||||||
|
| 损坏可自愈 | 库异常自动备份留档并回退安装模式,不裸奔 |
|
||||||
|
| 先内核后 UI | 本期只做"引导页"(安装必需),完整管理后台后置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 需求范围
|
||||||
|
|
||||||
|
### 2.1 本期(MVP)范围
|
||||||
|
|
||||||
|
| 编号 | 需求 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | SQLite 接入 | `modernc.org/sqlite`(纯 Go、无 CGO),自动建表、轻量迁移 |
|
||||||
|
| R2 | 全局配置入库 | `settings` 表接管现有全部 TTS 全局环境变量 |
|
||||||
|
| R3 | 音色库 | `voices` 表:name → speaker/resource_id/model/…,支持增删改查 |
|
||||||
|
| R4 | 安装状态检测 | `installed.lock` 判据 + 启动判定流程 |
|
||||||
|
| R5 | 引导式安装 UI | `/setup` 引导页(首次启动数据收集)+ `POST /api/setup` |
|
||||||
|
| R6 | 安装模式路由守卫 | 未安装时全站只开放 `/setup`,其余返回 503/跳转 |
|
||||||
|
| R7 | voice 动态路由 | `/v1/audio/speech` 按 `voice` 查库路由到火山 |
|
||||||
|
| R8 | 音色管理 API | `GET/POST /api/voices`、`PUT/DELETE /api/voices/:id`(带鉴权) |
|
||||||
|
| R9 | 损坏回退 | 库校验失败 → 备份 `.corrupt-<ts>` → 删 lock → 重新安装 |
|
||||||
|
| R10 | 环境变量收敛 | 迁移后 env 仅剩:`TTS_DB_PATH`、`PORT`、初始化凭证 |
|
||||||
|
|
||||||
|
### 2.2 后置(不在本期)
|
||||||
|
|
||||||
|
- 完整管理后台(音色列表/用量图表/配置管理)
|
||||||
|
- 多服务商聚合(火山 / OpenAI / 微软统一适配)
|
||||||
|
- 流式实时输出(SSE/WebSocket)
|
||||||
|
- TTS 结果缓存、长文本自动分片、字幕透出
|
||||||
|
- ASR 转写端点(`/v1/audio/transcriptions`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 总体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────── 单二进制 tts-api ─────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ main.go ── 启动:初始化 DB → 安装状态检测 → 加载配置 → 路由 → 监听 │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌─────────────┐ ┌────────────────────────────────────┐ │
|
||||||
|
│ │ installer/ │──▶│ store/ │ │ router/ │ │
|
||||||
|
│ │ lock 检测 │ │ SQLite 访问 │ │ /setup(引导页·embed) /api/setup │ │
|
||||||
|
│ │ 安装模式 │ │ settings │ │ /api/voices* /v1/audio/speech│ │
|
||||||
|
│ └────────────┘ │ voices │ │ /health /metrics /dashboard │ │
|
||||||
|
│ └─────────────┘ └────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ controller/ ── 语音合成(查库路由) · setup · voices CRUD │
|
||||||
|
│ middleware/ ── 鉴权 · 限流 · 并发 · 安装守卫 │
|
||||||
|
│ setting/ ── 仅保留引导参数(DB路径/端口/凭证) + 启动汇总 │
|
||||||
|
│ adapter/volcano/ ── 火山 v3 客户端(不改,仅入参来源变为 DB) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 技术选型
|
||||||
|
|
||||||
|
| 项 | 选型 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 语言 | Go(沿用) | 现有项目基线,无迁移成本 |
|
||||||
|
| 数据库 | SQLite(`modernc.org/sqlite`) | 纯 Go 无 CGO,保持 `CGO_ENABLED=0` 单二进制;单文件零运维 |
|
||||||
|
| 前端 | Vue3 + axios(沿用 dashboard 技术栈) | 复用现有 `//go:embed` 模式,`setup.html` 嵌入二进制 |
|
||||||
|
| 路由 | gorilla/mux(沿用) | 现有实现 |
|
||||||
|
| 构建 | 保持单二进制 | `//go:embed` 内嵌引导页与监控页 |
|
||||||
|
|
||||||
|
> 依赖注意:`modernc.org/sqlite` 体积较大(约 30-40MB 二进制),如介意可换 `mattn/go-sqlite3`(需 CGO,破坏单二进制),**本项目选前者**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据模型设计
|
||||||
|
|
||||||
|
### 5.1 `settings` 表(全局配置)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY, -- 配置键名
|
||||||
|
value TEXT NOT NULL, -- 配置值(统一存字符串,读取时按需转换)
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化后必填的键:
|
||||||
|
|
||||||
|
| key | 说明 | 来源(原环境变量) |
|
||||||
|
|---|---|---|
|
||||||
|
| `api_key` | 火山 API Key | `BYTEDANCE_TTS_API_KEY` |
|
||||||
|
| `default_resource_id` | 默认资源 ID | `BYTEDANCE_TTS_RESOURCE_ID` |
|
||||||
|
| `default_speaker` | 默认音色 | `BYTEDANCE_TTS_SPEAKER` |
|
||||||
|
| `initialized` | 安装完成标记 `"1"` | —(双保险,配合 lock) |
|
||||||
|
|
||||||
|
可选键(读取时带默认值):
|
||||||
|
|
||||||
|
| key | 默认值 | 来源 |
|
||||||
|
|---|---|---|
|
||||||
|
| `default_format` | `mp3` | `BYTEDANCE_TTS_FORMAT` |
|
||||||
|
| `sample_rate` | `24000` | `BYTEDANCE_TTS_SAMPLE_RATE` |
|
||||||
|
| `timeout` | `30s` | `BYTEDANCE_TTS_TIMEOUT` |
|
||||||
|
| `model` | 空 | `BYTEDANCE_TTS_MODEL` |
|
||||||
|
| `model_type` | 空 | `BYTEDANCE_TTS_MODEL_TYPE` |
|
||||||
|
| `explicit_language` | 空 | `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` |
|
||||||
|
| `enable_subtitle` | `false` | `BYTEDANCE_TTS_ENABLE_SUBTITLE` |
|
||||||
|
|
||||||
|
> 迁移建议:首次安装时若检测到旧的对应环境变量仍存在,可作为引导页**预填默认值**(仅预填,不替代库),便于老用户平滑迁移。
|
||||||
|
|
||||||
|
### 5.2 `voices` 表(音色库)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS voices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE, -- 对外 voice 名,请求里 voice 字段查它
|
||||||
|
speaker TEXT NOT NULL, -- 火山音色 ID(复刻音色以 S_ 开头)
|
||||||
|
resource_id TEXT NOT NULL, -- 对应火山资源 ID(决定计费/模型族)
|
||||||
|
model TEXT DEFAULT '', -- 子模型 seed-tts-2.0-standard / -expressive
|
||||||
|
language TEXT DEFAULT '', -- 显式语种(可选)
|
||||||
|
description TEXT DEFAULT '', -- 备注
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1, -- 0/1 启用
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_voices_name ON voices(name);
|
||||||
|
```
|
||||||
|
|
||||||
|
> 默认音色:`settings.default_speaker` 表示请求**未传 voice** 时用的音色;也可以约定默认 voice 名为 `default` 的行,二选一,建议用 settings 字段(更直观)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安装与启动流程设计
|
||||||
|
|
||||||
|
### 6.1 lock 判据
|
||||||
|
|
||||||
|
- lock 文件:`<DB目录>/installed.lock`(与 `tts.db` 同目录)。
|
||||||
|
- **存在 = 已成功完成过安装**;不存在 = 未安装。
|
||||||
|
- lock 内容:一行文本 `version <版本号> <初始化时间>`,便于未来判断是否需要重装/迁移。
|
||||||
|
- 写入时机:**先写库、后写 lock**(`/api/setup` 成功后、且通过完整性校验后才原子创建 lock),避免"lock 在、库是半成品"。
|
||||||
|
- **不用 `.db` 文件是否存在作为安装判据**(建表会自动创建 db 文件,无法区分"从未安装"和"已安装")。
|
||||||
|
|
||||||
|
### 6.2 启动判定流程
|
||||||
|
|
||||||
|
```
|
||||||
|
main 启动
|
||||||
|
├─ 解析引导环境变量(TTS_DB_PATH / PORT / 初始化凭证)
|
||||||
|
├─ store.Open()(打开/创建 SQLite,自动建表)
|
||||||
|
│
|
||||||
|
├─ installed.lock 不存在?
|
||||||
|
│ └─ YES → 进入【安装模式】:仅开放 /setup(静态资源 + 引导页 + POST /api/setup)
|
||||||
|
│ 其余路由(/v1/audio/speech、/api/voices 等)→ 503 + 跳转 /setup
|
||||||
|
│ └─ NO → 尝试读库 + PRAGMA integrity_check
|
||||||
|
│ ├─ 通过 → 加载 settings/voices 到内存缓存 → 【正常模式】
|
||||||
|
│ └─ 失败 → 备份 tts.db → tts.db.corrupt-<时间戳>
|
||||||
|
│ → 删除 installed.lock → 进入【安装模式】
|
||||||
|
│
|
||||||
|
└─ 打印启动摘要(模式 / DB路径 / lock状态 / 音色数)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 损坏回退规则
|
||||||
|
|
||||||
|
1. 只在 lock 存在但库打不开 / `integrity_check` 失败时触发回退;
|
||||||
|
2. **先备份**:损坏的 `.db` 改名 `tts.db.corrupt-<ts>` 留档,不直接删除;
|
||||||
|
3. 删除 `installed.lock`,进入安装模式;
|
||||||
|
4. 日志明确打印回退原因(文件损坏 / 权限 / 磁盘 / 版本等),便于排查;
|
||||||
|
5. 回退后用户重新走 `/setup` 即可恢复。
|
||||||
|
|
||||||
|
### 6.4 setup 劫持防护(必须)
|
||||||
|
|
||||||
|
安装模式是"谁先访问谁配置",公网暴露时存在被抢先初始化的风险。防护方案(至少一项):
|
||||||
|
|
||||||
|
- **A(推荐)初始化凭证**:启动时打印一次性 setup token(或从环境变量 `TTS_ADMIN_KEY` 指定),引导页提交时必须带 token,校验通过才写入;
|
||||||
|
- **B 回环限制**:安装模式下 `/setup` 仅允许本机回环地址访问(`127.0.0.1`),初始化完成后即失效;
|
||||||
|
- 二者可叠加。初始化完成后 `/api/setup` 永久关闭。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 接口设计
|
||||||
|
|
||||||
|
### 7.1 安装相关
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 鉴权 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/setup` | 引导页 HTML(`//go:embed`) | 安装模式开放 |
|
||||||
|
| GET | `/api/setup/status` | 返回 `{installed: bool}`,供引导页判断 | 无 |
|
||||||
|
| POST | `/api/setup` | 提交初始配置(全局 + 音色列表 + token)→ 写库 → 写 lock | 初始化凭证 |
|
||||||
|
|
||||||
|
`POST /api/setup` 请求体示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "一次性凭证",
|
||||||
|
"settings": {
|
||||||
|
"api_key": "xxx",
|
||||||
|
"default_resource_id": "volc.megatts.default",
|
||||||
|
"default_speaker": "zh_female_qingxin",
|
||||||
|
"default_format": "mp3",
|
||||||
|
"sample_rate": 24000
|
||||||
|
},
|
||||||
|
"voices": [
|
||||||
|
{ "name": "qian", "speaker": "S_xxx", "resource_id": "volc.megatts.icl", "model": "seed-tts-2.0-standard" },
|
||||||
|
{ "name": "xun", "speaker": "S_yyy", "resource_id": "volc.megatts.icl", "model": "seed-tts-2.0-expressive" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:`200 {ok:true, message:"installed"}` 或 `400/401/409`。
|
||||||
|
|
||||||
|
### 7.2 音色管理(正常模式)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/voices` | 列表(可分页/过滤 enabled) |
|
||||||
|
| POST | `/api/voices` | 新增音色(name 唯一冲突返回 409) |
|
||||||
|
| PUT | `/api/voices/:id` | 更新音色 |
|
||||||
|
| DELETE | `/api/voices/:id` | 删除音色(`default_speaker` 引用的音色禁止删除) |
|
||||||
|
|
||||||
|
鉴权:复用现有 `OPENAI_TTS_API_KEY`(Bearer)。若安装后用户未配置管理 Key,可提示在 settings 中配置。
|
||||||
|
|
||||||
|
### 7.3 语音合成(改造点)
|
||||||
|
|
||||||
|
`/v1/audio/speech` 改造逻辑:
|
||||||
|
|
||||||
|
```
|
||||||
|
收到请求
|
||||||
|
├─ voice 为空 → 用 settings.default_speaker(无默认 → 400 "no default voice")
|
||||||
|
├─ voice 非空 → 查 voices 表
|
||||||
|
│ ├─ 命中 → 用该行 speaker/resource_id/model/language 覆盖 opts
|
||||||
|
│ └─ 未命中 → 400 "unknown voice: <name>"
|
||||||
|
├─ model 非空 → 查库/映射(本期:model 仅校验长度,不做映射,或按 settings 默认)
|
||||||
|
└─ 其余逻辑不变(格式解析、speed、鉴权、限流、埋点)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 现有端点(不变)
|
||||||
|
|
||||||
|
`/health`、`/metrics`、`/dashboard`、`/` 行为保持;但**安装模式下**除 `/setup` 外统一返回 503(`/health` 可返回 `installed:false` 便于部署探针识别未初始化)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 模块划分与代码改动清单
|
||||||
|
|
||||||
|
### 8.1 新增包
|
||||||
|
|
||||||
|
| 包 | 职责 | 关键文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| `store/` | SQLite 访问层:打开/建表/迁移、settings CRUD、voices CRUD、integrity_check | `db.go`、`settings.go`、`voices.go`、`migrate.go` |
|
||||||
|
| `installer/` | 安装状态:lock 检测/创建/删除、安装模式判定、损坏回退 | `lock.go`、`bootstrap.go` |
|
||||||
|
|
||||||
|
### 8.2 改造文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `router/router.go` | 新增 `/setup`、`/api/setup/*`、`/api/voices`;安装模式路由守卫 |
|
||||||
|
| `controller/` | 新增 `setup.go`(安装提交)、`voices.go`(CRUD);改造 `tts.go`(voice 查库路由) |
|
||||||
|
| `middleware/` | 新增 `installguard.go`(安装模式拦截,未安装非 `/setup` → 503) |
|
||||||
|
| `setting/config.go` | 收敛:仅读 `TTS_DB_PATH`、`PORT`、初始化凭证;启动汇总展示"模式/lock/音色数" |
|
||||||
|
| `main.go` | 启动流程:初始化 DB → 安装检测 → 模式分支 |
|
||||||
|
| `router/setup.html` | 新增引导页(Vue3 + axios,`//go:embed`) |
|
||||||
|
| `go.mod` | 新增 `modernc.org/sqlite` |
|
||||||
|
| `.env.example` / `README.md` / `docker-compose.yml` | 更新为新的引导参数与首次安装说明 |
|
||||||
|
|
||||||
|
### 8.3 环境变量收敛表
|
||||||
|
|
||||||
|
**迁移前(现状,会持续膨胀):**
|
||||||
|
|
||||||
|
```
|
||||||
|
BYTEDANCE_TTS_API_KEY
|
||||||
|
BYTEDANCE_TTS_RESOURCE_ID
|
||||||
|
BYTEDANCE_TTS_SPEAKER
|
||||||
|
BYTEDANCE_TTS_FORMAT
|
||||||
|
BYTEDANCE_TTS_SAMPLE_RATE
|
||||||
|
BYTEDANCE_TTS_BIT_RATE
|
||||||
|
BYTEDANCE_TTS_MODEL
|
||||||
|
BYTEDANCE_TTS_MODEL_TYPE
|
||||||
|
BYTEDANCE_TTS_EXPLICIT_LANGUAGE
|
||||||
|
BYTEDANCE_TTS_ENABLE_SUBTITLE
|
||||||
|
BYTEDANCE_TTS_TIMEOUT
|
||||||
|
BYTEDANCE_TTS_DEBUG
|
||||||
|
OPENAI_TTS_API_KEY
|
||||||
|
ALLOWED_ORIGINS
|
||||||
|
TRUSTED_PROXY_HOPS
|
||||||
|
PORT
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移后(仅引导参数,其余进库):**
|
||||||
|
|
||||||
|
```
|
||||||
|
TTS_DB_PATH # 数据库/lock 目录(默认 ./)
|
||||||
|
PORT # 监听端口
|
||||||
|
TTS_ADMIN_KEY # 安装初始化凭证(可选,不设则启动打印一次性 token)
|
||||||
|
OPENAI_TTS_API_KEY # 管理 API / 合成 API 鉴权(可选,迁移进 settings 或保留)
|
||||||
|
ALLOWED_ORIGINS # CORS(可保留,属运行环境而非业务配置)
|
||||||
|
TRUSTED_PROXY_HOPS # 反代拓扑参数(保留,属部署环境)
|
||||||
|
```
|
||||||
|
|
||||||
|
> `BYTEDANCE_TTS_DEBUG`、`TRUSTED_PROXY_HOPS`、`ALLOWED_ORIGINS` 属"部署/运维环境"而非"业务配置",可留在 env;其余 TTS 业务配置全部进库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 安全设计
|
||||||
|
|
||||||
|
| 项 | 措施 |
|
||||||
|
|---|---|
|
||||||
|
| Setup 劫持 | 初始化凭证(`TTS_ADMIN_KEY` 或一次性 token)+ 可选回环限制;完成后 `/api/setup` 永久关闭 |
|
||||||
|
| 合成/管理鉴权 | 复用 `OPENAI_TTS_API_KEY`(Bearer);voice 路由不绕过鉴权 |
|
||||||
|
| 敏感信息 | API Key 在引导页只进不出;日志/`/health` 不回显明文 Key(沿用 `maskAPIKey`) |
|
||||||
|
| 输入校验 | voice 名白名单(字母数字 `_-`)、长度限制;SQL 全部参数化,防注入 |
|
||||||
|
| lock/DB 写入 | 先写库后写 lock;lock 原子创建;损坏先备份再回退 |
|
||||||
|
| 默认音色保护 | 删除被 `default_speaker` 引用的音色时拒绝(409) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 开发计划与里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 内容 | 验收标准 |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 存储层 | `store/` 包:SQLite 接入、两表建表、迁移、settings/voices CRUD、`integrity_check` | `go build` 通过;单测覆盖 CRUD |
|
||||||
|
| M2 安装流程 | `installer/`:lock 检测/创建/删除、安装模式判定、损坏回退;`middleware/installguard` | 无 lock → 安装模式;有 lock → 正常模式;损坏库 → 备份+回退 |
|
||||||
|
| M3 引导 UI | `/setup` 引导页 + `POST /api/setup`(token 校验、写库、写 lock) | 首次访问可完成安装;重复安装被拒;token 错误 401 |
|
||||||
|
| M4 音色路由 | `/api/voices` CRUD + `/v1/audio/speech` voice 查库路由 | 新增音色后 voice 生效;未知 voice 400;默认音色兜底 |
|
||||||
|
| M5 收敛与文档 | `setting/` 收敛、`.env.example`/README/部署更新、启动摘要展示模式与音色数 | 迁移后仅引导 env;文档与行为一致 |
|
||||||
|
| M6 测试收尾 | 覆盖 lock 判定、setup 流程、voice 路由、损坏回退的集成测试 | 关键路径有自动化测试 |
|
||||||
|
|
||||||
|
建议 M1→M2 连续做(安装流程是主链路),M3 与 M2 可并行;M4 依赖 M1 完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 影响 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| `modernc.org/sqlite` 体积增大 | 单二进制从 ~7MB 增至 ~40MB | 接受;如不可接受改 CGO 版(牺牲单文件) |
|
||||||
|
| Setup 劫持(公网部署) | 他人抢先配置 | 初始化凭证 + 回环限制 + 完成后关闭端点(见 6.4) |
|
||||||
|
| 库损坏导致服务不可用 | 服务起不来 | 自愈回退:备份 + 删 lock + 重新安装(见 6.3) |
|
||||||
|
| 老用户迁移 | 现有 env 用户升级后无库 | 引导页预填旧 env 值;README 给出迁移步骤 |
|
||||||
|
| voice 名冲突/默认引用 | 删除默认音色致不可用 | 唯一约束 + 默认音色删除保护(409) |
|
||||||
|
| 并发写库 | 数据竞争 | 单写锁(`database/sql` 默认 + 业务层互斥),单用户场景风险低 |
|
||||||
|
| 表结构未来升级 | 旧库不兼容 | lock 内容带版本号;`migrate.go` 预留版本迁移 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 验收标准(本期 MVP)
|
||||||
|
|
||||||
|
1. 删除所有业务 env 后,首次启动进入 `/setup` 引导页,可完成安装(全局 + ≥1 音色);
|
||||||
|
2. 安装完成后有 `installed.lock`,重启进入正常模式,`/v1/audio/speech` 可用;
|
||||||
|
3. 通过 `/api/voices` 新增音色后,请求带该 `voice` 能正常合成;未知 voice 返回 400;
|
||||||
|
4. 未传 `voice` 时使用默认音色;
|
||||||
|
5. 手动制造损坏库 → 自动备份 `.corrupt-*` 并删 lock 回退安装模式;
|
||||||
|
6. 安装模式下 `/v1/audio/speech` 返回 503 或跳转 `/setup`;
|
||||||
|
7. 单二进制运行,无外部文件依赖(引导页已 embed)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 非目标(明确不做)
|
||||||
|
|
||||||
|
- 本期不做多服务商聚合、流式输出、缓存、字幕透出、ASR;
|
||||||
|
- 不做完整的运营管理后台(仅引导页,管理 API 先行);
|
||||||
|
- 不做多租户/多用户体系(个人自用,单管理员)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本:v0.1 · 状态:待评审 · 配套代码基线:Volcano-Engine-TTS-UI @ develop*
|
||||||
@@ -158,7 +158,7 @@ TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公
|
|||||||
已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代)
|
已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代)
|
||||||
```
|
```
|
||||||
|
|
||||||
也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或写一个 Go 测试用例(参见 DEBT-1 单元测试任务)来覆盖不同 XFF 链场景。生产环境不要保留 debug 日志。
|
也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或参考已有的 `middleware/ratelimit_test.go`(24 个 XFF 表驱动用例)来扩展更多 XFF 链场景。生产环境不要保留 debug 日志。
|
||||||
|
|
||||||
### Resource ID 说明
|
### Resource ID 说明
|
||||||
|
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
# 漏洞报告 — Volcano-Engine-TTS-UI
|
|
||||||
|
|
||||||
## 元信息
|
|
||||||
|
|
||||||
| 项目 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| 目标 | ByteDance TTS v3 → OpenAI 兼容接口适配器(Go) |
|
|
||||||
| 审查范围 | 全部 12 个包、约 2400 行源码(不含 health.html 前端静态页) |
|
|
||||||
| 审查方式 | 人工代码审查 + `go build` / `go vet`(均通过) |
|
|
||||||
| 分支/提交 | develop @ 78c7200 |
|
|
||||||
| 报告日期 | 2026 年 8 月 25 日 |
|
|
||||||
| 严重度分级 | 🔴 高(必须修复)/ 🟠 中(建议修复)/ 🟡 低(视部署环境)/ ⚪ 信息(记录备查) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 漏洞清单(按严重度)
|
|
||||||
|
|
||||||
| 编号 | 严重度 | 标题 | 位置 | 一句话影响 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| VUL-004 | 🔴 高 | `.env` 未被 `.gitignore` 忽略,凭据可能入库/入镜像 | `.gitignore` | API Key 随 git 提交或 Docker 镜像层泄露 |
|
|
||||||
| VUL-001 | 🟠 中 | aac/flac 响应 Content-Type 与数据不一致 | controller/tts.go、adapter/volcano/synthesis.go | 客户端按 AAC 解码 MP3 数据,播放失败 |
|
|
||||||
| VUL-003 | 🟠 中 | `X-Forwarded-For` 信任链可伪造 IP 绕过限流 | middleware/ratelimit.go | 反代追加模式下限流失效 |
|
|
||||||
| VUL-002 | 🟡 低 | transport 层错误不进入 `UpstreamErrors` 指标 | metrics/metrics.go、adapter/volcano/synthesis.go | 网络故障在监控上不可见 |
|
|
||||||
| VUL-005 | 🟡 低 | 日志注入:客户端可控内容原样写入日志 | middleware/logger.go、controller/tts.go | 可伪造日志行 |
|
|
||||||
| VUL-006 | 🟡 低 | `/metrics`、`/health`、`/dashboard` 无鉴权 | router/router.go | 公网暴露时泄漏运行细节(设计权衡) |
|
|
||||||
| VUL-007 | 🟡 低 | `OPENAI_TTS_API_KEY` 未设置时鉴权完全关闭 | middleware/auth.go | 公网直连即无访问控制(设计权衡) |
|
|
||||||
| VUL-008 | ⚪ 信息 | speed 超范围静默截断 | adapter/volcano/request.go | 0.25~0.5x、2.0~4.0x 实际被 clamp,无提示 |
|
|
||||||
| VUL-009 | ⚪ 信息 | WAV 输出采样率依赖配置而非上游实际值 | adapter/volcano/audio.go | 配置错误导致音频变速 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-004 🔴 高 — `.env` 未被忽略,凭据可能入库/入镜像
|
|
||||||
|
|
||||||
**位置**: `.gitignore`(全文件仅忽略构建产物与编辑器文件)
|
|
||||||
|
|
||||||
**描述**: README 与 `.env.example` 均指导用户执行 `cp .env.example .env` 后填入火山 API Key。但 `.gitignore` **没有包含 `.env`**。任何按此流程操作并执行 `git add .` / `git commit` 的用户,都会把含 `BYTEDANCE_TTS_API_KEY`、`OPENAI_TTS_API_KEY` 的文件提交进仓库历史(即使之后删除,历史中仍可找回)。Dockerfile 第 8 行 `COPY . .` 同样会把 `.env` 拷入镜像层。
|
|
||||||
|
|
||||||
**影响**: 火山账号 API Key 泄露 → 冒用额度、产生费用、音色资源被盗用。密钥一旦进入 git 历史或镜像层即视为已泄露,只能吊销重建。
|
|
||||||
|
|
||||||
**修复建议**:
|
|
||||||
```gitignore
|
|
||||||
# Secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
```
|
|
||||||
|
|
||||||
**验证**: 当前工作区无 `.env` 文件,仓库历史也未发现已提交的 `.env`(已核查 `git log` 提交列表无该文件),属"配置隐患"而非"已泄露"。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-001 🟠 中 — aac/flac 响应 Content-Type 与真实数据不一致
|
|
||||||
|
|
||||||
**位置**: controller/tts.go:196-212(`contentTypeFor`)、adapter/volcano/synthesis.go:59-63、118-119
|
|
||||||
|
|
||||||
**描述**: 客户端请求 `response_format: "aac"`(或 `flac`)时,调用链为:
|
|
||||||
|
|
||||||
```
|
|
||||||
resolveClientFormat("aac") → "aac"(白名单放行)
|
|
||||||
synthesis: opts.Format = "mp3"(上游降级,正确)
|
|
||||||
synthesis: finalFormat = clientFormat = "aac"(错误,保留客户端格式)
|
|
||||||
controller: Content-Type = contentTypeFor("aac") = "audio/aac"(错误)
|
|
||||||
```
|
|
||||||
|
|
||||||
实际响应字节是 **MP3**,但 `Content-Type` 是 `audio/aac`。
|
|
||||||
|
|
||||||
**影响**: 客户端(浏览器 `<audio>`、播放器 SDK)按 AAC 解码器处理 MP3 流,轻则播放失败/杂音,重则解码崩溃。README 声称"降级到 mp3",但响应头未同步降级。
|
|
||||||
|
|
||||||
**修复建议**(二选一):
|
|
||||||
1. `synthesis.go` 在降级后把 `finalFormat` 置为实际上游格式(`mp3`);
|
|
||||||
2. `contentTypeFor` 对 `aac`/`flac` 直接返回 `audio/mpeg`。
|
|
||||||
|
|
||||||
推荐方案 1(响应头应反映真实数据)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-003 🟠 中 — X-Forwarded-For 信任链可伪造 IP 绕过限流
|
|
||||||
|
|
||||||
**位置**: middleware/ratelimit.go:129-150(`GetClientIP`)
|
|
||||||
|
|
||||||
**描述**: `GetClientIP` 在直连 IP 为私有地址(即判定为反代)时,信任 `X-Forwarded-For` 的**第一个**值,其次信任 `X-Real-IP`。若反代(nginx 等)使用追加模式(`$proxy_add_x_forwarded_for`),攻击者发送 `X-Forwarded-For: 1.2.3.4`,反代追加真实 IP 后请求头为 `1.2.3.4, 真实IP`,代码取 `1.2.3.4`。
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- 攻击者每次请求携带不同伪造 IP,即可绕过 100 次/分钟的 IP 限流(限流 key 由该函数返回值决定);
|
|
||||||
- 可伪装成受害 IP 请求,把受害 IP 的限流配额耗尽(间接 DoS)。
|
|
||||||
|
|
||||||
**前提**: 服务必须部署在反代之后(反代 IP 为私有)。直接公网直连时直连 IP 非私有,不走信任分支,不受影响。
|
|
||||||
|
|
||||||
**修复建议**(任一):
|
|
||||||
1. 反代配置覆盖而非追加:`proxy_set_header X-Forwarded-For $remote_addr`;
|
|
||||||
2. 代码改取 `XFF` **最后一个**值(追加模式下最后一个为真实来源);
|
|
||||||
3. 部署时用 `X-Real-IP` 且确保反代覆盖该头,代码优先信任 `X-Real-IP`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-002 🟡 低 — transport 层错误不进入 UpstreamErrors 指标
|
|
||||||
|
|
||||||
**位置**: metrics/metrics.go:134-136、adapter/volcano/synthesis.go:90-92、112
|
|
||||||
|
|
||||||
**描述**: `UpstreamFinished` 中 `if errCode != 0 { UpstreamErrors.Inc(...) }`。传输错误(连接失败、DNS 失败、读流失败)时调用方传入的 `errCode` 均为 0:
|
|
||||||
|
|
||||||
- `client.PostStream` 失败 → `UpstreamFinished(..., 0)` → 不计
|
|
||||||
- `ParseStream` 读流错误 → `UpstreamError{Code: 0}` → 不计
|
|
||||||
|
|
||||||
而 `codeLabel`(metrics/metrics.go:148-158)明确设计了 `code == 0 → "transport"` 分类,**该分类永远不会被触发**。
|
|
||||||
|
|
||||||
**影响**: 上游网络故障时 `tts_upstream_errors_total` 不增长,`/metrics` 与监控面板无法发现"火山接口连不上"类故障,只能从日志人工发现。
|
|
||||||
|
|
||||||
**修复建议**: 将 transport 错误单独计数,例如 `if errCode != 0 || status == "transport_error" { UpstreamErrors.Inc(Labels{"code": codeLabel(errCode)}) }`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-005 🟡 低 — 日志注入
|
|
||||||
|
|
||||||
**位置**: middleware/logger.go:26、adapter/volcano/synthesis.go:100(`Message` 拼入上游响应体)
|
|
||||||
|
|
||||||
**描述**: 访问日志直接拼接 `r.RequestURI`(客户端可控,URL 中可含 `\n`/`\r`);上游非 200 响应体 `rawBody` 拼入错误日志。Go `log` 不做转义,原样输出。
|
|
||||||
|
|
||||||
**影响**: 攻击者可在请求 URL 中注入换行符,伪造服务端日志行(如伪造"合成成功"记录、注入误导信息),干扰排障;无代码执行风险。
|
|
||||||
|
|
||||||
**修复建议**(低优先): 对 RequestURI 做换行转义(`strings.NewReplacer("\n", "\\n", "\r", "\\r")`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-006 🟡 低 — 监控端点无鉴权(设计权衡)
|
|
||||||
|
|
||||||
**位置**: router/router.go:21-31
|
|
||||||
|
|
||||||
**描述**: `/health`、`/metrics`、`/dashboard` 均不鉴权(README 明示,与 Prometheus 抓取场景对齐)。
|
|
||||||
|
|
||||||
**影响**: 若公网直接暴露,任何人可查看 `/metrics`(含 speaker/model/format 业务标签、请求计数、上游错误聚合)与 `/dashboard`(运行状态、配置检查结果)。不涉及凭据,但为侦察提供信息。
|
|
||||||
|
|
||||||
**判定**: 属于明确的设计决策,个人/内网使用可接受;公网部署建议通过反代鉴权(如 basic auth)保护 `/metrics`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-007 🟡 低 — 未配置 OPENAI_TTS_API_KEY 时鉴权完全关闭
|
|
||||||
|
|
||||||
**位置**: middleware/auth.go:20-22、setting/config.go:64-79
|
|
||||||
|
|
||||||
**描述**: `ValidateAPIKey` 在 `setting.Auth.APIKeys` 为空时直接返回 `true`(全部放行)。该变量仅在 `OPENAI_TTS_API_KEY` 设置后才会填充。
|
|
||||||
|
|
||||||
**影响**: 公网直接暴露且未配置该环境变量时,任何人均可无限制调用 TTS 合成,消耗火山额度。
|
|
||||||
|
|
||||||
**判定**: 属设计行为(内网可信),README 已有说明。公网部署必须配置该变量,或由反代承担鉴权。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-008 ⚪ 信息 — speed 超范围静默截断
|
|
||||||
|
|
||||||
**位置**: adapter/volcano/request.go:89-101、controller/tts.go:132-141
|
|
||||||
|
|
||||||
**描述**: README 声明 `speed` 支持 0.25~4.0;controller 按此范围 clamp,但火山 `speech_rate` 仅支持 [-50, 100](即 0.5x~2.0x)。`0.25~0.5x` 与 `2.0~4.0x` 区间会被二次 clamp 截断,且无任何客户端提示。
|
|
||||||
|
|
||||||
**影响**: 用户请求 0.25x 实际得到 0.5x 语速,表现与预期不符。
|
|
||||||
|
|
||||||
**修复建议**: README 修正文档范围,或对超范围请求返回 400 而非静默截断。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## VUL-009 ⚪ 信息 — WAV 采样率依赖配置而非上游实际值
|
|
||||||
|
|
||||||
**位置**: adapter/volcano/audio.go:32-39、controller/tts.go:121
|
|
||||||
|
|
||||||
**描述**: `WrapWAVHeader` 使用 `opts.SampleRate`(环境变量 `BYTEDANCE_TTS_SAMPLE_RATE`,默认 24000)写 WAV 头。若上游实际返回的 PCM 采样率与配置不一致(配置错误或上游忽略该参数),WAV 头与数据不匹配。
|
|
||||||
|
|
||||||
**影响**: 音频以错误速率播放(变速/变调)。
|
|
||||||
|
|
||||||
**判定**: 正常配置下无影响;配置异常时表现为"音频怪声",README 第 3 条已有排查指引。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安全加固建议(非漏洞)
|
|
||||||
|
|
||||||
1. **Docker 环境变量**: compose 中密钥通过 `environment` 明文传递,进程环境可见(`/proc/<pid>/environ`)。生产可改用 Docker Secrets 或启动时注入。
|
|
||||||
2. **TLS**: 当前 HTTP 明文,建议生产经反代(nginx/caddy)终结 TLS,或服务前挂证书。
|
|
||||||
3. **依赖固定**: go.mod 仅锁定 `gorilla/mux v1.8.1`(2018 年发布),建议 `go get -u` 检查是否存在已知 CVE 的新版本,或至少 `go mod verify`。
|
|
||||||
|
|
||||||
## 工程债务(非安全,记录备查)
|
|
||||||
|
|
||||||
| 项目 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| 零单元测试 | 全部 12 个包无 `_test.go`;流解析(曾有 4 次 bug 修复)、WAV 拼头、speech_rate 转换、限流窗口、Prometheus 转义均无自动化回归保护 |
|
|
||||||
| 死代码 | `ratelimit_middleware.go` 整文件未引用;`auth.go:InitAPIKeys`、`cors.go:InitCORSConfig` 为未被调用的 no-op;`dto.ByteDanceTTSConfig` + `setting/config.go:311` 占位引用 |
|
|
||||||
| 冗余代码 | `resolveClientFormat`(controller/tts.go:49-52)两分支同值;`common.MaxResponseTimes`/`MaxErrors` 常量未使用 |
|
|
||||||
| 云盘占用 | 注释表明 `ratelimit_middleware.go` 因"云盘同步被永久占用"无法删除,仓库位于云盘目录,git 操作与文件删除存在异常风险 |
|
|
||||||
|
|
||||||
## 已核查无风险项
|
|
||||||
|
|
||||||
- ✅ API Key 比较使用 `subtle.ConstantTimeCompare`,无时序侧信道
|
|
||||||
- ✅ 启动日志与 `/health` 对 API Key 脱敏(`maskAPIKey`)
|
|
||||||
- ✅ 请求体上限 1MB、文本上限 5000 字、model 名长度/字符校验
|
|
||||||
- ✅ 并发信号量 + IP 限流仅对 `/v1/` 生效,监控路径豁免;CORS 预检不消耗配额
|
|
||||||
- ✅ telemetry label key 注册时锁定,当前 cardinality 可控(无客户端可控高基数标签)
|
|
||||||
- ✅ 上游连接池复用、超时(30s)与 context 取消正确传播
|
|
||||||
- ✅ 优雅退出(SIGINT/SIGTERM → 5s 内 Shutdown)
|
|
||||||
- ✅ 无 `_test.go` 之外的明显并发竞态:全局配置启动期写入后只读,共享状态均有锁
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetClientIP 覆盖 XFF 解析在两种模式下的关键场景。
|
|
||||||
// 表驱动测试,每个 case 独立设置 trustedProxyHops,验证 GetClientIP 输出。
|
|
||||||
func TestGetClientIP(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mode int // 0=启发式, N>0=精确 N 跳
|
|
||||||
remoteAddr string // 直连 IP:port
|
|
||||||
xff string // X-Forwarded-For 头(空则不设)
|
|
||||||
xri string // X-Real-IP 头(空则不设)
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
// === 直出部署(directIP 是公网,XFF 分支不进)===
|
|
||||||
{
|
|
||||||
name: "直出_无XFF",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "1.2.3.4:5678",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "直出_XFF被忽略",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "1.2.3.4:5678",
|
|
||||||
xff: "fake",
|
|
||||||
want: "1.2.3.4", // 公网直连不走 XFF 分支
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "直出_精确模式也不走XFF",
|
|
||||||
mode: 2,
|
|
||||||
remoteAddr: "1.2.3.4:5678",
|
|
||||||
xff: "fake, 5.6.7.8",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 单跳反代 ===
|
|
||||||
{
|
|
||||||
name: "单跳_启发式",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "单跳_精确N1",
|
|
||||||
mode: 1,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 攻击者伪造首值 ===
|
|
||||||
{
|
|
||||||
name: "伪造_启发式跳过fake",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "fake, 1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "伪造_精确N1也跳过fake",
|
|
||||||
mode: 1,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "fake, 1.2.3.4",
|
|
||||||
want: "1.2.3.4", // target=1, 跳过 fake 取 real
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "伪造_多个假值前缀",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "fake1, fake2, 1.2.3.4",
|
|
||||||
want: "1.2.3.4", // 从尾扫,只看最后一个
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 多跳 CDN+nginx ===
|
|
||||||
{
|
|
||||||
name: "多跳_启发式返回CDN边缘",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4, 203.0.113.5",
|
|
||||||
want: "203.0.113.5", // 链尾公网=CDN 边缘
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多跳_精确N2返回真实client",
|
|
||||||
mode: 2,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4, 203.0.113.5",
|
|
||||||
want: "1.2.3.4", // 倒数第2=真实 client
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多跳_精确N1不够穿透",
|
|
||||||
mode: 1,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4, 203.0.113.5",
|
|
||||||
want: "203.0.113.5", // 数到 nginx,没穿透到 client
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 链尾私有 IP ===
|
|
||||||
{
|
|
||||||
name: "链尾私有_启发式跳过",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4, 10.0.0.1",
|
|
||||||
want: "1.2.3.4", // 跳过私有取公网
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "链尾私有_精确N1取末值",
|
|
||||||
mode: 1,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4, 10.0.0.1",
|
|
||||||
want: "10.0.0.1", // 精确模式不跳私有
|
|
||||||
},
|
|
||||||
|
|
||||||
// === X-Real-IP 兜底 ===
|
|
||||||
{
|
|
||||||
name: "无XFF_走XRI",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xri: "1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "XFF全非法_走XRI",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "not_ip, also_not",
|
|
||||||
xri: "1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "XRI被XFF优先_但XFF全非法",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "not_an_ip",
|
|
||||||
xri: "1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 全部私有 IP(启发式无解)===
|
|
||||||
{
|
|
||||||
name: "全私有_启发式回退directIP",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "192.168.1.1, 172.16.0.1",
|
|
||||||
want: "10.0.0.1", // 全跳私有,走 directIP
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 畸形/空 XFF ===
|
|
||||||
{
|
|
||||||
name: "畸形XFF_启发式跳过畸形",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "not_an_ip, 1.2.3.4",
|
|
||||||
want: "1.2.3.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "全空XFF_回退directIP",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: " , , ",
|
|
||||||
want: "10.0.0.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "XFF带前后空格",
|
|
||||||
mode: 0,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: " 1.2.3.4 , 5.6.7.8 ",
|
|
||||||
want: "5.6.7.8", // TrimSpace 处理
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 精确模式 N 超出链长 ===
|
|
||||||
{
|
|
||||||
name: "精确N超出链长_回退到首值",
|
|
||||||
mode: 5,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4",
|
|
||||||
want: "1.2.3.4", // target<0 保护,取首个合法
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "精确N等于链长_取首值",
|
|
||||||
mode: 1,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4",
|
|
||||||
want: "1.2.3.4", // target=0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "精确N大于链长_取首值",
|
|
||||||
mode: 2,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4",
|
|
||||||
want: "1.2.3.4", // target<0,fall back
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 精确模式链中含畸形 ===
|
|
||||||
{
|
|
||||||
name: "精确N1_链中畸形回退到首值",
|
|
||||||
mode: 1,
|
|
||||||
remoteAddr: "10.0.0.1:5678",
|
|
||||||
xff: "1.2.3.4, not_ip",
|
|
||||||
want: "1.2.3.4", // target=1(not_ip 失败)→ i=0(1.2.3.4 成功)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
trustedProxyHops = tt.mode
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.RemoteAddr = tt.remoteAddr
|
|
||||||
if tt.xff != "" {
|
|
||||||
r.Header.Set("X-Forwarded-For", tt.xff)
|
|
||||||
}
|
|
||||||
if tt.xri != "" {
|
|
||||||
r.Header.Set("X-Real-IP", tt.xri)
|
|
||||||
}
|
|
||||||
got := GetClientIP(r)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("GetClientIP() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置为默认,避免影响其他测试或运行时行为
|
|
||||||
trustedProxyHops = 0
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user