package httpapi import ( "crypto/subtle" "net/http" "os" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/version" ) // shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。 const shutdownTokenHeader = "X-Shutdown-Token" // systemRoutes 挂载系统级只读信息与优雅关闭握手。 func (s *server) systemRoutes(mux *http.ServeMux) { // 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到 mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout)) // 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。 mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler)) } func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) { return map[string]any{ "internalSignature": version.InternalSignature, // 构建时间同样是编译期注入的,未注入时为空串,由前端显示「未注入」。 "buildTime": version.BuildTime, }, nil } // shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。 func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) { method, ok := s.shutdownAuth(r) if !ok { // 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。 if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" { return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"} } return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"} } if s.shutdown == nil { return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"} } s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method}) writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) // 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。 go s.shutdown() return nil, errHandled } // shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。 // 环境变量为空时只认 admin 会话。 func (s *server) shutdownAuth(r *http.Request) (string, bool) { if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) { return "session", true } expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN")) if expected == "" { return "", false } got := r.Header.Get(shutdownTokenHeader) if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { return "", false } return "token", true }