package admin import ( "errors" "log/slog" "net" "net/http" "sync" "time" "git.asio.asia/nixevol/NixMsg/internal/app/group" "git.asio.asia/nixevol/NixMsg/internal/app/identity" "git.asio.asia/nixevol/NixMsg/internal/auth" "git.asio.asia/nixevol/NixMsg/internal/config" "git.asio.asia/nixevol/NixMsg/internal/httpx" "git.asio.asia/nixevol/NixMsg/internal/store" ) const ( cookieName = "nixmsg_admin" csrfHeader = "X-Nixmsg-Request" csrfValue = "1" adminUsername = "admin" settingAdminHash = "admin_password_hash" defaultSessionTTL = 12 * time.Hour minPasswordLen = 12 lastUsedMinGap = time.Minute maxLoginBodyBytes = 8 << 10 maxJSONBodyBytes = 1 << 20 maxImportBodyBytes = 8 << 20 defaultReadFor = 15 * time.Second loginReadFor = 10 * time.Second importReadFor = 2 * time.Minute ) // Deps 是管理 Handler 的依赖。 type Deps struct { DB *store.DB Hash auth.HashPool Tokens auth.APITokens Locks auth.LoginLocks Logger *slog.Logger // AuditLogger 独立审计日志,始终 Info,不受 log.level 影响;nil 时使用独立 JSON Info 处理器。 AuditLogger *slog.Logger // TrustedProxies 受信任代理网段。 TrustedProxies []*net.IPNet // SessionTTL 会话有效期;零值用 12 小时。 SessionTTL time.Duration // SecureCookies 为 true 时 Cookie 始终带 Secure;否则按请求是否 HTTPS 决定。 SecureCookies bool // KickEndpoint 踢下线钩子(只断开连接);nil 时踢线为 no-op。 KickEndpoint EndpointKickFunc // PasswordResetKick 重置登录密码后踢线(应发 fatal);nil 时回退 KickEndpoint。 PasswordResetKick EndpointKickFunc // DisableKick 停用后踢线(应发 fatal(disabled));nil 且已注入 Identity 时不再 Kick。 DisableKick EndpointKickFunc // DeleteKick 删除后踢线(应发 fatal(deleted));nil 且已注入 Identity 时不再 Kick。 DeleteKick EndpointKickFunc // Identity 端停用/启用/删除级联(I5);nil 时回退为仅改 enabled/删行。 Identity identity.Service // Groups 群服务;nil 时群写操作返回 busy。列表/详情可只读库。 Groups group.Service // Config 只读运行参数来源;零值用 config.Default()。 Config config.Config // Version 概览里的版本号;空则 "dev"。 Version string } // Handler 是可挂载的管理接口(路由前缀 /api/admin/)。 type Handler struct { db *store.DB hash auth.HashPool tokens auth.APITokens locks auth.LoginLocks log *slog.Logger auditLog *slog.Logger trusted []*net.IPNet ttl time.Duration forceSec bool kick EndpointKickFunc resetKick EndpointKickFunc disableKick EndpointKickFunc deleteKick EndpointKickFunc identity identity.Service groups group.Service cfg config.Config version string startedAt time.Time mux *http.ServeMux lastUsedMu sync.Mutex lastUsed map[string]time.Time // api token id -> last DB write } // New 构造可挂载的管理 Handler。返回值实现 http.Handler。 func New(d Deps) *Handler { if d.Logger == nil { d.Logger = slog.Default() } if d.AuditLogger == nil { d.AuditLogger = defaultAuditLogger() } if d.Locks == nil { d.Locks = NewMemoryLoginLocks() } ttl := d.SessionTTL if ttl <= 0 { ttl = defaultSessionTTL } cfg := d.Config if cfg.Listen == "" { cfg = config.Default() } ver := d.Version if ver == "" { ver = "dev" } h := &Handler{ db: d.DB, hash: d.Hash, tokens: d.Tokens, locks: d.Locks, log: d.Logger, auditLog: d.AuditLogger, trusted: d.TrustedProxies, ttl: ttl, forceSec: d.SecureCookies, kick: d.KickEndpoint, resetKick: d.PasswordResetKick, disableKick: d.DisableKick, deleteKick: d.DeleteKick, identity: d.Identity, groups: d.Groups, cfg: cfg, version: ver, startedAt: time.Now(), mux: http.NewServeMux(), lastUsed: make(map[string]time.Time), } h.routes() return h } // ServeHTTP 实现 http.Handler。按路由限制请求体大小与读截止时间。 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { limit, readFor := requestBodyBudget(r) if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(readFor)); err != nil && !errors.Is(err, http.ErrNotSupported) { // 测试用 ResponseRecorder 或不支持截止时间的封装:忽略。 } if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, limit) } h.mux.ServeHTTP(w, r) } func requestBodyBudget(r *http.Request) (int64, time.Duration) { if r.Method == http.MethodPost && r.URL.Path == "/api/admin/endpoints/import" { return maxImportBodyBytes, importReadFor } if r.Method == http.MethodPost && r.URL.Path == "/api/admin/login" { return maxLoginBodyBytes, loginReadFor } return maxJSONBodyBytes, defaultReadFor } func writeDecodeError(w http.ResponseWriter, err error) { if httpx.IsBodyTooLarge(err) { httpx.WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", "请求体过大") return } httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效") } func (h *Handler) routes() { // 公开 h.mux.HandleFunc("POST /api/admin/login", h.handleLogin) // 需鉴权 h.mux.Handle("POST /api/admin/logout", h.auth(h.handleLogout)) h.mux.Handle("GET /api/admin/me", h.auth(h.handleMe)) h.mux.Handle("POST /api/admin/password", h.auth(h.handlePassword)) // 令牌管理:仅 Cookie h.mux.Handle("GET /api/admin/tokens", h.auth(h.handleTokenList)) h.mux.Handle("POST /api/admin/tokens", h.auth(h.handleTokenCreate)) h.mux.Handle("PATCH /api/admin/tokens/{id}", h.auth(h.handleTokenPatch)) h.mux.Handle("DELETE /api/admin/tokens/{id}", h.auth(h.handleTokenDelete)) // A2 端管理 h.mux.Handle("GET /api/admin/endpoints", h.auth(h.handleEndpointList)) h.mux.Handle("POST /api/admin/endpoints", h.auth(h.handleEndpointCreate)) h.mux.Handle("POST /api/admin/endpoints/import", h.auth(h.handleEndpointImport)) h.mux.Handle("POST /api/admin/endpoints/batch", h.auth(h.handleEndpointBatch)) h.mux.Handle("GET /api/admin/endpoints/{id}", h.auth(h.handleEndpointGet)) h.mux.Handle("PATCH /api/admin/endpoints/{id}", h.auth(h.handleEndpointPatch)) h.mux.Handle("DELETE /api/admin/endpoints/{id}", h.auth(h.handleEndpointDelete)) h.mux.Handle("POST /api/admin/endpoints/{id}/kick", h.auth(h.handleEndpointKick)) h.mux.Handle("POST /api/admin/endpoints/{id}/reset-login-password", h.auth(h.handleEndpointResetLoginPassword)) h.mux.Handle("PUT /api/admin/endpoints/{id}/talk-password", h.auth(h.handleEndpointTalkPassword)) h.mux.Handle("POST /api/admin/endpoints/{id}/unlock", h.auth(h.handleEndpointUnlock)) // A3 h.mux.Handle("GET /api/admin/overview", h.auth(h.handleOverview)) h.mux.Handle("GET /api/admin/registration", h.auth(h.handleRegistrationGet)) h.mux.Handle("PUT /api/admin/registration", h.auth(h.handleRegistrationPut)) h.mux.Handle("GET /api/admin/groups", h.auth(h.handleGroupList)) h.mux.Handle("POST /api/admin/groups", h.auth(h.handleGroupCreate)) h.mux.Handle("GET /api/admin/groups/{id}", h.auth(h.handleGroupGet)) h.mux.Handle("PATCH /api/admin/groups/{id}", h.auth(h.handleGroupRename)) h.mux.Handle("DELETE /api/admin/groups/{id}", h.auth(h.handleGroupDissolve)) h.mux.Handle("POST /api/admin/groups/{id}/members", h.auth(h.handleGroupAddMembers)) h.mux.Handle("DELETE /api/admin/groups/{id}/members/{endpointId}", h.auth(h.handleGroupRemoveMember)) h.mux.Handle("POST /api/admin/groups/{id}/transfer", h.auth(h.handleGroupTransfer)) h.mux.Handle("GET /api/admin/messages", h.auth(h.handleMessageList)) h.mux.Handle("GET /api/admin/messages/{seq}", h.auth(h.handleMessageGet)) h.mux.Handle("GET /api/admin/settings", h.auth(h.handleSettingsGet)) }