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" ) // System management admin writes (owner-only, audited; design/11 §4 敏感操作代审 + // design/10 §3 module 14 系统设置). Every handler audits a system.* action and never // echoes a JWT secret or a database path back to the client. // adminRevokeAllKeys revokes every currently registered API key. It is the // `system.revoke_keys` admin write; the table is emptied so no prior key can // authenticate afterward. 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 is the POST /api/v1/admin/system/rotate-jwt body. The new // secret is accepted from the request so the owner can supply a fresh value; it // is never echoed back or logged. type rotateJWTRequest struct { NewSecret string `json:"newSecret"` } // adminRotateJWT replaces the JWT signing secret, invalidating every token issued // under the old secret. It is the `system.rotate_jwt` admin write. 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 } s.auditClaims(r, "system.rotate_jwt", "ok") writeJSON(w, http.StatusOK, map[string]any{ "rotated": true, "note": "现有 token 已全部失效,需重新登录", }) } // adminBackup snapshots the SQLite database into the data directory using // `VACUUM INTO` (which includes committed WAL content), returning the backup path // and size. It is the `system.backup` admin write. 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 is the POST /api/v1/admin/system/restore body. Path is the // backup file to restore, expected to live inside the data directory. type restoreRequest struct { Path string `json:"path"` } // adminRestore validates a backup path (must be inside the data directory — a // path-traversal guard), snapshots the current database first, then replaces the // live DB file. Because the running process holds the SQLite file handle open, the // replace takes effect on restart; it is reported as such. It is the // `system.restore` admin write. 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 } // Snapshot the current database before touching it, so an accidental restore // of the wrong file is recoverable. 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 resolves src to an absolute path and reports whether it // lives inside the data directory (the parent of s.dbPath). It is the path- // traversal guard for the restore endpoint: only paths under the data dir are // accepted. 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 snapshots the live SQLite database into // /backup-.db via `VACUUM INTO`, which includes every committed WAL // transaction, and returns the new file's path and size. Opening a second // connection to the file-backed database is safe: SQLite serializes access, and // busy_timeout handles a momentarily locked source. 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 takes a string literal, not a bound parameter; escape the // generated filename (safe characters, but escape defensively). 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 atomically replaces the live DB file at dest with a copy of src, // writing to a temp file first so a failure never leaves dest truncated. On Linux // a running SQLite connection keeps its open handle to the old inode, so the new // data becomes visible only after the process restarts — which the restore // endpoint reports. 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 doubles any single quote so a filename can be safely embedded // in a SQL string literal for VACUUM INTO. func escapeSQLString(s string) string { return strings.ReplaceAll(s, "'", "''") }