Files
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

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, "'", "''")
}