主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
268 lines
9.7 KiB
Go
268 lines
9.7 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"
|
|
)
|
|
|
|
// 系统管理写操作(仅 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 数据库快照到
|
|
// <dataDir>/backup-<ts>.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, "'", "''")
|
|
}
|