Files
Cyrene_v2/internal/services/gateway/admin_system.go
T
Aska 1ebc82fffd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
agent-111 JWT过期重置+系统设置去占位:
- ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连
- api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清
- types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice

agent-113 用户端登录页:
- components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向

agent-112 系统设置前三个(owner+审计):
- auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显)
- gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启)
- wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约

实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
2026-09-07 13:57:36 +08:00

251 lines
9.0 KiB
Go

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
// <dataDir>/backup-<ts>.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, "'", "''")
}