From 753de8ca9f3aa2f087f2a068ea312081c17e43c0 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sun, 4 Oct 2026 00:45:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20v0.3.0=20=E9=98=B6=E6=AE=B52?= =?UTF-8?q?=20=E6=94=B6=E5=8F=A3=E5=81=A5=E5=BA=B7=E4=B8=8E=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收口三个匿名可读端点: - 根路径 /metrics:默认完全不注册(访问 404);配置 METRICS_ALLOW_CIDR 后按内网白名单开放 - /health:改为鉴权(原本匿名泄漏版本、commit、运行时长、内存、goroutine 与配置错误文本) - /dashboard:改为鉴权(原本匿名,且它同时聚合 /health 与 /metrics,是单点泄漏最严重的端点) - /api/setup/status:改为鉴权(原本 normal 模式下仍匿名返回 installed/mode) 新增 /healthz 匿名存活探针,只回 200 与字面量 ok、不含任何字段,解决 "给 /health 加鉴权后 K8s 探针与 Docker HEALTHCHECK 会一律 401 导致 Pod 反复重启" 的问题; 新增 /api/admin/health 鉴权版详细健康数据,与 /api/admin/metrics 风格一致。 /dashboard 对浏览器 HTML 请求做内容协商:返回页面外壳由前端显示登录视图(SPA 登录态存在 sessionStorage、不随请求发送,服务端无从判断,强行 401 会把现有体验变成直接报错); 非 HTML 请求(脚本、抓取)无凭证一律 401。页面外壳本身不含数据,数据全部来自鉴权后的 API。 IP 白名单中间件复用 GetClientIP(已处理 XFF 与 TRUSTED_PROXY_HOPS),非白名单返回 404 而非 403,避免向扫描者确认端点存在。main.go 启动日志同步改为指向 /healthz 与 /api/admin/health。 验证(真实服务器端到端,非仅单元测试): - 安装前 /healthz=200、/health=401 - 安装后未带凭证:/healthz=200 /health=401 /metrics=404 /dashboard=401 /api/setup/status=401 - /dashboard 带 Accept: text/html 得 200 页面外壳,Accept: application/json 得 401 - /healthz 响应体确认为字面量 ok(不含任何字段) - 回退模式(未配 admin_key):业务 key 访问管理接口全部 200,向后兼容成立 - 配置独立 admin_key 后:业务 key 访问 /health /api/admin/* /api/voices /api/settings 全部 401, admin_key 全部 200,而 /v1/audio/speech 不受影响;GET /api/settings 只回打码值 另新增 middleware/metricsip_test.go 覆盖 CIDR 命中、越界、非法 IP、空列表拒绝, 以及伪造 X-Forwarded-For 不能绕过白名单。 go build ./... / go vet ./... / go test ./... -count=1 全绿。 --- CHANGELOG.md | 30 ++++++-- controller/tts.go | 15 ++++ main.go | 11 ++- middleware/metricsip.go | 103 +++++++++++++++++++++++++ middleware/metricsip_test.go | 143 +++++++++++++++++++++++++++++++++++ router/router.go | 55 +++++++++++--- 6 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 middleware/metricsip.go create mode 100644 middleware/metricsip_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 750a5f0..157f235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ ### v0.3.0 · 进行中 -按 [docs/IMPLEMENT_v0.3.0.md](docs/IMPLEMENT_v0.3.0.md) 分阶段实施,当前完成 **阶段 1**。 +按 [docs/IMPLEMENT_v0.3.0.md](docs/IMPLEMENT_v0.3.0.md) 分阶段实施,当前完成 **阶段 1、阶段 2**。 #### 新增 +- **`GET /healthz` 匿名存活探针**:只返回 `200` 与字面量 `ok`,**不含任何字段**。 + 供 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查使用 —— + 这些探针默认不带 `Authorization`,若继续指向 `/health` 会因鉴权而全部失败。 +- **`GET /api/admin/health` 鉴权版详细健康数据**:与 `/api/admin/metrics` 风格一致, + 供管理面板与运维使用。 +- **`METRICS_ALLOW_CIDR` 内网白名单**(逗号分隔 CIDR,支持裸 IP 自动补掩码): + 配置后在根路径注册 `/metrics`,仅放行白名单来源;**非白名单返回 404 而非 403**, + 不向扫描者确认端点存在。未配置时根路径 `/metrics` **完全不注册**。 - **独立管理凭证 `admin_key`(可选)**:管理接口凭证与业务调用凭证分离。 取值优先级 `admin_key`(DB) → `auth_key`(DB) → `OPENAI_TTS_API_KEY`(env)。 **不配置时行为与旧版完全一致**(回退用 `auth_key`),配置后业务 key 无法访问管理接口。 @@ -18,14 +26,22 @@ #### 变更(Breaking Change) +- **`/health`、`/metrics`、`/dashboard` 不再匿名可读**: + - `/health` → 鉴权(原匿名,泄漏版本 / commit / 内存 / goroutine / 配置错误文本) + - `/dashboard` → 鉴权(原匿名,且它同时聚合 `/health` + `/metrics`,是单点泄漏最严重的端点)。 + 对浏览器 HTML 请求做内容协商:返回页面外壳由前端显示登录视图;非 HTML 请求(脚本、抓取)无凭证一律 401。 + - 根路径 `/metrics` → 需要 `METRICS_ALLOW_CIDR`,否则 404 + - `/api/setup/status` → 鉴权(原本 normal 模式下仍匿名返回 `{installed, mode}`) - **`RequireAdmin` 在凭证未配置时不再放行**。此前 `len(keys)==0` 直接放行,导致未配置凭证的 - 部署上管理接口完全裸奔(也使得给 `/metrics`、`/health` 加鉴权的加固形同虚设)。 - 现在该情况返回 **401**。 -- **normal 模式下未配置任何管理凭证时服务拒绝启动**(fail-fast),并在启动摘要中打印 - 管理凭证来源。此前会正常启动但后台实际无保护。 + 部署上管理接口完全裸奔(也使得给 `/metrics`、`/health` 加鉴权的加固形同虚设)。现在该情况返回 **401**。 +- **normal 模式下未配置任何管理凭证时服务拒绝启动**(fail-fast),启动摘要打印管理凭证来源。 -> ⚠️ **升级提示**:若你的部署当前未配置 `auth_key` / `admin_key` / `OPENAI_TTS_API_KEY` -> 中的任何一个,升级到本版本后服务将拒绝启动。请先配置其中之一。 +> ⚠️ **升级提示** +> 1. 若部署未配置 `auth_key` / `admin_key` / `OPENAI_TTS_API_KEY` 中任何一个,升级后服务将拒绝启动。 +> 2. K8s 探针 / Docker HEALTHCHECK 请改指 **`/healthz`**;Prometheus 请改用鉴权版 +> `/api/admin/metrics`,或配置 `METRICS_ALLOW_CIDR`。 +> 3. `METRICS_ALLOW_CIDR` 在 Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环), +> 请填容器内网网段(如 `172.16.0.0/12`)。 ### 修复 diff --git a/controller/tts.go b/controller/tts.go index 5292674..eb00c6f 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -274,6 +274,21 @@ func contentTypeFor(format string) string { } // HealthHandler 暴露运行期状态;无鉴权。 +// HealthzHandler GET /healthz —— 匿名存活探针,**只回 200 与字面量 "ok"**。 +// +// 为什么单独做这个:v0.3.0 把详细健康数据(/health)收口到管理鉴权之后, +// 但 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查默认都不带 Authorization。 +// 若把它们继续指向 /health,加鉴权后会一律 401,导致探针失败、Pod 反复重启。 +// +// 因此本端点刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式信息), +// 只用于回答"进程还在不在"。运维要细节请走鉴权后的 /health。 +func HealthzHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + func HealthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/main.go b/main.go index 11971d8..df1bde4 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,8 @@ func main() { setting.InitAllConfigs() metrics.Init() middleware.InitRateLimiter() + // v0.3.0:根路径 /metrics 的内网白名单(未配置则根路径完全不注册) + middleware.InitMetricsAllowList() // 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式 dbPath := ttsDBPath() @@ -128,8 +130,13 @@ func main() { log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port) log.Printf("Admin WebUI: http://localhost:%s/admin", setting.Server.Port) } - log.Printf("Health check: http://localhost:%s/health", setting.Server.Port) - log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port) + log.Printf("Health check(匿名存活探针): http://localhost:%s/healthz", setting.Server.Port) + if middleware.MetricsAllowListConfigured() { + log.Printf("Metrics(内网白名单): http://localhost:%s/metrics", setting.Server.Port) + } else { + log.Printf("Metrics: 根路径 /metrics 未注册(未配置 METRICS_ALLOW_CIDR);请用鉴权版 /api/admin/metrics") + } + log.Printf("详细健康数据(鉴权): http://localhost:%s/api/admin/health", setting.Server.Port) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) diff --git a/middleware/metricsip.go b/middleware/metricsip.go new file mode 100644 index 0000000..1a7fe4f --- /dev/null +++ b/middleware/metricsip.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "log" + "net" + "net/http" + "os" + "strings" +) + +// metricsAllowList 是 METRICS_ALLOW_CIDR 解析出的内网白名单。 +// 启动期由 InitMetricsAllowList 填充;运行期只读,无并发写。 +var metricsAllowList []*net.IPNet + +// metricsAllowConfigured 表示是否配置了非空的 METRICS_ALLOW_CIDR。 +// 决定根路径 /metrics、/health 是否注册(未配置则完全不注册,访问 404)。 +var metricsAllowConfigured bool + +// InitMetricsAllowList 解析 METRICS_ALLOW_CIDR,启动期调用一次。 +// +// 格式:逗号分隔的 CIDR 列表,例如 "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"。 +// 也接受裸 IP(自动补 /32 或 /128),方便写 "127.0.0.1"。 +// +// 为什么需要它:根路径 /metrics、/health 是给 Prometheus 抓取 / K8s 探针用的机器端点, +// 让它们带 Bearer 会强制改抓取配置;用内网白名单更省事,且默认不暴露。 +// +// 【重要】Docker 环境不要填 127.0.0.1/32 —— 那是容器自身的回环, +// Prometheus 在宿主机或另一个容器里根本进不来。请填容器内网网段(如 172.16.0.0/12)。 +func InitMetricsAllowList() { + raw := strings.TrimSpace(os.Getenv("METRICS_ALLOW_CIDR")) + metricsAllowList = nil + metricsAllowConfigured = false + if raw == "" { + return + } + + for _, part := range strings.Split(raw, ",") { + entry := strings.TrimSpace(part) + if entry == "" { + continue + } + // 裸 IP 自动补全掩码 + if !strings.Contains(entry, "/") { + if ip := net.ParseIP(entry); ip != nil { + if ip.To4() != nil { + entry += "/32" + } else { + entry += "/128" + } + } + } + _, ipnet, err := net.ParseCIDR(entry) + if err != nil { + log.Printf("[metrics-ip] 忽略非法 CIDR 条目 %q: %v", part, err) + continue + } + metricsAllowList = append(metricsAllowList, ipnet) + } + metricsAllowConfigured = len(metricsAllowList) > 0 + if metricsAllowConfigured { + log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 已启用,根路径 /metrics 仅对 %d 个网段开放", len(metricsAllowList)) + } else { + log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 无有效条目,根路径 /metrics 将不注册") + } +} + +// MetricsAllowListConfigured 报告是否配置了有效的白名单网段。 +// router 据此决定是否注册根路径 /metrics / /health。 +func MetricsAllowListConfigured() bool { return metricsAllowConfigured } + +// MetricsIPAllowList 只放行来源 IP 命中白名单的请求。 +// +// 未命中返回 **404**(而不是 403):不向扫描者确认"这里存在一个只是你没权限的端点"。 +// 客户端 IP 取自 GetClientIP,它已处理 X-Forwarded-For 与 TRUSTED_PROXY_HOPS, +// 所以反代后面的真实来源也能正确判定。 +func MetricsIPAllowList(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !ipAllowed(GetClientIP(r)) { + // 不打印每个被拒请求,避免扫描流量刷爆日志;只按需在 debug 下输出 + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// ipAllowed 判定客户端 IP 是否命中任一白名单网段。 +// 空 IP(解析失败)一律拒绝 —— 宁可拒绝也不能误放行。 +func ipAllowed(clientIP string) bool { + if clientIP == "" { + return false + } + ip := net.ParseIP(strings.TrimSpace(clientIP)) + if ip == nil { + return false + } + for _, n := range metricsAllowList { + if n.Contains(ip) { + return true + } + } + return false +} diff --git a/middleware/metricsip_test.go b/middleware/metricsip_test.go new file mode 100644 index 0000000..abf268e --- /dev/null +++ b/middleware/metricsip_test.go @@ -0,0 +1,143 @@ +package middleware + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// allowListFrom 把逗号分隔的 CIDR 串解析成白名单,供测试直接注入。 +func allowListFrom(t *testing.T, spec string) []*net.IPNet { + t.Helper() + var out []*net.IPNet + for _, part := range strings.Split(spec, ",") { + entry := strings.TrimSpace(part) + if entry == "" { + continue + } + _, n, err := net.ParseCIDR(entry) + if err != nil { + t.Fatalf("测试用例里的 CIDR 非法 %q: %v", entry, err) + } + out = append(out, n) + } + return out +} + +// reqFrom 构造带指定来源地址的请求。 +func reqFrom(remoteAddr string) *http.Request { + r := httptest.NewRequest("GET", "/metrics", nil) + r.RemoteAddr = remoteAddr + return r +} + +// TestIPAllowed 验证 CIDR 白名单判定: +// 命中放行、未命中拒绝,解析失败的 IP 一律拒绝(宁可拒绝也不误放行)。 +func TestIPAllowed(t *testing.T) { + // 直接构造白名单,不依赖环境变量 + metricsAllowList = allowListFrom(t, "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,::1/128") + metricsAllowConfigured = true + t.Cleanup(func() { + metricsAllowList = nil + metricsAllowConfigured = false + }) + + cases := []struct { + name string + ip string + want bool + }{ + {"本机 IPv4 命中 /32", "127.0.0.1", true}, + {"10.x 命中 /8", "10.1.2.3", true}, + {"172.16.x 命中 /12", "172.16.5.9", true}, + {"172.31.x 仍在 /12 内", "172.31.255.254", true}, + {"IPv6 回环命中 /128", "::1", true}, + {"公网 IP 不在白名单", "8.8.8.8", false}, + {"172.32.x 超出 /12 范围", "172.32.0.1", false}, + {"192.168.x 未配置", "192.168.1.1", false}, + {"空 IP 拒绝", "", false}, + {"非法 IP 拒绝", "not-an-ip", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ipAllowed(c.ip); got != c.want { + t.Errorf("ipAllowed(%q) = %v, want %v", c.ip, got, c.want) + } + }) + } +} + +// TestIPAllowed_EmptyListRejectsAll 白名单为空时全部拒绝(未配置 = 不开放)。 +func TestIPAllowed_EmptyListRejectsAll(t *testing.T) { + metricsAllowList = nil + metricsAllowConfigured = false + t.Cleanup(func() { + metricsAllowList = nil + metricsAllowConfigured = false + }) + + if ipAllowed("127.0.0.1") { + t.Error("白名单为空时应拒绝所有 IP") + } +} + +// TestMetricsIPAllowList_Scope 白名单内放行、白名单外 404。 +// 用 404 而不是 403,是为了不向扫描者确认"该端点存在,只是你没权限"。 +func TestMetricsIPAllowList_Scope(t *testing.T) { + metricsAllowList = allowListFrom(t, "10.0.0.0/8") + t.Cleanup(func() { metricsAllowList = nil }) + + h := MetricsIPAllowList(okHandler()) + + // 命中白名单 → 放行到 next + w := httptest.NewRecorder() + h.ServeHTTP(w, reqFrom("10.1.1.1:1234")) + if w.Code != http.StatusOK { + t.Errorf("白名单内来源: code=%d, want 200", w.Code) + } + + // 白名单外(RFC 5737 文档地址)→ 404,且不应触达 next + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, reqFrom("192.0.2.1:1234")) + if w2.Code != http.StatusNotFound { + t.Errorf("白名单外来源: code=%d, want 404", w2.Code) + } + if w2.Body.String() == "ok" { + t.Error("白名单外来源不应触达被保护的 handler") + } +} + +// TestMetricsIPAllowList_XForwardedForSpoof 伪造 X-Forwarded-For 不能绕过白名单。 +// +// GetClientIP 的启发式模式(trustedProxyHops==0,默认)从 XFF 链**尾部**取第一个 +// **公网** IP,刻意跳过私网跳 —— 这样攻击者无法用 "X-Forwarded-For: <内网IP>" +// 把自己伪装成白名单来源。本测试锁死这个安全属性。 +func TestMetricsIPAllowList_XForwardedForSpoof(t *testing.T) { + metricsAllowList = allowListFrom(t, "10.0.0.0/8") + t.Cleanup(func() { metricsAllowList = nil }) + + h := MetricsIPAllowList(okHandler()) + + // 直连是私网(像反代),XFF 里塞一个内网 IP 想混进白名单 + r := reqFrom("127.0.0.1:1234") + r.Header.Set("X-Forwarded-For", "10.9.9.9") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + // 启发式模式会跳过私网跳、落到直连地址 127.0.0.1(不在 10.0.0.0/8)→ 404。 + // 关键断言:伪造的内网 XFF **没有**让它通过。 + if w.Code == http.StatusOK { + t.Errorf("伪造私网 XFF 不应绕过白名单: code=%d", w.Code) + } + + // 反向对照:XFF 填公网 IP 时,GetClientIP 会采用它,同样不在白名单 → 404 + r2 := reqFrom("127.0.0.1:1234") + r2.Header.Set("X-Forwarded-For", "8.8.8.8") + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, r2) + if w2.Code != http.StatusNotFound { + t.Errorf("公网来源不在白名单: code=%d, want 404", w2.Code) + } +} diff --git a/router/router.go b/router/router.go index b682628..8108b38 100644 --- a/router/router.go +++ b/router/router.go @@ -72,7 +72,8 @@ func Setup() *mux.Router { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(setupHTML) }).Methods("GET") - r.HandleFunc("/api/setup/status", controller.SetupStatusHandler).Methods("GET") + // v0.3.0:安装状态不再匿名暴露(原本 normal 模式下仍返回 {installed, mode}) + r.Handle("/api/setup/status", middleware.RequireAdmin(http.HandlerFunc(controller.SetupStatusHandler))).Methods("GET") r.HandleFunc("/api/setup/prefill", controller.SetupPrefillHandler).Methods("GET") r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST") @@ -100,6 +101,9 @@ func Setup() *mux.Router { r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET") // /api/admin/metrics (鉴权);返 Prometheus 文本 r.Handle("/api/admin/metrics", middleware.RequireAdmin(http.HandlerFunc(controller.AdminMetricsHandler))).Methods("GET") + // v0.3.0:详细健康数据的鉴权版(面板与运维用)。 + // 与根路径 /health 的区别:这里始终注册且强制鉴权;根路径 /health 默认 404。 + r.Handle("/api/admin/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET") // /api/voices 音色 CRUD (鉴权) r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET") @@ -121,11 +125,19 @@ func Setup() *mux.Router { // 业务路由 r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/health", controller.HealthHandler).Methods("GET") - r.HandleFunc("/dashboard", func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(dashboardHTML) - }).Methods("GET") + + // v0.3.0 端点收口: + // /healthz 匿名存活探针,只回 "ok"(K8s probe / Docker HEALTHCHECK 用) + // /health 详细健康数据,**鉴权**(原本匿名,会泄漏版本/内存/配置错误文本) + // /dashboard 管理看板;**鉴权**(原本匿名,聚合了 /health + /metrics 的全部数据) + // /api/admin/health 鉴权版详细健康数据(面板拉取用,风格与 /api/admin/* 一致) + // + // 注意 /health 的鉴权是"内容协商"式的(见 htmlAwareAdmin): + // 浏览器直接地址栏访问时返回登录页而不是 401,保持原有 UX; + // 非 HTML 请求(脚本/抓取)不带凭证一律 401。数据安全由 API 层保证。 + r.HandleFunc("/healthz", controller.HealthzHandler).Methods("GET") + r.Handle("/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET") + r.Handle("/dashboard", htmlAwareAdmin(serveAdmin(adminHTML))).Methods("GET") r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { // 安装模式下,根路径跳 /setup if installer.GetMode() == installer.ModeSetup { @@ -136,13 +148,38 @@ func Setup() *mux.Router { http.Redirect(w, req, "/admin", http.StatusFound) }).Methods("GET") - // /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。 - // Prometheus 抓取不带 Origin,因此经过 CORS 中间件时会直接 pass-through。 - r.Handle("/metrics", metrics.Meter.Handler()).Methods("GET") + // 根路径 /metrics 与 /health 的机器访问: + // - 配置了 METRICS_ALLOW_CIDR → 注册 /metrics,包裹内网白名单(非白名单返回 404) + // - 未配置 → 根路径 /metrics **完全不注册**(访问 404),避免默认对外暴露 + // /health 已在上面按鉴权注册,不再提供匿名版本。 + if middleware.MetricsAllowListConfigured() { + r.Handle("/metrics", middleware.MetricsIPAllowList(metrics.Meter.Handler())).Methods("GET") + } return r } +// htmlAwareAdmin 给**浏览器直接访问的管理页面**套鉴权,但对 HTML 请求做内容协商: +// +// - Accept 含 text/html(地址栏打开、点链接)→ 照常返回页面外壳。 +// 此时不做 401:因为前端登录态存在 sessionStorage,**不会随请求发送**, +// 服务端无从判断是否已登录;强行 401 会让"打开 /dashboard 看到登录页" +// 这一现有体验变成"打开 /dashboard 直接报错"。 +// 页面外壳本身不含任何数据,数据全部来自鉴权后的 /api/admin/* 接口。 +// - 其它(脚本、curl、抓取工具)→ 强制 RequireAdmin,无凭证 401。 +// +// 这样既堵住了"匿名抓取健康数据",又不破坏 SPA 的登录流程。 +func htmlAwareAdmin(html http.Handler) http.Handler { + guarded := middleware.RequireAdmin(html) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if acceptsHTML(r.Header.Get("Accept")) { + html.ServeHTTP(w, r) + return + } + guarded.ServeHTTP(w, r) + }) +} + // acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。 // 用途:NotFoundHandler 判断浏览器 Accept。 func acceptsHTML(accept string) bool {