package gateway import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) // 系统管理写操作(仅 owner,带审计;design/11 §4 敏感操作代审 + // design/10 §3 module 14 系统设置)。每个 handler 都会审计一个 system.* 动作, // 绝不把 JWT 秘密或数据库路径回显给客户端。 // adminRevokeAllKeys 吊销当前已注册的每一个 API key。它是 // `system.revoke_keys` 管理写;该表被清空,因此此前任何 key // 之后都无法认证。 func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) { keys, ok := s.users.(*auth.APIKey) if !ok { s.auditClaims(r, "system.revoke_keys", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置")) return } n := keys.RevokeAll() s.auditClaims(r, "system.revoke_keys", "ok") writeJSON(w, http.StatusOK, map[string]any{"revoked": n}) } // rotateJWTRequest 是 POST /api/v1/admin/system/rotate-jwt 的请求体。新 // 秘密从请求中接受,使主人能提供新值;它绝不 // 被回显或被记日志。 type rotateJWTRequest struct { NewSecret string `json:"newSecret"` } // adminRotateJWT 替换 JWT 签名秘密,使在旧秘密下签发的所有 // token 失效。它是 `system.rotate_jwt` 管理写。 func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) { var req rotateJWTRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.NewSecret) == "" { s.auditClaims(r, "system.rotate_jwt", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "newSecret is required")) return } jwt, ok := s.auth.(*auth.JWTService) if !ok { s.auditClaims(r, "system.rotate_jwt", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "auth_unavailable", "认证服务未配置")) return } if err := jwt.RotateSecret(req.NewSecret); err != nil { s.auditClaims(r, "system.rotate_jwt", "error") if errors.Is(err, auth.ErrInvalidSecret) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "新密钥长度不足 32 位")) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret")) return } // 凭据轮换也必须让每一个长期授权失效,而不只是旧秘密下 // 签发的访问令牌。refresh token 是不透明的、与秘密无关, // 因此被整体删除 —— 这是本架构中最小且精确的 // 全局失效手段(无需 token_version 列)。 var refreshRevoked int64 if s.refresh != nil { n, rerr := s.refresh.RevokeAll(r.Context()) if rerr != nil { s.log.Error("rotate-jwt revoke refresh tokens", "err", rerr) s.auditClaims(r, "system.rotate_jwt", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_revoke_failed", "JWT 已轮换,但 refresh token 作废失败,请重试或检查数据库")) return } refreshRevoked = n } s.auditClaims(r, "system.rotate_jwt", "ok") writeJSON(w, http.StatusOK, map[string]any{ "rotated": true, "refreshTokensRevoked": refreshRevoked, "note": "现有 token 与 refresh token 已全部失效,需重新登录", }) } // adminBackup 用 `VACUUM INTO` 把 SQLite 数据库快照进数据目录 // (该语句包含已提交的 WAL 内容),返回备份路径 // 与大小。它是 `system.backup` 管理写。 func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(s.dbPath) == "" { s.auditClaims(r, "system.backup", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_unavailable", "数据库路径未配置")) return } path, size, err := s.backupDatabase(r.Context()) if err != nil { s.auditClaims(r, "system.backup", "error") s.log.Error("backup", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_failed", "备份失败")) return } s.auditClaims(r, "system.backup", "ok") writeJSON(w, http.StatusOK, map[string]any{"path": path, "size": size}) } // restoreRequest 是 POST /api/v1/admin/system/restore 的请求体。Path 是 // 要恢复的备份文件,预期位于数据目录内。 type restoreRequest struct { Path string `json:"path"` } // adminRestore 校验备份路径(必须位于数据目录内 —— 这是 // 路径穿越防护),先为当前数据库做快照,然后替换 // 活的 DB 文件。由于运行中的进程仍持有 SQLite 文件句柄, // 替换在重启后生效;响应中如实说明。它是 // `system.restore` 管理写。 func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) { var req restoreRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(s.dbPath) == "" { s.auditClaims(r, "system.restore", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_unavailable", "数据库路径未配置")) return } src := strings.TrimSpace(req.Path) if src == "" { s.auditClaims(r, "system.restore", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "path is required")) return } absSrc, ok := s.restorePathWithinDataDir(src) if !ok { s.auditClaims(r, "system.restore", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "恢复路径必须在数据目录内")) return } if _, err := os.Stat(absSrc); err != nil { s.auditClaims(r, "system.restore", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "备份文件不存在")) return } // 在动它之前先为当前数据库做快照,这样误恢复 // 错文件时仍可挽回。 curPath, _, err := s.backupDatabase(r.Context()) if err != nil { s.auditClaims(r, "system.restore", "error") s.log.Error("restore pre-backup", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "restore_failed", "恢复前备份失败")) return } if err := replaceDatabase(absSrc, s.dbPath); err != nil { s.auditClaims(r, "system.restore", "error") s.log.Error("restore replace", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "restore_failed", "替换数据库失败")) return } s.auditClaims(r, "system.restore", "ok") writeJSON(w, http.StatusOK, map[string]any{ "restored": true, "backup": curPath, "note": "已安排恢复,需重启服务后生效", }) } // restorePathWithinDataDir 把 src 解析为绝对路径并报告它是否 // 位于数据目录(s.dbPath 的父目录)内。它是恢复端点的 // 路径穿越防护:只接受数据目录之下的 // 路径。 func (s *Server) restorePathWithinDataDir(src string) (string, bool) { dataDir, err := filepath.Abs(filepath.Dir(s.dbPath)) if err != nil { return "", false } dataDir = filepath.Clean(dataDir) absSrc, err := filepath.Abs(src) if err != nil { return "", false } absSrc = filepath.Clean(absSrc) rel, err := filepath.Rel(dataDir, absSrc) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", false } return absSrc, true } // backupDatabase 经 `VACUUM INTO` 把活的 SQLite 数据库快照到 // /backup-.db,该语句包含每一个已提交的 WAL // 事务,并返回新文件的路径与大小。对基于文件的数据库再开 // 一个连接是安全的:SQLite 会串行化访问,而 // busy_timeout 能应对源被短暂锁定。 func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) { dir := filepath.Dir(s.dbPath) if err := os.MkdirAll(dir, 0o755); err != nil { return "", 0, fmt.Errorf("create backup dir: %w", err) } dest := filepath.Join(dir, fmt.Sprintf("backup-%s.db", time.Now().UTC().Format("20060102T150405.000000000Z"))) conn, err := db.Open(ctx, s.dbPath, s.log) if err != nil { return "", 0, fmt.Errorf("open db for backup: %w", err) } defer conn.Close() // VACUUM INTO 接受字符串字面量,而不是绑定参数;对生成的 // 文件名做转义(字符本身安全,但防御性地转义)。 query := fmt.Sprintf("VACUUM INTO '%s'", escapeSQLString(dest)) if _, err := conn.ExecContext(ctx, query); err != nil { return "", 0, fmt.Errorf("vacuum into: %w", err) } info, err := os.Stat(dest) if err != nil { return "", 0, fmt.Errorf("stat backup: %w", err) } return dest, info.Size(), nil } // replaceDatabase 用 src 的副本原子替换 dest 处的活 DB 文件, // 先写临时文件,因此失败绝不留下被截断的 dest。在 Linux 上 // 运行中的 SQLite 连接仍持有旧 inode 的句柄,因此新数据 // 只有在进程重启后才可见 —— 恢复端点会如实 // 报告这一点。 func replaceDatabase(src, dest string) error { in, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } defer in.Close() tmp := dest + ".restore.tmp" out, err := os.Create(tmp) if err != nil { return fmt.Errorf("create temp: %w", err) } if _, err := io.Copy(out, in); err != nil { _ = out.Close() _ = os.Remove(tmp) return fmt.Errorf("copy: %w", err) } if err := out.Close(); err != nil { _ = os.Remove(tmp) return fmt.Errorf("close temp: %w", err) } if err := os.Rename(tmp, dest); err != nil { _ = os.Remove(tmp) return fmt.Errorf("rename: %w", err) } return nil } // escapeSQLString 把每个单引号翻倍,使文件名能安全地嵌入 // 供 VACUUM INTO 使用的 SQL 字符串字面量。 func escapeSQLString(s string) string { return strings.ReplaceAll(s, "'", "''") }