收口三个匿名可读端点: - 根路径 /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 全绿。
This commit is contained in:
+46
-9
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user