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密码仅登录验证不打印
This commit is contained in:
1 parent
7e39153a69
commit
1ebc82fffd
19 files changed
+1164
-245
No files matched your search
@@ -0,0 +1,250 @@
|
||||
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, "'", "''")
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// newFileDB creates a real SQLite database file at dir/<name>, migrates it, and
|
||||
// closes the handle, returning the file path. The gateway backup/restore
|
||||
// endpoints open their own connections, so the handle is not held open here.
|
||||
func newFileDB(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, path, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open file db: %v", err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("close file db: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --- revoke-keys -------------------------------------------------------------
|
||||
|
||||
func TestAdminRevokeAllKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := auth.NewAPIKey(config.AuthConfig{})
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
|
||||
if _, err := keys.Generate(role); err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Revoked int `json:"revoked"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if res.Revoked != 3 {
|
||||
t.Fatalf("revoked = %d, want 3", res.Revoked)
|
||||
}
|
||||
if len(keys.List()) != 0 {
|
||||
t.Fatalf("key table not empty after RevokeAll: %d", len(keys.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRevokeAllKeysUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Users store
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- rotate-jwt --------------------------------------------------------------
|
||||
|
||||
func TestAdminRotateJWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
|
||||
// The route is owner-only; verify an agent with the current secret passes.
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
newSecret := strings.Repeat("Z", 40)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]any{"newSecret": newSecret}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Rotated bool `json:"rotated"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !res.Rotated || res.Note == "" {
|
||||
t.Fatalf("res = %+v, want rotated with note", res)
|
||||
}
|
||||
// The pre-rotation token no longer verifies.
|
||||
if _, err := jwt.Verify(tok); err == nil {
|
||||
t.Fatal("pre-rotation token still verifies after RotateSecret")
|
||||
}
|
||||
// The new secret is never echoed back in the body, nor is a short one accepted.
|
||||
if strings.Contains(string(body), newSecret) {
|
||||
t.Fatalf("new secret leaked in response body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRotateJWTShortSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]any{"newSecret": "short"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
// The previous secret stays intact after a rejected rotation.
|
||||
if _, err := jwt.Verify(tok); err != nil {
|
||||
t.Fatalf("token invalid after rejected rotation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRotateJWTMissingSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]any{"newSecret": " "}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- backup ------------------------------------------------------------------
|
||||
|
||||
func TestAdminBackupProducesFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(res.Path, dir) {
|
||||
t.Fatalf("backup path %q is not under data dir %q", res.Path, dir)
|
||||
}
|
||||
if res.Size <= 0 {
|
||||
t.Fatalf("backup size = %d, want > 0", res.Size)
|
||||
}
|
||||
if info, err := os.Stat(res.Path); err != nil || info.Size() != res.Size {
|
||||
t.Fatalf("backup file stat = (%v, %d), want no error and size %d", err, info.Size(), res.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminBackupUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- restore -----------------------------------------------------------------
|
||||
|
||||
func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
backupSrc := newFileDB(t, dir, "backup.db")
|
||||
|
||||
// Make the backup source clearly different from the live db by appending a
|
||||
// marker. Restore only copies bytes and never opens the source, so the marker
|
||||
// survives into the replaced live file and proves the swap happened.
|
||||
srcFile, err := os.OpenFile(backupSrc, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("open backup source: %v", err)
|
||||
}
|
||||
if _, err := srcFile.WriteString("RESTORE-MARKER"); err != nil {
|
||||
t.Fatalf("append marker: %v", err)
|
||||
}
|
||||
if err := srcFile.Close(); err != nil {
|
||||
t.Fatalf("close backup source: %v", err)
|
||||
}
|
||||
liveBefore, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read live db before: %v", err)
|
||||
}
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// No pre-existing backups yet.
|
||||
before, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": backupSrc}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Restored bool `json:"restored"`
|
||||
Backup string `json:"backup"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !res.Restored || res.Note == "" {
|
||||
t.Fatalf("res = %+v, want restored with note", res)
|
||||
}
|
||||
// The current DB was snapshotted before the replace (a new backup appeared).
|
||||
after, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("backup count before=%d after=%d, want one more pre-restore snapshot", len(before), len(after))
|
||||
}
|
||||
// The live DB was replaced with the backup source's bytes, so it now carries
|
||||
// the marker and differs from its pre-restore content.
|
||||
liveAfter, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read live db after: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(liveAfter), "RESTORE-MARKER") {
|
||||
t.Fatalf("live db not replaced (marker missing); before len=%d after len=%d", len(liveBefore), len(liveAfter))
|
||||
}
|
||||
if string(liveAfter) == string(liveBefore) {
|
||||
t.Fatal("live db unchanged after restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRestoreRejectsTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir() // data dir
|
||||
outside := t.TempDir() // a sibling directory, outside the data dir
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
outsideBackup := newFileDB(t, outside, "outside.db")
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": outsideBackup}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestAdminRestoreRejectsMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": filepath.Join(dir, "does-not-exist.db")}, tok)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestAdminRestoreUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": "x"}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestAdminRestoreRequiresPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": ""}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,12 @@ type Deps struct {
|
||||
// data; the composition root injects NewSQLiteAuditStore over a.db for real
|
||||
// persistence. Optional.
|
||||
AuditStore AuditStore
|
||||
// DatabasePath is the resolved path of the SQLite database file the
|
||||
// composition root actually opened (the value derived by resolveSQLitePath,
|
||||
// which may differ from cfg.DB.SQLitePath when the data dir default applies).
|
||||
// It backs the admin backup/restore endpoints. Optional; when empty those
|
||||
// endpoints answer honestly with a clear not-configured error.
|
||||
DatabasePath string
|
||||
// Streaming marks the injected Loop as streaming-capable (its OnStream is
|
||||
// wired to publish ws.stream_chunk). When set the hub skips its own single
|
||||
// final stream_chunk emission, since deltas already stream through OnStream.
|
||||
@@ -286,6 +292,7 @@ type Server struct {
|
||||
voice VoiceService
|
||||
streaming bool
|
||||
frontend fs.FS
|
||||
dbPath string
|
||||
// llm is the hot-swappable provider stack (models.json). When nil the
|
||||
// admin /llm endpoints fall back to the env config. It is optional so tests
|
||||
// that do not wire it keep working.
|
||||
@@ -329,6 +336,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
llm: deps.LLM,
|
||||
channels: deps.Channels,
|
||||
webhooks: deps.Webhooks,
|
||||
dbPath: deps.DatabasePath,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
s.hub = newHub(s)
|
||||
@@ -415,6 +423,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
|
||||
mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
mux.Handle("POST /api/v1/admin/system/revoke-keys", ownerAuth(http.HandlerFunc(s.adminRevokeAllKeys)))
|
||||
mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT)))
|
||||
mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup)))
|
||||
mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore)))
|
||||
|
||||
// External webhook callbacks (e.g. the webhook channel) are mounted without
|
||||
// gateway auth; each handler enforces its own signature/authorization.
|
||||
|
||||
Reference in new issue
Block a user