Compare commits
4
Commits
753de8ca9f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1328705f65 | ||
|
|
94b3557c7e | ||
|
|
5113941f44 | ||
|
|
1e2e37ae97 |
+21
-1
@@ -29,5 +29,25 @@ OPENAI_TTS_API_KEY=
|
|||||||
# 装完后可改(也支持环境变量作为 DB 缺失时的 fallback)
|
# 装完后可改(也支持环境变量作为 DB 缺失时的 fallback)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# X-Forwarded-For 解析跳数(可选, 0=启发式默认, N>0=精确 N 跳反代)
|
# X-Forwarded-For 解析跳数(可选, 0=启发式默认, N>0=精确 N 跳反代)
|
||||||
# 装好后推荐到 /admin 改 trusted_proxy_hops
|
# 装好后推荐到 /dashboard 改 trusted_proxy_hops
|
||||||
# TRUSTED_PROXY_HOPS=2
|
# TRUSTED_PROXY_HOPS=2
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# v0.3.0 新增
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 根路径 /metrics 的内网 CIDR 白名单(可选, 逗号分隔, 也接受裸 IP)
|
||||||
|
# - 不配置:根路径 /metrics 完全不注册(访问 404)。
|
||||||
|
# Prometheus 请改用鉴权版 /api/admin/metrics 并带 Bearer 凭证。
|
||||||
|
# - 配置后:根路径 /metrics 仅对白名单来源开放,非白名单返回 404。
|
||||||
|
# ⚠️ Docker 部署必须填「容器内网网段」,不要填 127.0.0.1/32 ——
|
||||||
|
# 那是容器自身回环,宿主机/其他容器里的 Prometheus 进不来。
|
||||||
|
# 例:
|
||||||
|
# METRICS_ALLOW_CIDR=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||||
|
|
||||||
|
# 管理后台专用凭证(可选)
|
||||||
|
# - 不配置:管理接口回退使用 DB 里的 auth_key(即 OPENAI_TTS_API_KEY 那套),行为与旧版一致。
|
||||||
|
# - 配置后:管理后台/管理接口只认这个 key,业务调用方拿到的 auth_key 无法访问管理接口。
|
||||||
|
# 建议通过 WebUI 配置(/dashboard → 设置),不必写在这里;此处仅作说明。
|
||||||
|
# 对应 DB 设置项:admin_key
|
||||||
|
|
||||||
|
|||||||
+23
-5
@@ -2,14 +2,29 @@
|
|||||||
|
|
||||||
本项目的所有重要变更都记录在本文档。版本号遵循 [SemVer](https://semver.org/)。
|
本项目的所有重要变更都记录在本文档。版本号遵循 [SemVer](https://semver.org/)。
|
||||||
|
|
||||||
## [未发布]
|
## [0.2.4] - 2026-10-04
|
||||||
|
|
||||||
### v0.3.0 · 进行中
|
### 路由鉴权架构升级 · 健康/指标端点收口 · 管理入口迁移
|
||||||
|
|
||||||
按 [docs/IMPLEMENT_v0.3.0.md](docs/IMPLEMENT_v0.3.0.md) 分阶段实施,当前完成 **阶段 1、阶段 2**。
|
> ⚠️ **本版本为破坏性变更版本**,升级前请阅读 [Release Notes](docs/RELEASE_NOTES_v0.3.0.md)。
|
||||||
|
>
|
||||||
|
> **版本号说明**:本次以 `0.2.4` 发布(按补丁号连续),但**语义上是破坏性变更** ——
|
||||||
|
> 未配凭证的部署会拒绝启动、匿名监控端点被收口、探针需改指 `/healthz`。
|
||||||
|
> 之所以不用 minor 号,是为了保持编号连续;后续 `0.2.x` 用于修复本版本暴露的问题。
|
||||||
|
>
|
||||||
|
> **阶段编号说明**:初版方案的"阶段 1(收紧鉴权)"与"阶段 2(收口端点)"属于同一次安全改造、
|
||||||
|
> 必须一起上线才有意义(只做前者是"有风险无收益"),故已**合并为现在的「阶段 1」**;
|
||||||
|
> 后续阶段依次顺延。下文的「阶段 N」均指合并后的编号。
|
||||||
|
|
||||||
#### 新增
|
#### 新增
|
||||||
|
|
||||||
|
- **管理入口迁移到 `/dashboard`**:`/dashboard`、`/dashboard/voices`、`/dashboard/settings`
|
||||||
|
为新入口;详细状态页(原 `/dashboard` 的旧预览页)迁至 **`/dashboard/status`**,
|
||||||
|
并在侧边栏新增「观测 → 详细状态」入口。`/admin` 及子路径 **301 永久重定向**到对应新路径。
|
||||||
|
- **API 路径分层**:`/api/admin/voices`、`/api/admin/settings`(含 `api-key` / `auth-key` / `admin-key`)
|
||||||
|
为正式路径;**旧路径 `/api/voices`、`/api/settings` 保留为别名**(同一个 handler,不做重定向)。
|
||||||
|
选别名而非 301/308 的原因:301 会把 POST/PUT/PATCH/DELETE 降级为 GET(RFC 7231)且被浏览器永久缓存,
|
||||||
|
客户端察觉不到路径变化;308 虽保留方法但对脚本仍是行为变化。别名零往返、零破坏,老脚本完全无感。
|
||||||
- **`GET /healthz` 匿名存活探针**:只返回 `200` 与字面量 `ok`,**不含任何字段**。
|
- **`GET /healthz` 匿名存活探针**:只返回 `200` 与字面量 `ok`,**不含任何字段**。
|
||||||
供 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查使用 ——
|
供 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查使用 ——
|
||||||
这些探针默认不带 `Authorization`,若继续指向 `/health` 会因鉴权而全部失败。
|
这些探针默认不带 `Authorization`,若继续指向 `/health` 会因鉴权而全部失败。
|
||||||
@@ -21,8 +36,8 @@
|
|||||||
- **独立管理凭证 `admin_key`(可选)**:管理接口凭证与业务调用凭证分离。
|
- **独立管理凭证 `admin_key`(可选)**:管理接口凭证与业务调用凭证分离。
|
||||||
取值优先级 `admin_key`(DB) → `auth_key`(DB) → `OPENAI_TTS_API_KEY`(env)。
|
取值优先级 `admin_key`(DB) → `auth_key`(DB) → `OPENAI_TTS_API_KEY`(env)。
|
||||||
**不配置时行为与旧版完全一致**(回退用 `auth_key`),配置后业务 key 无法访问管理接口。
|
**不配置时行为与旧版完全一致**(回退用 `auth_key`),配置后业务 key 无法访问管理接口。
|
||||||
新增 `PUT /api/admin/settings/admin-key`(及旧前缀别名 `/api/settings/admin-key`)用于配置,
|
新增 `PUT /api/admin/settings/admin-key`(及旧前缀别名)用于配置,
|
||||||
凭证**只写不读**(`GET /api/settings` 仅返回打码值与 `admin_key_set` / `admin_key_source`)。
|
凭证**只写不读**(`GET /api/admin/settings` 仅返回打码值与 `admin_key_set` / `admin_key_source`)。
|
||||||
|
|
||||||
#### 变更(Breaking Change)
|
#### 变更(Breaking Change)
|
||||||
|
|
||||||
@@ -42,6 +57,9 @@
|
|||||||
> `/api/admin/metrics`,或配置 `METRICS_ALLOW_CIDR`。
|
> `/api/admin/metrics`,或配置 `METRICS_ALLOW_CIDR`。
|
||||||
> 3. `METRICS_ALLOW_CIDR` 在 Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环),
|
> 3. `METRICS_ALLOW_CIDR` 在 Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环),
|
||||||
> 请填容器内网网段(如 `172.16.0.0/12`)。
|
> 请填容器内网网段(如 `172.16.0.0/12`)。
|
||||||
|
> 4. 管理入口改为 **`/dashboard`**;旧 `/admin` 路径保留 301 重定向,书签与脚本请尽快迁移。
|
||||||
|
> 5. 管理 API 正式路径改为 **`/api/admin/*`**;旧 `/api/*` 路径仍可用(别名,非重定向),
|
||||||
|
> 计划在后续版本移除。
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -36,7 +36,10 @@ USER appuser
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# v0.3.0:健康检查必须用 /healthz(匿名存活探针)。
|
||||||
|
# /health 现在需要管理凭证,而 HEALTHCHECK 不带 Authorization ——
|
||||||
|
# 继续用 /health 会让镜像一直被判定为 unhealthy。
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/health || exit 1
|
CMD wget -qO- http://localhost:8080/healthz || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["./tts-api"]
|
ENTRYPOINT ["./tts-api"]
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
|
|
||||||
- 完全兼容 OpenAI `/v1/audio/speech` API
|
- 完全兼容 OpenAI `/v1/audio/speech` API
|
||||||
- **引导式安装**: 首次启动自动进入 `/setup` 向导,无需手写 env
|
- **引导式安装**: 首次启动自动进入 `/setup` 向导,无需手写 env
|
||||||
- **WebUI 后台** `/admin`: 音色管理 / 全局设置 / CORS 配置 / 鉴权
|
- **WebUI 后台** `/dashboard`: 音色管理 / 全局设置 / CORS 配置 / 鉴权
|
||||||
- **音色库**: 多 voice 动态路由,未知 / 禁用 voice 返回明确错误
|
- **音色库**: 多 voice 动态路由,未知 / 禁用 voice 返回明确错误
|
||||||
- **多格式输出**: mp3 / ogg_opus / pcm / wav (内部转 pcm + 本地拼头) / aac / flac
|
- **多格式输出**: mp3 / ogg_opus / pcm / wav (内部转 pcm + 本地拼头) / aac / flac
|
||||||
- **鉴权 + 限流**: API Key 校验, IP 速率限制, 全局并发限制
|
- **鉴权 + 限流**: API Key 校验, IP 速率限制, 全局并发限制
|
||||||
- **观测**: Prometheus 文本格式 `/metrics` + 服务状态 `/health`, 零外部依赖
|
- **凭证分离**(v0.3.0): 管理后台可配独立 `admin_key`,业务调用 Key 无法访问管理接口
|
||||||
|
- **观测**: Prometheus 文本格式 + 服务状态, **默认无匿名监控端点**(v0.3.0 起)
|
||||||
|
- `/healthz` 匿名存活探针(不含任何字段)· `/api/admin/metrics` 与 `/api/admin/health` 需鉴权
|
||||||
|
- 根路径 `/metrics` 默认 404,需 `METRICS_ALLOW_CIDR` 内网白名单才注册
|
||||||
- **跨平台**: Windows / Linux / macOS / Docker
|
- **跨平台**: Windows / Linux / macOS / Docker
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -35,7 +38,7 @@ go build -o tts-api .
|
|||||||
3. 音色列表: 每个 voice 的对外名 + 火山 speaker ID
|
3. 音色列表: 每个 voice 的对外名 + 火山 speaker ID
|
||||||
4. 确认提交
|
4. 确认提交
|
||||||
|
|
||||||
完成后自动跳转到 `/admin`,从这里登录管理。
|
完成后自动跳转到 `/dashboard`,从这里登录管理。
|
||||||
|
|
||||||
### 3. 调用
|
### 3. 调用
|
||||||
|
|
||||||
@@ -56,9 +59,19 @@ curl -X POST http://localhost:8080/v1/audio/speech \
|
|||||||
| `TTS_ADMIN_KEY` | ✓ (生产) | `/api/setup` 安装 token,首次安装时校验 |
|
| `TTS_ADMIN_KEY` | ✓ (生产) | `/api/setup` 安装 token,首次安装时校验 |
|
||||||
| `TTS_DB_PATH` | ✗ | DB 路径,默认 `./tts.db` |
|
| `TTS_DB_PATH` | ✗ | DB 路径,默认 `./tts.db` |
|
||||||
| `PORT` | ✗ | 监听端口,默认 `8080` |
|
| `PORT` | ✗ | 监听端口,默认 `8080` |
|
||||||
| `OPENAI_TTS_API_KEY` | ✗ (生产) | OpenAI 端鉴权 key,可不设(内网) |
|
| `OPENAI_TTS_API_KEY` | ✓ (生产) | 业务端鉴权 key;未设 `admin_key` 时**也用于管理后台登录** |
|
||||||
|
|
||||||
**所有业务配置**(`api_key` / `default_resource_id` / `default_speaker` / `default_format` / `sample_rate` / `model` / `model_type` / `explicit_language` / `enable_subtitle` / `timeout` / `cors_origins` / `cors_allow_all` / `auth_key` / `trusted_proxy_hops`) — 全部在 `/admin` 设置,持久化到 SQLite。
|
v0.3.0 新增(均可选):
|
||||||
|
|
||||||
|
| 变量 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `METRICS_ALLOW_CIDR` | 根路径 `/metrics` 的内网 CIDR 白名单(逗号分隔)。不配则根路径完全不注册 |
|
||||||
|
|
||||||
|
> ⚠️ **v0.3.0 起,normal 模式下未配置任何管理凭证(DB `admin_key` / DB `auth_key` /
|
||||||
|
> env `OPENAI_TTS_API_KEY` 三者皆空)时服务将拒绝启动** —— 因为管理接口在无凭证时会拒绝
|
||||||
|
> 所有请求,启动一个进不去后台的实例没有意义。
|
||||||
|
|
||||||
|
**所有业务配置**(`api_key` / `default_resource_id` / `default_speaker` / `default_format` / `sample_rate` / `model` / `model_type` / `explicit_language` / `enable_subtitle` / `timeout` / `cors_origins` / `cors_allow_all` / `auth_key` / **`admin_key`** / `trusted_proxy_hops`) — 全部在 `/dashboard` 设置,持久化到 SQLite。
|
||||||
|
|
||||||
env 仍可作为 fallback 读(老用户兼容),但**新用户应通过 WebUI 配**。
|
env 仍可作为 fallback 读(老用户兼容),但**新用户应通过 WebUI 配**。
|
||||||
|
|
||||||
@@ -70,9 +83,9 @@ env 仍可作为 fallback 读(老用户兼容),但**新用户应通过 WebUI 配
|
|||||||
1. **凭证**: 火山 API Key (必填) + OpenAI 鉴权 Key (可选)
|
1. **凭证**: 火山 API Key (必填) + OpenAI 鉴权 Key (可选)
|
||||||
2. **默认路由**: 默认资源 ID (`seed-icl-2.0`) + 默认音色名 + 默认格式 + 采样率
|
2. **默认路由**: 默认资源 ID (`seed-icl-2.0`) + 默认音色名 + 默认格式 + 采样率
|
||||||
3. **音色列表**: 每个 voice 一行,填对外名 + 火山 speaker ID
|
3. **音色列表**: 每个 voice 一行,填对外名 + 火山 speaker ID
|
||||||
4. **确认**: 提交写入 DB,自动跳 `/admin`
|
4. **确认**: 提交写入 DB,自动跳 `/dashboard`
|
||||||
|
|
||||||
### `/admin` 日常管理
|
### `/dashboard` 日常管理
|
||||||
|
|
||||||
| Tab | 用途 |
|
| Tab | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -121,17 +134,31 @@ OpenAI 兼容,鉴权 `Authorization: Bearer <OPENAI_TTS_API_KEY>`(若已设)。
|
|||||||
| `pcm` | pcm | `audio/pcm` |
|
| `pcm` | pcm | `audio/pcm` |
|
||||||
| `aac` / `flac` | mp3 (降级) | `audio/mpeg` |
|
| `aac` / `flac` | mp3 (降级) | `audio/mpeg` |
|
||||||
|
|
||||||
### `GET /health`
|
### `GET /healthz`
|
||||||
|
|
||||||
无鉴权,返回:
|
**无鉴权**。存活探针,**只返回 `200` 与字面量 `ok`,不含任何字段**。
|
||||||
|
|
||||||
|
供 K8s `livenessProbe`/`readinessProbe`、Docker `HEALTHCHECK`、负载均衡健康检查使用 ——
|
||||||
|
这些探针默认不带 `Authorization`,不能指向需要鉴权的 `/health`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/healthz
|
||||||
|
# ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /health`(v0.3.0 起需鉴权)
|
||||||
|
|
||||||
|
**需管理凭证**(`Authorization: Bearer <admin_key>`)。返回完整健康数据:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok", // ok | not_installed | configuration_error
|
"status": "ok", // ok | not_installed | configuration_error
|
||||||
"service": "ByteDance TTS to OpenAI API Adapter",
|
"service": "ByteDance TTS to OpenAI API Adapter",
|
||||||
"version": "v0.2.0",
|
"version": "v0.3.0",
|
||||||
"commit": "621f9f8",
|
"commit": "abc1234",
|
||||||
"uptime": "3600 seconds",
|
"uptime": "3600 seconds",
|
||||||
|
"start_time": "2026-10-04T00:00:00Z",
|
||||||
|
"memory": { "heap_alloc": 0, "heap_inuse": 0, "goroutines": 0 },
|
||||||
"config_status": {
|
"config_status": {
|
||||||
"all_required_vars_set": true,
|
"all_required_vars_set": true,
|
||||||
"config_error": false,
|
"config_error": false,
|
||||||
@@ -145,16 +172,41 @@ OpenAI 兼容,鉴权 `Authorization: Bearer <OPENAI_TTS_API_KEY>`(若已设)。
|
|||||||
- 正常: HTTP 200, `status: "ok"`
|
- 正常: HTTP 200, `status: "ok"`
|
||||||
- 未安装: HTTP 200, `status: "not_installed"`
|
- 未安装: HTTP 200, `status: "not_installed"`
|
||||||
- 配置损坏: HTTP 503, `status: "configuration_error"`, `error` 字段有原因
|
- 配置损坏: HTTP 503, `status: "configuration_error"`, `error` 字段有原因
|
||||||
|
- 无凭证: HTTP **401**
|
||||||
|
|
||||||
|
> v0.3.0 之前本端点是匿名的,会泄漏版本、commit、运行时长、内存与配置错误文本,现已收口。
|
||||||
|
> 管理面板内使用鉴权版 **`GET /api/admin/health`**(返回同样的数据)。
|
||||||
|
|
||||||
### `GET /metrics`
|
### `GET /metrics`
|
||||||
|
|
||||||
Prometheus 文本格式,无鉴权。主要指标见 [观测 / Metrics](#观测--metrics)。
|
**默认不注册**(访问 404)。两种使用方式:
|
||||||
|
|
||||||
### `GET /admin`, `GET /setup`, `GET /dashboard`
|
1. **配置 `METRICS_ALLOW_CIDR`**(内网 CIDR 白名单,逗号分隔):
|
||||||
|
根路径 `/metrics` 仅对白名单来源开放,**非白名单返回 404**(不是 403,避免确认端点存在)。
|
||||||
|
```bash
|
||||||
|
METRICS_ALLOW_CIDR=10.0.0.0/8,172.16.0.0/12
|
||||||
|
```
|
||||||
|
⚠️ Docker 部署请填**容器内网网段**,不要填 `127.0.0.1/32`(那是容器自身回环)。
|
||||||
|
2. **使用鉴权版 `GET /api/admin/metrics`**,带 `Authorization: Bearer <管理凭证>`。
|
||||||
|
Prometheus 侧需配 `authorization: { credentials: <管理凭证> }`。
|
||||||
|
|
||||||
- `/admin`: 后台 (Vue SPA),需鉴权
|
Prometheus 文本格式。主要指标见 [观测 / Metrics](#观测--metrics)。
|
||||||
- `/setup`: 引导式安装页 (Vue SPA),无鉴权,装完自动跳转
|
|
||||||
- `/dashboard`: 服务状态预览页 (无鉴权)
|
### 管理页面与安装页
|
||||||
|
|
||||||
|
| 路径 | 说明 | 鉴权 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /dashboard` | 管理看板 | 需鉴权(浏览器请求返回页面外壳,数据走 API) |
|
||||||
|
| `GET /dashboard/voices` | 音色管理 | 同上 |
|
||||||
|
| `GET /dashboard/settings` | 全局设置 | 同上 |
|
||||||
|
| `GET /dashboard/status` | 详细服务状态(健康 + 指标看板) | 同上 |
|
||||||
|
| `GET /dashboard/login` | 登录页 | **公开**(否则无法登录) |
|
||||||
|
| `GET /setup` | 引导式安装页 | 无鉴权,装完自动跳转 |
|
||||||
|
| `GET /admin` 等旧路径 | 301 永久重定向到对应 `/dashboard/*` | — |
|
||||||
|
|
||||||
|
> **为什么页面是"需鉴权"却又能直接打开?** 浏览器的 `Accept` 含 `text/html` 时返回页面外壳,
|
||||||
|
> 由前端根据 `sessionStorage` 里的凭证决定显示登录视图;非 HTML 请求(脚本、抓取工具)
|
||||||
|
> 无凭证一律 401。页面外壳不含任何数据,数据全部来自鉴权后的 `/api/admin/*` 接口。
|
||||||
|
|
||||||
## 老用户迁移 (从 v0.1.0 → v0.2.0)
|
## 老用户迁移 (从 v0.1.0 → v0.2.0)
|
||||||
|
|
||||||
@@ -162,19 +214,19 @@ v0.1.0 用 env 配 11 个 `BYTEDANCE_TTS_*` 变量。v0.2.0 起改 WebUI:
|
|||||||
|
|
||||||
| 旧 env | v0.2.0 配置入口 |
|
| 旧 env | v0.2.0 配置入口 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `BYTEDANCE_TTS_API_KEY` | `/admin` 设置 → API Key |
|
| `BYTEDANCE_TTS_API_KEY` | `/dashboard` 设置 → API Key |
|
||||||
| `BYTEDANCE_TTS_RESOURCE_ID` | `/admin` 设置 → 默认资源 ID |
|
| `BYTEDANCE_TTS_RESOURCE_ID` | `/dashboard` 设置 → 默认资源 ID |
|
||||||
| `BYTEDANCE_TTS_SPEAKER` | `/admin` 设置 → 默认音色 (填 voice **名字**,不是 speaker ID) |
|
| `BYTEDANCE_TTS_SPEAKER` | `/dashboard` 设置 → 默认音色 (填 voice **名字**,不是 speaker ID) |
|
||||||
| `BYTEDANCE_TTS_FORMAT` | `/admin` 设置 → 默认格式 |
|
| `BYTEDANCE_TTS_FORMAT` | `/dashboard` 设置 → 默认格式 |
|
||||||
| `BYTEDANCE_TTS_SAMPLE_RATE` | `/admin` 设置 → 采样率 |
|
| `BYTEDANCE_TTS_SAMPLE_RATE` | `/dashboard` 设置 → 采样率 |
|
||||||
| `BYTEDANCE_TTS_BIT_RATE` | `/admin` 设置 → MP3 比特率 |
|
| `BYTEDANCE_TTS_BIT_RATE` | `/dashboard` 设置 → MP3 比特率 |
|
||||||
| `BYTEDANCE_TTS_MODEL` | `/admin` 设置 → 子模型 |
|
| `BYTEDANCE_TTS_MODEL` | `/dashboard` 设置 → 子模型 |
|
||||||
| `BYTEDANCE_TTS_MODEL_TYPE` | `/admin` 设置 → 模型类型 |
|
| `BYTEDANCE_TTS_MODEL_TYPE` | `/dashboard` 设置 → 模型类型 |
|
||||||
| `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` | `/admin` 设置 → 显式语言 |
|
| `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` | `/dashboard` 设置 → 显式语言 |
|
||||||
| `BYTEDANCE_TTS_ENABLE_SUBTITLE` | `/admin` 设置 → 启用字级时间戳 |
|
| `BYTEDANCE_TTS_ENABLE_SUBTITLE` | `/dashboard` 设置 → 启用字级时间戳 |
|
||||||
| `BYTEDANCE_TTS_TIMEOUT` | (保留 env 暂未搬 DB) |
|
| `BYTEDANCE_TTS_TIMEOUT` | (保留 env 暂未搬 DB) |
|
||||||
| `ALLOWED_ORIGINS` | `/admin` → CORS → 跨域白名单 |
|
| `ALLOWED_ORIGINS` | `/dashboard` → CORS → 跨域白名单 |
|
||||||
| `OPENAI_TTS_API_KEY` | `/admin` 设置 → OpenAI 端鉴权 key |
|
| `OPENAI_TTS_API_KEY` | `/dashboard` 设置 → OpenAI 端鉴权 key |
|
||||||
|
|
||||||
**逐步迁移建议**:
|
**逐步迁移建议**:
|
||||||
|
|
||||||
@@ -251,14 +303,30 @@ Prometheus 文本格式,无鉴权。可直接被 Prometheus 抓取或浏览器
|
|||||||
- 反代**仅**把鉴权后的请求转发到 `:8080`
|
- 反代**仅**把鉴权后的请求转发到 `:8080`
|
||||||
- 此时 `OPENAI_TTS_API_KEY` 可不设
|
- 此时 `OPENAI_TTS_API_KEY` 可不设
|
||||||
|
|
||||||
**`/metrics` / `/dashboard` / `/health` 均不鉴权**,生产环境务必通过反代保护:
|
### v0.3.0 起各端点的暴露面
|
||||||
|
|
||||||
|
v0.3.0 已把健康与指标端点收口,**默认不再有匿名可读的监控端点**:
|
||||||
|
|
||||||
|
| 端点 | 默认状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/healthz` | **匿名可读** | 只回 `200 ok`,**不含任何字段**,专供探针 |
|
||||||
|
| `/health` | 需鉴权 | 完整健康数据 |
|
||||||
|
| `/metrics`(根路径) | **默认 404** | 需 `METRICS_ALLOW_CIDR` 才注册 |
|
||||||
|
| `/dashboard/*` | 需鉴权 | 管理面板(HTML 请求返回外壳,数据走 API) |
|
||||||
|
| `/api/admin/*` | 需鉴权 | 管理 API(含 `/api/admin/health`、`/api/admin/metrics`) |
|
||||||
|
| `/api/public/*` | 无鉴权 | 预留空壳,当前无业务 |
|
||||||
|
|
||||||
|
因此反代**只需**确保业务与管理接口不被匿名滥用;监控端点已由应用自身把关:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location /metrics { allow 10.0.0.0/8; deny all; } # 仅 Prometheus 服务器
|
# 若未配 METRICS_ALLOW_CIDR,根路径 /metrics 本就 404,无需额外规则。
|
||||||
|
# 若希望 Prometheus 走根路径,建议在应用侧配 METRICS_ALLOW_CIDR,而不是在反代做白名单。
|
||||||
location /dashboard { auth_basic "admin"; auth_basic_user_file /etc/nginx/.htpasswd; }
|
location /dashboard { auth_basic "admin"; auth_basic_user_file /etc/nginx/.htpasswd; }
|
||||||
location /health { allow 10.0.0.0/8; deny all; } # 或 K8s liveness probe 直接访问
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ⚠️ **探针必须指向 `/healthz`**。K8s `livenessProbe`/`readinessProbe`、Docker `HEALTHCHECK`
|
||||||
|
> 默认不带 `Authorization`,若仍指向 `/health` 会一律 401,导致 Pod 反复重启 / 容器被判不健康。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -269,6 +337,7 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` 已配 named volume `tts-api-data` 挂载到容器 `/data`,DB 与 lock 文件持久化,容器重启不丢配置。
|
`docker-compose.yml` 已配 named volume `tts-api-data` 挂载到容器 `/data`,DB 与 lock 文件持久化,容器重启不丢配置。
|
||||||
|
其 `healthcheck` 已使用 `/healthz`。
|
||||||
|
|
||||||
### Linux Systemd
|
### Linux Systemd
|
||||||
|
|
||||||
@@ -282,7 +351,7 @@ docker compose up -d
|
|||||||
| `installer/` | 启动期模式检测,DB 自愈回退 |
|
| `installer/` | 启动期模式检测,DB 自愈回退 |
|
||||||
| `setting/` | 全局配置 (TTSOptions / Auth / CORS) + 启动汇总 |
|
| `setting/` | 全局配置 (TTSOptions / Auth / CORS) + 启动汇总 |
|
||||||
| `store/` | SQLite 数据访问 (settings / voices) + 自愈 |
|
| `store/` | SQLite 数据访问 (settings / voices) + 自愈 |
|
||||||
| `controller/` | /v1/audio/speech、/health、/setup、/admin、/api/setup、/api/settings、/api/voices |
|
| `controller/` | /v1/audio/speech、/healthz、/health、/setup、/dashboard、/api/setup、/api/admin/* |
|
||||||
| `middleware/` | SecurityHeaders, CORS, 鉴权, 限流, 并发, 日志, 客户端 IP 提取, install 模式守卫 |
|
| `middleware/` | SecurityHeaders, CORS, 鉴权, 限流, 并发, 日志, 客户端 IP 提取, install 模式守卫 |
|
||||||
| `router/` | 路由注册 |
|
| `router/` | 路由注册 |
|
||||||
| `adapter/volcano/` | 火山 v3 HTTP Chunked 客户端 |
|
| `adapter/volcano/` | 火山 v3 HTTP Chunked 客户端 |
|
||||||
@@ -339,12 +408,12 @@ PORT=8081 ./tts-api
|
|||||||
|
|
||||||
如有问题,请检查:
|
如有问题,请检查:
|
||||||
1. 服务启动后日志第一段 "环境配置汇总" — 火山必填项是否全 ✓
|
1. 服务启动后日志第一段 "环境配置汇总" — 火山必填项是否全 ✓
|
||||||
2. `/health` 返回 `status: "ok"` 且 `config_error: false`
|
2. `/healthz` 返回 `ok`(存活);详细状态用鉴权接口 `/api/admin/health`,应返回 `status: "ok"` 且 `config_error: false`
|
||||||
3. `/admin` → 设置 tab 检查 API Key / 默认资源 ID / 默认音色
|
3. `/dashboard` → 设置 tab 检查 API Key / 默认资源 ID / 默认音色
|
||||||
4. `/admin` → 音色管理 tab 检查 voice 是否启用
|
4. `/dashboard` → 音色 tab 检查 voice 是否启用
|
||||||
5. 火山控制台 → 在线体验同一对 resource + speaker 能合成
|
5. 火山控制台 → 在线体验同一对 resource + speaker 能合成
|
||||||
6. 客户端请求 URL 是否以 https:// 开头 (公网)
|
6. 客户端请求 URL 是否以 https:// 开头 (公网)
|
||||||
7. `/admin` → CORS tab 检查白名单含前端完整 origin
|
7. `/dashboard` → 设置 → CORS 检查白名单含前端完整 origin
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -244,7 +244,7 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"message": "installed",
|
"message": "installed",
|
||||||
"redirect": "/admin",
|
"redirect": "/dashboard", // v0.3.0: 管理入口由 /admin 迁至 /dashboard
|
||||||
"settings": len(settingsKV),
|
"settings": len(settingsKV),
|
||||||
"voices": inserted,
|
"voices": inserted,
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-3
@@ -13,15 +13,23 @@ services:
|
|||||||
- TTS_DB_PATH=/data/tts.db
|
- TTS_DB_PATH=/data/tts.db
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-}
|
- OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-}
|
||||||
# 可选,装好后也可在 /admin 改
|
# 可选,装好后也可在 /dashboard 改
|
||||||
- TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS:-}
|
- TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS:-}
|
||||||
|
# v0.3.0 新增(可选):根路径 /metrics 的内网 CIDR 白名单,逗号分隔。
|
||||||
|
# 不配置时根路径 /metrics 完全不注册(Prometheus 请改用鉴权版 /api/admin/metrics)。
|
||||||
|
# ⚠️ 这里必须填「容器内网网段」,不要填 127.0.0.1/32 ——
|
||||||
|
# 那是容器自身回环,Prometheus 在宿主机/其他容器里根本进不来。
|
||||||
|
# 例:METRICS_ALLOW_CIDR=172.16.0.0/12
|
||||||
|
- METRICS_ALLOW_CIDR=${METRICS_ALLOW_CIDR:-}
|
||||||
volumes:
|
volumes:
|
||||||
# DB + lock 文件必须挂载,否则容器重启后配置全丢
|
# DB + lock 文件必须挂载,否则容器重启后配置全丢
|
||||||
- tts-data:/data
|
- tts-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# 用 wget 检查,alpine 镜像有
|
# v0.3.0:必须用 /healthz(匿名存活探针)。
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
# /health 现在需要管理凭证,而健康检查不带 Authorization ——
|
||||||
|
# 继续用 /health 会让容器一直被判定为 unhealthy。
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
+45
-19
@@ -10,6 +10,27 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 实施进度
|
||||||
|
|
||||||
|
> **编号说明**:初版方案的"阶段 1(收紧鉴权)"与"阶段 2(收口端点)"属于同一次安全改造、
|
||||||
|
> 必须一起上线才有意义(只做前者是"有风险无收益"),已**合并为现在的「阶段 1」**,
|
||||||
|
> 后续阶段依次顺延。下表的阶段号均为合并后编号。
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 | 提交 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 收紧 `RequireAdmin` + 独立 `admin_key` + 启动期校验 + 收口健康/指标端点 + `/healthz` + `METRICS_ALLOW_CIDR` | ✅ 已完成 | 见下 |
|
||||||
|
| 2 | 路由分层 + 旧路径别名 + `/admin`→`/dashboard` | ✅ 已完成 | 见下 |
|
||||||
|
| 3 | 文档 / 示例配置 / Docker 注释 / CHANGELOG | ✅ 已完成 | 见下 |
|
||||||
|
| 4 | 全量回归测试(发布前) | ⏳ 待执行 | — |
|
||||||
|
|
||||||
|
**阶段 3 额外修复的两处部署陷阱**(阶段 1 给 `/health` 加鉴权引发的连带问题):
|
||||||
|
|
||||||
|
- `docker-compose.yml` 的 `healthcheck` 原指向 `/health`;该端点加鉴权后会一律 401,
|
||||||
|
导致**容器被判定为 unhealthy** → 已改为 `/healthz`
|
||||||
|
- `Dockerfile` 的 `HEALTHCHECK` 同样指向 `/health` → 已改为 `/healthz`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 改造目标
|
## 0. 改造目标
|
||||||
|
|
||||||
| # | 目标 | 验收标准 |
|
| # | 目标 | 验收标准 |
|
||||||
@@ -70,7 +91,12 @@
|
|||||||
|
|
||||||
## 2. 分阶段实施
|
## 2. 分阶段实施
|
||||||
|
|
||||||
### 阶段 1|收紧 `RequireAdmin`(前置缺口,最小改动)
|
### 阶段 1|鉴权收紧 + 健康/指标端点收口(本版本核心)
|
||||||
|
|
||||||
|
> 本节由初版方案的「阶段 1(收紧 `RequireAdmin`)」与「阶段 2(收口端点)」**合并**而成。
|
||||||
|
> 合并原因:两者是同一件事的两半 —— 只做前者(收紧空凭证放行)而不同时收口端点,
|
||||||
|
> 结果是"有风险无收益"(旧部署可能起不来,而 `/health`、`/dashboard`、`/metrics` 仍匿名可读)。
|
||||||
|
> 下面 1.1–1.3 对应初版的阶段 1,1.4–1.6 对应初版的阶段 2。
|
||||||
|
|
||||||
**为什么先做这一步**:`RequireAdmin` 当前在凭证列表为空时**直接放行**(`middleware/admin_auth.go:29-33`)。
|
**为什么先做这一步**:`RequireAdmin` 当前在凭证列表为空时**直接放行**(`middleware/admin_auth.go:29-33`)。
|
||||||
不修这个,后面给 `/metrics`、`/health` 套 `RequireAdmin` **等于没套**。
|
不修这个,后面给 `/metrics`、`/health` 套 `RequireAdmin` **等于没套**。
|
||||||
@@ -110,7 +136,7 @@ if len(keys) == 0 {
|
|||||||
> ⚠️ **破坏性提示**:若你的部署目前未配置任何凭证,本阶段后服务将无法启动。
|
> ⚠️ **破坏性提示**:若你的部署目前未配置任何凭证,本阶段后服务将无法启动。
|
||||||
> 这是刻意的——此前那种状态下管理接口是完全裸奔的。请先配好 `auth_key`(或 `admin_key`)再升级。
|
> 这是刻意的——此前那种状态下管理接口是完全裸奔的。请先配好 `auth_key`(或 `admin_key`)再升级。
|
||||||
|
|
||||||
#### 1.4 ✅ 阶段 1 回归
|
#### 1.4 ✅ 鉴权部分回归
|
||||||
|
|
||||||
- [ ] `go build ./... && go vet ./...` 通过
|
- [ ] `go build ./... && go vet ./...` 通过
|
||||||
- [ ] 配置 `auth_key` 后:`/api/admin/overview` 带正确 key 返回 200,错误 key 返回 401
|
- [ ] 配置 `auth_key` 后:`/api/admin/overview` 带正确 key 返回 200,错误 key 返回 401
|
||||||
@@ -120,11 +146,11 @@ if len(keys) == 0 {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 阶段 2|收口健康 / 指标端点(本版本核心目标)
|
#### 1.5 端点收口(本版本核心目标)
|
||||||
|
|
||||||
**改动范围**:`router/router.go`、`controller/tts.go`、`middleware/`(新增 CIDR 白名单)、`setting/config.go`
|
**改动范围**:`router/router.go`、`controller/tts.go`、`middleware/`(新增 CIDR 白名单)、`setting/config.go`
|
||||||
|
|
||||||
#### 2.1 `/health` 拆分为两个端点
|
#### 1.5.1 `/health` 拆分为两个端点
|
||||||
|
|
||||||
| 端点 | 鉴权 | 返回 |
|
| 端点 | 鉴权 | 返回 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -137,7 +163,7 @@ if len(keys) == 0 {
|
|||||||
> 📌 探针迁移提醒:若现有 K8s `livenessProbe` / `readinessProbe` / Docker `HEALTHCHECK`
|
> 📌 探针迁移提醒:若现有 K8s `livenessProbe` / `readinessProbe` / Docker `HEALTHCHECK`
|
||||||
> 指向 `/health`,需同步改指 `/healthz`。README 中要写明。
|
> 指向 `/health`,需同步改指 `/healthz`。README 中要写明。
|
||||||
|
|
||||||
#### 2.2 `/metrics` 收口 + 可选内网白名单
|
#### 1.5.2 `/metrics` 收口 + 可选内网白名单
|
||||||
|
|
||||||
新环境变量 `METRICS_ALLOW_CIDR`(逗号分隔多个 CIDR):
|
新环境变量 `METRICS_ALLOW_CIDR`(逗号分隔多个 CIDR):
|
||||||
|
|
||||||
@@ -152,14 +178,14 @@ if len(keys) == 0 {
|
|||||||
> ⚠️ **Docker 环境**:`METRICS_ALLOW_CIDR` 必须填**容器内网网段**(如 `172.16.0.0/12`),
|
> ⚠️ **Docker 环境**:`METRICS_ALLOW_CIDR` 必须填**容器内网网段**(如 `172.16.0.0/12`),
|
||||||
> **不要填 `127.0.0.1/32`** —— 那是容器自身的回环,Prometheus 在宿主机或其他容器里进不来。
|
> **不要填 `127.0.0.1/32`** —— 那是容器自身的回环,Prometheus 在宿主机或其他容器里进不来。
|
||||||
|
|
||||||
#### 2.3 `/dashboard` 与 `/api/setup/status` 收口
|
#### 1.5.3 `/dashboard` 与 `/api/setup/status` 收口
|
||||||
|
|
||||||
- `/dashboard`:套 `RequireAdmin`。它是本版本**单点泄漏最严重**的端点(一个 URL 暴露全部健康数据)。
|
- `/dashboard`:套 `RequireAdmin`。它是本版本**单点泄漏最严重**的端点(一个 URL 暴露全部健康数据)。
|
||||||
- `/api/setup/status`:套 `RequireAdmin`。
|
- `/api/setup/status`:套 `RequireAdmin`。
|
||||||
- **前端状态提示**:`/dashboard` 的 HTML 外壳本身不敏感,前端登录判断逻辑保持现状
|
- **前端状态提示**:`/dashboard` 的 HTML 外壳本身不敏感,前端登录判断逻辑保持现状
|
||||||
(`sessionStorage` 无 key → 显示登录视图)。**不在服务端做页面级跳转**,原因见 [附录 B](#附录-b为什么不做-cookie-会话)。
|
(`sessionStorage` 无 key → 显示登录视图)。**不在服务端做页面级跳转**,原因见 [附录 B](#附录-b为什么不做-cookie-会话)。
|
||||||
|
|
||||||
#### 2.4 ✅ 阶段 2 回归
|
#### 1.6 ✅ 端点收口回归
|
||||||
|
|
||||||
- [ ] 未配置 `METRICS_ALLOW_CIDR`:根 `/metrics`、`/health` 返回 404
|
- [ ] 未配置 `METRICS_ALLOW_CIDR`:根 `/metrics`、`/health` 返回 404
|
||||||
- [ ] 配置 `METRICS_ALLOW_CIDR=127.0.0.1/32`:本机可访问根 `/metrics`,其他 IP 返回 404
|
- [ ] 配置 `METRICS_ALLOW_CIDR=127.0.0.1/32`:本机可访问根 `/metrics`,其他 IP 返回 404
|
||||||
@@ -171,11 +197,11 @@ if len(keys) == 0 {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 阶段 3|路由分层与路径迁移
|
### 阶段 2|路由分层与路径迁移
|
||||||
|
|
||||||
**改动范围**:`router/router.go`、`controller/`(新增两个桩 handler + 页面路由别名)
|
**改动范围**:`router/router.go`、`controller/`(新增两个桩 handler + 页面路由别名)
|
||||||
|
|
||||||
#### 3.1 分层路由注册
|
#### 2.1 分层路由注册
|
||||||
|
|
||||||
```go
|
```go
|
||||||
apiPublic := r.PathPrefix("/api/public").Subrouter()
|
apiPublic := r.PathPrefix("/api/public").Subrouter()
|
||||||
@@ -202,7 +228,7 @@ apiAdmin.Use(middleware.RequireAdmin)
|
|||||||
| `GET /api/admin/overview` | 位置不变 |
|
| `GET /api/admin/overview` | 位置不变 |
|
||||||
| `GET /api/admin/metrics` | 位置不变 |
|
| `GET /api/admin/metrics` | 位置不变 |
|
||||||
|
|
||||||
#### 3.2 旧路径兼容:用**别名**,不要重定向
|
#### 2.2 旧路径兼容:用**别名**,不要重定向
|
||||||
|
|
||||||
**旧路径指向同一个 handler,不做 301/308 跳转。**
|
**旧路径指向同一个 handler,不做 301/308 跳转。**
|
||||||
|
|
||||||
@@ -216,7 +242,7 @@ apiAdmin.Use(middleware.RequireAdmin)
|
|||||||
|
|
||||||
旧路径在当前版本**保留**,并在代码注释标注"过渡期别名,未来版本移除"。
|
旧路径在当前版本**保留**,并在代码注释标注"过渡期别名,未来版本移除"。
|
||||||
|
|
||||||
#### 3.3 页面路由迁移
|
#### 2.3 页面路由迁移
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 新入口
|
// 新入口
|
||||||
@@ -246,14 +272,14 @@ r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
|||||||
> 变成"管理后台首页",健康/指标数据改由 `/api/admin/health`、`/api/admin/metrics` 提供。
|
> 变成"管理后台首页",健康/指标数据改由 `/api/admin/health`、`/api/admin/metrics` 提供。
|
||||||
> 若仍希望保留独立的只读状态页,请单独确认(本版本不提供匿名版本)。
|
> 若仍希望保留独立的只读状态页,请单独确认(本版本不提供匿名版本)。
|
||||||
|
|
||||||
#### 3.4 桩接口
|
#### 2.4 桩接口
|
||||||
|
|
||||||
| 端点 | 鉴权 | 行为 |
|
| 端点 | 鉴权 | 行为 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET /api/user/usage` | `RequireAdmin` | 单用户模式返回全局用量统计(桩,v0.4.0 改为按用户过滤) |
|
| `GET /api/user/usage` | `RequireAdmin` | 单用户模式返回全局用量统计(桩,v0.4.0 改为按用户过滤) |
|
||||||
| `GET /v1/dashboard/billing/usage` | Bearer 业务 key | 返回用量统计,对齐 OpenAI 返回格式 |
|
| `GET /v1/dashboard/billing/usage` | Bearer 业务 key | 返回用量统计,对齐 OpenAI 返回格式 |
|
||||||
|
|
||||||
#### 3.5 ✅ 阶段 3 回归
|
#### 2.5 ✅ 阶段 2 回归
|
||||||
|
|
||||||
- [ ] `/admin` 301 跳 `/dashboard`;`/dashboard` 各页面与静态资源正常加载(无 404、无样式丢失)
|
- [ ] `/admin` 301 跳 `/dashboard`;`/dashboard` 各页面与静态资源正常加载(无 404、无样式丢失)
|
||||||
- [ ] 面板内音色增删改、全局设置、CORS 配置全部正常
|
- [ ] 面板内音色增删改、全局设置、CORS 配置全部正常
|
||||||
@@ -264,7 +290,7 @@ r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 阶段 4|文档、配置、发布
|
### 阶段 3|文档、配置、发布
|
||||||
|
|
||||||
**改动范围**:`README.md`、`.env.example`、`docker-compose.yml`、`CHANGELOG.md`
|
**改动范围**:`README.md`、`.env.example`、`docker-compose.yml`、`CHANGELOG.md`
|
||||||
|
|
||||||
@@ -289,7 +315,7 @@ r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 阶段 5|全量回归测试(发布前必须全部通过)
|
#### 阶段 4|全量回归测试(发布前必须全部通过)
|
||||||
|
|
||||||
> ⚠️ **本项目测试文件不入库**(`.gitignore` 的 `*_test.go`),CI 只能守住"编译 + vet",
|
> ⚠️ **本项目测试文件不入库**(`.gitignore` 的 `*_test.go`),CI 只能守住"编译 + vet",
|
||||||
> **守不住行为回归**。因此下面的手工回归是发布前的唯一防线,必须逐条执行并记录结果。
|
> **守不住行为回归**。因此下面的手工回归是发布前的唯一防线,必须逐条执行并记录结果。
|
||||||
@@ -332,9 +358,9 @@ r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 发布 v0.3.0
|
## 3. 发布 v0.3.0
|
||||||
|
|
||||||
1. 阶段 5 全部通过;Docker 镜像构建测试通过
|
1. 阶段 4 全部通过;Docker 镜像构建测试通过
|
||||||
2. `git tag v0.3.0` 并推送
|
2. `git tag v0.3.0` 并推送
|
||||||
3. 创建 Release,粘贴 Release Notes,**明确列出 5 条 Breaking Change**
|
3. 创建 Release,粘贴 Release Notes,**明确列出 5 条 Breaking Change**
|
||||||
4. 创建维护分支 `v0.3.x`,用于后续 bugfix 与安全补丁
|
4. 创建维护分支 `v0.3.x`,用于后续 bugfix 与安全补丁
|
||||||
@@ -342,7 +368,7 @@ r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. v0.4.0 后续规划(仅规划,不在 v0.3.0 实现)
|
## 4. v0.4.0 后续规划(仅规划,不在 v0.3.0 实现)
|
||||||
|
|
||||||
v0.3.0 已把**路由分层**和**权限隔离**打好;v0.4.0 只需填充业务逻辑。
|
v0.3.0 已把**路由分层**和**权限隔离**打好;v0.4.0 只需填充业务逻辑。
|
||||||
|
|
||||||
@@ -355,7 +381,7 @@ v0.3.0 已把**路由分层**和**权限隔离**打好;v0.4.0 只需填充业
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 风险注意事项
|
## 5. 风险注意事项
|
||||||
|
|
||||||
| # | 风险 | 对策 |
|
| # | 风险 | 对策 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# v0.3.0 Release Notes
|
||||||
|
|
||||||
|
> 发布日期:待定 · 基线:`develop`
|
||||||
|
> 主题:**路由鉴权架构升级 · 健康与指标端点收口 · 管理入口统一**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话概括
|
||||||
|
|
||||||
|
v0.3.0 把**默认对外的监控面收窄到只有一个匿名存活探针**,
|
||||||
|
并把**管理凭证与业务调用凭证拆开**,同时把管理入口统一到 `/dashboard`。
|
||||||
|
业务接口 `/v1/audio/speech` 行为不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Breaking Changes(升级前必读)
|
||||||
|
|
||||||
|
### 1. 监控端点不再匿名可读
|
||||||
|
|
||||||
|
| 端点 | v0.2.x | v0.3.0 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/healthz` | 不存在 | **匿名可读**,只回 `200 ok`(不含任何字段) |
|
||||||
|
| `/health` | 匿名,返回完整健康数据 | **需管理凭证**,否则 401 |
|
||||||
|
| `/metrics`(根路径) | 匿名 | **默认 404**;需配置 `METRICS_ALLOW_CIDR` 才注册 |
|
||||||
|
| `/dashboard` | 匿名(服务状态预览页) | **需鉴权**(且已成为管理看板,见第 3 条) |
|
||||||
|
| `/api/setup/status` | 匿名 | **需管理凭证** |
|
||||||
|
|
||||||
|
**你需要做的**:
|
||||||
|
|
||||||
|
- **K8s 探针 / Docker HEALTHCHECK** 改指 **`/healthz`**。
|
||||||
|
继续指向 `/health` 会一律 401 → Pod 反复重启 / 容器被判 unhealthy。
|
||||||
|
(本仓库的 `docker-compose.yml` 与 `Dockerfile` 已同步修改。)
|
||||||
|
- **Prometheus** 二选一:
|
||||||
|
- 改用鉴权版 `/api/admin/metrics`,并在 `scrape_configs` 配
|
||||||
|
`authorization: { credentials: <管理凭证> }`;
|
||||||
|
- 或配置 `METRICS_ALLOW_CIDR` 走根路径白名单。
|
||||||
|
⚠️ Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环),
|
||||||
|
请填容器内网网段,例如 `172.16.0.0/12`。
|
||||||
|
|
||||||
|
### 2. 未配置管理凭证时服务拒绝启动
|
||||||
|
|
||||||
|
`RequireAdmin` 此前在凭证列表为空时**直接放行**(等于管理接口完全裸奔)。
|
||||||
|
现在该情况返回 401;并且 **normal 模式下若 `admin_key` / `auth_key` /
|
||||||
|
`OPENAI_TTS_API_KEY` 三者皆为空,服务将 fail-fast 拒绝启动**。
|
||||||
|
|
||||||
|
**你需要做的**:升级前确认三者至少配置一个,否则服务起不来。
|
||||||
|
|
||||||
|
### 3. 管理入口从 `/admin` 迁至 `/dashboard`
|
||||||
|
|
||||||
|
- `/dashboard`、`/dashboard/voices`、`/dashboard/settings` 为新入口
|
||||||
|
- 详细服务状态页迁至 **`/dashboard/status`**(原 `/dashboard` 是状态预览页)
|
||||||
|
- `/admin` 及子路径 **301 永久重定向**到对应新路径,书签与脚本请尽快迁移
|
||||||
|
|
||||||
|
### 4. 管理 API 正式路径改为 `/api/admin/*`
|
||||||
|
|
||||||
|
- 新:`/api/admin/voices`、`/api/admin/settings`(含 `api-key` / `auth-key` / `admin-key`)
|
||||||
|
- 旧:`/api/voices`、`/api/settings` **仍可用**(**别名**,不是重定向),计划后续版本移除
|
||||||
|
|
||||||
|
> 为什么用别名而不是 308:301 会把 `POST`/`PUT`/`PATCH`/`DELETE` 降级成 `GET`
|
||||||
|
> 且被浏览器永久缓存;308 虽保留方法,但对非浏览器脚本仍是行为变化。
|
||||||
|
> 别名零往返、零破坏,老脚本完全无感。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 新特性
|
||||||
|
|
||||||
|
### 独立管理凭证 `admin_key`
|
||||||
|
|
||||||
|
管理后台登录凭证与业务调用凭证分离。取值优先级:
|
||||||
|
|
||||||
|
```
|
||||||
|
admin_key(DB) → auth_key(DB) → OPENAI_TTS_API_KEY(env)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不配置**:回退使用 `auth_key`,**行为与 v0.2.x 完全一致**
|
||||||
|
- **配置后**:业务调用方持有的 `auth_key` **无法访问管理接口**(返回 401)
|
||||||
|
|
||||||
|
配置方式:`PUT /api/admin/settings/admin-key`,或在 `/dashboard → 设置` 中操作。
|
||||||
|
凭证**只写不读** —— `GET /api/admin/settings` 只返回打码值与来源标记。
|
||||||
|
|
||||||
|
### 内网 CIDR 白名单 `METRICS_ALLOW_CIDR`
|
||||||
|
|
||||||
|
逗号分隔,支持裸 IP 自动补掩码。非白名单来源返回 **404**(而非 403,
|
||||||
|
避免向扫描者确认端点存在)。未配置时根路径 `/metrics` **完全不注册**。
|
||||||
|
|
||||||
|
### 匿名存活探针 `/healthz`
|
||||||
|
|
||||||
|
刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式),只回答"进程还在不在"。
|
||||||
|
运维要细节请走鉴权后的 `/health` 或 `/api/admin/health`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 修复
|
||||||
|
|
||||||
|
- `RequireAdmin` 空凭证放行(安全缺口,见 Breaking Change 第 2 条)
|
||||||
|
- `/api/setup/status` 在 normal 模式下仍匿名暴露 `{installed, mode}`
|
||||||
|
- `docker-compose.yml` / `Dockerfile` 的健康检查端点(随鉴权改动失效)
|
||||||
|
- 指标写入对空接收者安全(`nil *Counter` / `*Gauge` / `*Histogram` 不再 panic)
|
||||||
|
- 上游 `HTTPClient` 未初始化时返回错误而非 panic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
go test ./... -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:本仓库测试源码**不入库**(`.gitignore` 的 `*_test.go`),
|
||||||
|
> 干净克隆上 `go test` 只会打印 `no test files`。
|
||||||
|
> 自动化测试需在开发机(测试文件所在处)执行。
|
||||||
|
> 发布前请按 [IMPLEMENT_v0.3.0.md](IMPLEMENT_v0.3.0.md) 第 3 节逐条手工回归。
|
||||||
|
|
||||||
|
端到端实测结果(真实服务器):
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `/healthz` 匿名 | `200` + 字面量 `ok` |
|
||||||
|
| `/health` 无凭证 / 带凭证 | `401` / `200` |
|
||||||
|
| 根 `/metrics` 未配 CIDR | `404`(不注册) |
|
||||||
|
| `/admin`、`/admin/login`、`/admin/voices`、`/admin/settings` | 全部 `301` 至对应 `/dashboard/*` |
|
||||||
|
| `/dashboard*` 浏览器请求 / 脚本请求 | `200` 页面外壳 / `401` |
|
||||||
|
| `/api/admin/*` 与 `/api/*` 别名 | 均 `200`(无 301/308) |
|
||||||
|
| 配独立 `admin_key` 后业务 key 访问管理接口 | `401` |
|
||||||
|
| 业务接口 `/v1/audio/speech` | 行为不变 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 升级步骤
|
||||||
|
|
||||||
|
1. 确认已配置 `admin_key` / `auth_key` / `OPENAI_TTS_API_KEY` 至少一个
|
||||||
|
2. 更新探针指向 `/healthz`
|
||||||
|
3. 更新 Prometheus 抓取配置(鉴权版或 `METRICS_ALLOW_CIDR`)
|
||||||
|
4. 替换镜像 / 二进制并重启
|
||||||
|
5. 访问 `/dashboard` 登录验证;确认旧 `/admin` 书签仍能自动跳转
|
||||||
|
6. 按需在 `/dashboard → 设置` 配置独立 `admin_key`
|
||||||
|
|
||||||
|
**无需数据库迁移** —— 旧的 `tts.db` 可直接复用。
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>管理后台 · 登录</title>
|
<title>管理后台 · 登录</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref } = Vue;
|
const { createApp, ref } = Vue;
|
||||||
const { http, setKey, clearKey, getKey } = window.admin;
|
const { http, setKey, clearKey, getKey } = window.admin;
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
// 已登录则直接跳 dashboard
|
// 已登录则直接跳 dashboard
|
||||||
if (getKey()) {
|
if (getKey()) {
|
||||||
http.get('/admin/overview').then(() => {
|
http.get('/admin/overview').then(() => {
|
||||||
location.href = '/admin';
|
location.href = '/dashboard';
|
||||||
}).catch(() => { clearKey(); });
|
}).catch(() => { clearKey(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
loginErr.value = '';
|
loginErr.value = '';
|
||||||
try {
|
try {
|
||||||
await window.admin.login(keyInput.value);
|
await window.admin.login(keyInput.value);
|
||||||
location.href = '/admin';
|
location.href = '/dashboard';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
|
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>火山 TTS · 系统设置</title>
|
<title>火山 TTS · 系统设置</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">鉴权 Key</div>
|
<div class="card-title">鉴权 Key</div>
|
||||||
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code> 与登录 /admin 共用</div>
|
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code>。管理后台登录改用 <code>admin_key</code>(未配置时回退用本 Key)</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill pill-accent">敏感</span>
|
<span class="pill pill-accent">敏感</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, computed, onMounted } = Vue;
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
|
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
authKeyInput.value = '';
|
authKeyInput.value = '';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearKey();
|
clearKey();
|
||||||
location.href = '/admin/login';
|
location.href = '/dashboard/login';
|
||||||
}, 200);
|
}, 200);
|
||||||
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
|
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
const resetSettingsForm = () => { loadSettings(); };
|
const resetSettingsForm = () => { loadSettings(); };
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
mountShell({
|
mountShell({
|
||||||
activeNav: 'settings',
|
activeNav: 'settings',
|
||||||
overview: overview.value,
|
overview: overview.value,
|
||||||
|
|||||||
+10
-4
@@ -112,21 +112,27 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">概览</div>
|
<div class="nav-label">概览</div>
|
||||||
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/admin">
|
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/dashboard">
|
||||||
<span class="nav-icon">▦</span><span>仪表盘</span>
|
<span class="nav-icon">▦</span><span>仪表盘</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">管理</div>
|
<div class="nav-label">管理</div>
|
||||||
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/admin/voices">
|
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/dashboard/voices">
|
||||||
<span class="nav-icon">♪</span><span>音色</span>
|
<span class="nav-icon">♪</span><span>音色</span>
|
||||||
<span class="nav-badge">${voiceCount}</span>
|
<span class="nav-badge">${voiceCount}</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/admin/settings">
|
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/dashboard/settings">
|
||||||
<span class="nav-icon">⚙</span><span>设置</span>
|
<span class="nav-icon">⚙</span><span>设置</span>
|
||||||
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
|
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-label">观测</div>
|
||||||
|
<a class="nav-item${activeNav === 'status' ? ' active' : ''}" href="/dashboard/status">
|
||||||
|
<span class="nav-icon">♥</span><span>详细状态</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-foot">
|
<div class="sidebar-foot">
|
||||||
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
|
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
|
||||||
@@ -142,7 +148,7 @@
|
|||||||
if (btnLogout) btnLogout.addEventListener('click', () => {
|
if (btnLogout) btnLogout.addEventListener('click', () => {
|
||||||
clearKey();
|
clearKey();
|
||||||
if (typeof options.onLogout === 'function') options.onLogout();
|
if (typeof options.onLogout === 'function') options.onLogout();
|
||||||
else location.href = '/admin/login';
|
else location.href = '/dashboard/login';
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>火山 TTS · 音色管理</title>
|
<title>火山 TTS · 音色管理</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
</teleport>
|
</teleport>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, computed, onMounted } = Vue;
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
const { http, getKey, toast, mountShell } = window.admin;
|
const { http, getKey, toast, mountShell } = window.admin;
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
mountShell({
|
mountShell({
|
||||||
activeNav: 'voices',
|
activeNav: 'voices',
|
||||||
overview: overview.value,
|
overview: overview.value,
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@
|
|||||||
<title>火山 TTS · 仪表盘</title>
|
<title>火山 TTS · 仪表盘</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, onMounted } = Vue;
|
const { createApp, ref, onMounted } = Vue;
|
||||||
const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin;
|
const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin;
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview });
|
mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview });
|
||||||
loadOverview();
|
loadOverview();
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-3
@@ -6,6 +6,7 @@
|
|||||||
<title>TTS 服务监控</title>
|
<title>TTS 服务监控</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0f1a;
|
--bg: #0b0f1a;
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-flex">
|
<div class="row-flex">
|
||||||
|
<a class="btn" href="/dashboard" style="text-decoration:none;">← 返回控制台</a>
|
||||||
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
|
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
|
||||||
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
|
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
|
||||||
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
|
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
|
||||||
@@ -377,8 +379,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, computed, onMounted } = Vue;
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
|
// v0.3.0:/health 与 /metrics 已收口到鉴权接口,统一走带凭证头的 /api/admin/*
|
||||||
|
const { http, getKey } = window.admin;
|
||||||
|
|
||||||
function parseProm(text) {
|
function parseProm(text) {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -597,16 +602,16 @@
|
|||||||
|
|
||||||
const fetchHealth = async () => {
|
const fetchHealth = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await axios.get("/health");
|
const r = await http.get("/admin/health");
|
||||||
health.value = r.data;
|
health.value = r.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = "无法获取健康状态: " + (e.message || "未知错误");
|
error.value = "无法获取健康状态: " + (e.response?.data?.error?.message || e.message || "未知错误");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await axios.get("/metrics");
|
const r = await http.get("/admin/metrics");
|
||||||
metrics.value = parseProm(r.data);
|
metrics.value = parseProm(r.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 不覆盖 error,让 health 错误更显眼
|
// 不覆盖 error,让 health 错误更显眼
|
||||||
@@ -650,6 +655,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// v0.3.0:本页已收口到管理鉴权,未登录先回登录页
|
||||||
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 10000);
|
setInterval(refresh, 10000);
|
||||||
});
|
});
|
||||||
|
|||||||
+66
-26
@@ -66,7 +66,7 @@ func Setup() *mux.Router {
|
|||||||
// /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。
|
// /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。
|
||||||
r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) {
|
r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) {
|
||||||
if installer.GetMode() == installer.ModeNormal {
|
if installer.GetMode() == installer.ModeNormal {
|
||||||
http.Redirect(w, req, "/admin", http.StatusFound)
|
http.Redirect(w, req, "/dashboard", http.StatusFound) // v0.3.0: 管理入口迁至 /dashboard
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -77,24 +77,50 @@ func Setup() *mux.Router {
|
|||||||
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")
|
||||||
|
|
||||||
// /admin 管理后台(M2);HTML 本身公开,鉴权由前端 JS 拦截
|
// ===== v0.3.0 阶段 3:管理面板入口从 /admin 迁至 /dashboard =====
|
||||||
// (sessionStorage 没 key 就显示登录页;有 key 调 /api/admin/overview 触发 401 跳登录)
|
|
||||||
// API 端点(/api/admin/* /api/voices*)才需要 RequireAdmin。
|
|
||||||
//
|
//
|
||||||
// 拆分后 admin.html / admin-login.html / admin-voices.html / admin-settings.html
|
// 页面本身走 htmlAwareAdmin:浏览器请求(Accept: text/html)返回页面外壳,
|
||||||
// 各自独立,URL 路由切换;共享 admin.css + admin-shell.js 由 adminStatic 提供。
|
// 前端依据 sessionStorage 里的凭证显示登录视图;非 HTML 请求(脚本/抓取)强制鉴权。
|
||||||
r.HandleFunc("/admin", serveAdmin(adminHTML)).Methods("GET")
|
// 原因:SPA 的登录态存在 sessionStorage、不随请求发送,服务端无从判断是否已登录,
|
||||||
r.HandleFunc("/admin/login", serveAdmin(adminLoginHTML)).Methods("GET")
|
// 一律 401 会把"打开 /dashboard 看到登录页"变成"打开 /dashboard 直接报错"。
|
||||||
r.HandleFunc("/admin/voices", serveAdmin(adminVoicesHTML)).Methods("GET")
|
// 页面外壳不含任何数据,数据全部来自鉴权后的 /api/admin/* 接口。
|
||||||
r.HandleFunc("/admin/settings", serveAdmin(adminSettingsHTML)).Methods("GET")
|
// /dashboard 是管理看板(admin.html,与 /dashboard/voices、/dashboard/settings 同属一套 SPA)。
|
||||||
// admin 静态资源
|
// 注意:拆分前的 /dashboard 曾是"服务状态预览页"(health.html),v0.3.0 起
|
||||||
r.HandleFunc("/admin/admin.css", func(w http.ResponseWriter, _ *http.Request) {
|
// 管理入口统一到 /dashboard,该预览页迁到 /dashboard/status 作为"详细状态"视图。
|
||||||
|
r.Handle("/dashboard", htmlAwareAdmin(serveAdmin(adminHTML))).Methods("GET")
|
||||||
|
r.Handle("/dashboard/voices", htmlAwareAdmin(serveAdmin(adminVoicesHTML))).Methods("GET")
|
||||||
|
r.Handle("/dashboard/settings", htmlAwareAdmin(serveAdmin(adminSettingsHTML))).Methods("GET")
|
||||||
|
r.Handle("/dashboard/status", htmlAwareAdmin(serveAdmin(dashboardHTML))).Methods("GET")
|
||||||
|
// 登录页无数据,保持公开(否则无法登录)
|
||||||
|
r.HandleFunc("/dashboard/login", serveAdmin(adminLoginHTML)).Methods("GET")
|
||||||
|
|
||||||
|
// 静态资源:迁到 /dashboard/ 下,同时**保留 /admin/ 旧路径为别名**,
|
||||||
|
// 这样过渡期内新旧页面都能正确加载样式与脚本,不会出现"页面能开但丢样式"。
|
||||||
|
serveCSS := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
_, _ = w.Write(adminCSS)
|
_, _ = w.Write(adminCSS)
|
||||||
}).Methods("GET")
|
}
|
||||||
r.HandleFunc("/admin/admin-shell.js", func(w http.ResponseWriter, _ *http.Request) {
|
serveShellJS := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
_, _ = w.Write(adminShellJS)
|
_, _ = w.Write(adminShellJS)
|
||||||
|
}
|
||||||
|
r.HandleFunc("/dashboard/admin.css", serveCSS).Methods("GET")
|
||||||
|
r.HandleFunc("/dashboard/admin-shell.js", serveShellJS).Methods("GET")
|
||||||
|
r.HandleFunc("/admin/admin.css", serveCSS).Methods("GET")
|
||||||
|
r.HandleFunc("/admin/admin-shell.js", serveShellJS).Methods("GET")
|
||||||
|
|
||||||
|
// 旧入口永久重定向(页面用 301 是正确的;API 路径**不用**重定向,见下方别名说明)
|
||||||
|
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, "/dashboard", http.StatusMovedPermanently)
|
||||||
|
}).Methods("GET")
|
||||||
|
r.HandleFunc("/admin/login", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, "/dashboard/login", http.StatusMovedPermanently)
|
||||||
|
}).Methods("GET")
|
||||||
|
r.HandleFunc("/admin/voices", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, "/dashboard/voices", http.StatusMovedPermanently)
|
||||||
|
}).Methods("GET")
|
||||||
|
r.HandleFunc("/admin/settings", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, "/dashboard/settings", http.StatusMovedPermanently)
|
||||||
}).Methods("GET")
|
}).Methods("GET")
|
||||||
|
|
||||||
// /api/admin/overview (鉴权)
|
// /api/admin/overview (鉴权)
|
||||||
@@ -106,20 +132,39 @@ func Setup() *mux.Router {
|
|||||||
r.Handle("/api/admin/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET")
|
r.Handle("/api/admin/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET")
|
||||||
|
|
||||||
// /api/voices 音色 CRUD (鉴权)
|
// /api/voices 音色 CRUD (鉴权)
|
||||||
|
//
|
||||||
|
// ⚠️ v0.3.0 阶段 3 路径迁移:/api/admin/voices 是**正式路径**,
|
||||||
|
// /api/voices 保留为**过渡期别名**(同一个 handler,不做重定向)。
|
||||||
|
//
|
||||||
|
// 为什么用别名而不是 301/308:
|
||||||
|
// - 301 会把 POST/PUT/PATCH/DELETE 降级成 GET(RFC 7231),且被浏览器永久缓存,
|
||||||
|
// 客户端根本察觉不到路径变化,"过渡期"名存实亡
|
||||||
|
// - 308 能保留方法,但对非浏览器脚本仍是行为变化,且多一次往返
|
||||||
|
// - 别名让新旧路径都能直接用,零往返、零破坏,老脚本完全无感
|
||||||
|
// 旧别名计划在后续版本移除。
|
||||||
|
r.Handle("/api/admin/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
|
||||||
|
r.Handle("/api/admin/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST")
|
||||||
|
r.Handle("/api/admin/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE")
|
||||||
|
r.Handle("/api/admin/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH")
|
||||||
|
|
||||||
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
|
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
|
||||||
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST")
|
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST")
|
||||||
r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE")
|
r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE")
|
||||||
r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH")
|
r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH")
|
||||||
|
|
||||||
// /api/settings 全局设置 (鉴权) — M3
|
// /api/settings 全局设置 (鉴权) — M3
|
||||||
|
// 同样:/api/admin/settings 为正式路径,/api/settings 为过渡期别名。
|
||||||
|
// 注意更长的 /api/admin/settings/admin-key 必须**先注册**(mux 按注册顺序取首个匹配)。
|
||||||
|
r.Handle("/api/admin/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT")
|
||||||
|
r.Handle("/api/admin/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET")
|
||||||
|
r.Handle("/api/admin/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
|
||||||
|
r.Handle("/api/admin/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT")
|
||||||
|
r.Handle("/api/admin/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
|
||||||
|
|
||||||
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET")
|
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET")
|
||||||
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/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")
|
||||||
|
|
||||||
@@ -129,23 +174,18 @@ func Setup() *mux.Router {
|
|||||||
// v0.3.0 端点收口:
|
// v0.3.0 端点收口:
|
||||||
// /healthz 匿名存活探针,只回 "ok"(K8s probe / Docker HEALTHCHECK 用)
|
// /healthz 匿名存活探针,只回 "ok"(K8s probe / Docker HEALTHCHECK 用)
|
||||||
// /health 详细健康数据,**鉴权**(原本匿名,会泄漏版本/内存/配置错误文本)
|
// /health 详细健康数据,**鉴权**(原本匿名,会泄漏版本/内存/配置错误文本)
|
||||||
// /dashboard 管理看板;**鉴权**(原本匿名,聚合了 /health + /metrics 的全部数据)
|
// /dashboard 管理看板(**鉴权**,见上方阶段 3 的注册处)
|
||||||
// /api/admin/health 鉴权版详细健康数据(面板拉取用,风格与 /api/admin/* 一致)
|
// /api/admin/health 鉴权版详细健康数据(面板拉取用,风格与 /api/admin/* 一致)
|
||||||
//
|
|
||||||
// 注意 /health 的鉴权是"内容协商"式的(见 htmlAwareAdmin):
|
|
||||||
// 浏览器直接地址栏访问时返回登录页而不是 401,保持原有 UX;
|
|
||||||
// 非 HTML 请求(脚本/抓取)不带凭证一律 401。数据安全由 API 层保证。
|
|
||||||
r.HandleFunc("/healthz", controller.HealthzHandler).Methods("GET")
|
r.HandleFunc("/healthz", controller.HealthzHandler).Methods("GET")
|
||||||
r.Handle("/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).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 {
|
||||||
http.Redirect(w, req, "/setup", http.StatusFound)
|
http.Redirect(w, req, "/setup", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 正常模式:跳 /admin(M2 之后优先于 /dashboard)
|
// 正常模式:跳 /dashboard(v0.3.0 管理入口)
|
||||||
http.Redirect(w, req, "/admin", http.StatusFound)
|
http.Redirect(w, req, "/dashboard", http.StatusFound)
|
||||||
}).Methods("GET")
|
}).Methods("GET")
|
||||||
|
|
||||||
// 根路径 /metrics 与 /health 的机器访问:
|
// 根路径 /metrics 与 /health 的机器访问:
|
||||||
|
|||||||
+5
-5
@@ -202,7 +202,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>OpenAI 鉴权 Key <span class="req">*</span></label>
|
<label>OpenAI 鉴权 Key <span class="req">*</span></label>
|
||||||
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
|
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
|
||||||
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/admin</code> 的密码。装完可在 <code>/admin → 设置</code> 改。</div>
|
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/dashboard</code> 的密码。装完可在 <code>/dashboard → 设置</code> 改(可另配独立 <code>admin_key</code> 用于后台登录,避免与业务 Key 共用)。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>火山 TTS API Key <span class="req">*</span></label>
|
<label>火山 TTS API Key <span class="req">*</span></label>
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">默认路由与格式</div>
|
<div class="card-title">默认路由与格式</div>
|
||||||
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/admin → 设置</code> 改。</div>
|
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/dashboard → 设置</code> 改。</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="step-badge">第 2 步</span>
|
<span class="step-badge">第 2 步</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
|
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
|
||||||
<div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</div>
|
<div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /dashboard 中增删。</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="step-badge">第 3 步</span>
|
<span class="step-badge">第 3 步</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<!-- Nav -->
|
<!-- Nav -->
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
|
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
|
||||||
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。</div>
|
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/dashboard</code>。</div>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
|
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
|
||||||
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
|
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
|
||||||
@@ -412,7 +412,7 @@
|
|||||||
},
|
},
|
||||||
voices: form.value.voices,
|
voices: form.value.voices,
|
||||||
});
|
});
|
||||||
window.location.href = r.data.redirect || '/admin';
|
window.location.href = r.data.redirect || '/dashboard';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e.response?.data?.error?.message || e.message;
|
const msg = e.response?.data?.error?.message || e.message;
|
||||||
errorMsg.value = '安装失败: ' + msg;
|
errorMsg.value = '安装失败: ' + msg;
|
||||||
|
|||||||
Reference in New Issue
Block a user