From 1ebc82fffdf6f308587b1a0dfc29abf6782f29b9 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 13:57:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=E8=BF=87=E6=9C=9F=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=20+=20=E7=94=A8=E6=88=B7=E7=AB=AF=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=20+=20=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=89=8D=E4=B8=89=E4=B8=AA=E7=9C=9F=E5=AE=9E=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()→;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密码仅登录验证不打印 --- Docs/api-client.md | 13 +- cmd/cyrene/wire.go | 57 ++-- frontend/web/src/App.tsx | 9 + frontend/web/src/admin/AdminApp.tsx | 2 +- frontend/web/src/admin/modules/System.tsx | 70 ++-- frontend/web/src/components/Login.tsx | 136 ++++++++ frontend/web/src/components/Nav.tsx | 3 +- frontend/web/src/components/TopBar.tsx | 3 +- frontend/web/src/lib/api.ts | 59 +++- frontend/web/src/lib/types.ts | 3 +- frontend/web/src/lib/ws.ts | 215 ++++-------- frontend/web/src/lib/wsParse.ts | 160 +++++++++ internal/auth/apikey.go | 11 + internal/auth/apikey_test.go | 25 ++ internal/auth/jwt.go | 28 +- internal/auth/jwt_test.go | 43 +++ internal/services/gateway/admin_system.go | 250 ++++++++++++++ .../services/gateway/admin_system_test.go | 310 ++++++++++++++++++ internal/services/gateway/server.go | 12 + 19 files changed, 1164 insertions(+), 245 deletions(-) create mode 100644 frontend/web/src/components/Login.tsx create mode 100644 frontend/web/src/lib/wsParse.ts create mode 100644 internal/services/gateway/admin_system.go create mode 100644 internal/services/gateway/admin_system_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index 90fb9cd..c923140 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -53,8 +53,12 @@ | 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) | | 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` | | 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` | +| 20 | `/api/v1/admin/system/revoke-keys` | POST | ✅(写) | 吊销**全部** API key(owner-only,审计);返回 `{revoked:n}`——key 表清空,先前签发的所有 key 立即失效 | +| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note}`——**现有 token 立即失效需重新登录**;新密钥**不回显、不落日志** | +| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `/backup-.db` → `{path,size}` | +| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) | -> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**插件安装(需源码清单)**、**主人档案保存**、**系统设置**、**知识库删除**——后端如实返回 `501 not_implemented`,不伪造成功。 +> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);仍为后置 M10:**新增用户**、**IoT 场景与规则**、**插件安装(需源码清单)**、**主人档案保存**、**编辑环境变量/重启服务/检查更新**、**知识库删除**——后端如实返回 `501 not_implemented`,不伪造成功。 **管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`): - `POST /admin/personas` body `{id,name,role,systemPrompt?,characterRef?,modes?/mode?,memoryNamespace?}` → `201 {data:{id,name,character,isDefault,mode,memoryNamespace}}`(注册进 persona registry + 持久化到 `configs/personas/.yaml`);id 已存在回 `409 duplicate`。 @@ -80,6 +84,12 @@ - `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。 - `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。 +**系统设置契约**(`POST /api/v1/admin/system/*`,均 **owner-only**,审计;成功 `{data}` / 失败 `{error:{code,message}}`): +- `POST /system/revoke-keys`(无 body)→ `200 {data:{revoked:n}}`。**吊销全部 API key**:清空 key 表(先前签发的所有 key 立即失效),`revoked` 为吊销数量;未注入 API key 服务回 `500 users_unavailable`。 +- `POST /system/rotate-jwt` body `{newSecret}` → `200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}`。**轮换 JWT 密钥**:`newSecret` ≥32 位,替换当前签名密钥,**先前 token 全部失效**;`newSecret` 缺失/空白回 `400`;长度 <32 回 `400`;认证服务未配置回 `500 auth_unavailable`。**新密钥永不回显、不落日志**。 +- `POST /system/backup`(无 body)→ `200 {data:{path,size}}`。**备份数据**:经 SQLite `VACUUM INTO`(含已提交 WAL 内容)快照到 `/backup-.db`,`path` 为备份文件绝对路径、`size` 为字节数;数据库路径未配置回 `500 backup_unavailable`、快照失败回 `500 backup_failed`。 +- `POST /system/restore` body `{path}` → `200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}`。**恢复数据**:`path` 须为 **dataDir 内**的备份文件(**路径穿越防护**——`filepath.Rel` 校验,`..` 逃逸回 `400`);文件不存在回 `404`;**先自动备份当前数据库**(`backup` 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,**替换后需重启服务生效**;数据库路径未配置回 `500 backup_unavailable`、替换失败回 `500 restore_failed`。 + **知识库 RAG 契约**: - `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。 - `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。 @@ -161,6 +171,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.17`(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 `POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}`(均 **owner-only** + 审计)。`revoke-keys` 走 `auth.APIKey.RevokeAll()`(清空 key 表,返回 `{revoked:n}`);`rotate-jwt` 走 `auth.JWTService.RotateSecret`(≥32 位,**新密钥不回显/不落日志**,现有 token 立即失效);`backup` 用 SQLite `VACUUM INTO` 快照到 `/backup-.db`(含已提交 WAL 内容)返回 `{path,size}`;`restore` 先校验路径在 dataDir 内(**`filepath.Rel` 防路径穿越**,逃逸回 `400`)→ **先自动备份当前 DB** → 替换 DB 文件 → 返回 `{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 `Deps.DatabasePath`(composition root 传入实际打开的 SQLite 路径)。`编辑环境变量 / 重启服务 / 检查更新` 仍为后置,未实现、不伪造。 - `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → `UpsertChannel` → 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。 - `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。 - `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。 diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 72537d9..e0c1fff 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -76,6 +76,7 @@ type app struct { cfg *config.Config log *slog.Logger db *sql.DB + dbPath string bus bus.Bus modelAdapter model.Adapter llm *model.RuntimeLLM @@ -138,11 +139,12 @@ func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, er eventBus := bus.New() a := &app{ - cfg: cfg, - log: log, - db: sqlDB, - bus: eventBus, - caps: registry.New(), + cfg: cfg, + log: log, + db: sqlDB, + dbPath: databasePath, + bus: eventBus, + caps: registry.New(), } // Release already-opened resources if any later wiring step fails, without @@ -598,28 +600,29 @@ func (a *app) wireFrontend() error { // and the gateway share the same persistent store. History was a no-op dependency // before the IM path, so nothing downstream depends on its creation order change. gw, err := gateway.New(a.cfg, gateway.Deps{ - Log: logger.WithSource(a.log, "gateway"), - Auth: jwt, - Bus: a.bus, - Registry: a.caps, - Loop: a.loop, - Session: a.sessions, - Personas: a.personas, - Memories: a.memStore, - History: a.history, - IoT: a.iot, - Scheduler: a.scheduler, - Tasks: a.tasks, - Automations: a.automation, - Users: apiKeys, - Backthink: a.backthink, - RAG: ragStore, - LLM: a.llm, - Channels: a.channels, - AuditStore: auditStore, - Streaming: true, - Frontend: resolveFrontendFS(a.log), - Webhooks: a.channelBridge.WebhookHandlers(), + Log: logger.WithSource(a.log, "gateway"), + Auth: jwt, + Bus: a.bus, + Registry: a.caps, + Loop: a.loop, + Session: a.sessions, + Personas: a.personas, + Memories: a.memStore, + History: a.history, + IoT: a.iot, + Scheduler: a.scheduler, + Tasks: a.tasks, + Automations: a.automation, + Users: apiKeys, + Backthink: a.backthink, + RAG: ragStore, + LLM: a.llm, + Channels: a.channels, + AuditStore: auditStore, + Streaming: true, + Frontend: resolveFrontendFS(a.log), + Webhooks: a.channelBridge.WebhookHandlers(), + DatabasePath: a.dbPath, }) if err != nil { return fmt.Errorf("gateway: %w", err) diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 4953d57..f2d07c2 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -3,6 +3,7 @@ import { TopBar } from "./components/TopBar"; import { Sidebar, MobileNav } from "./components/Nav"; import { ModeRouter } from "./modes/ModeRouter"; import { AdminApp } from "./admin/AdminApp"; +import { Login } from "./components/Login"; import { parseAdminHash } from "./admin/router"; import { connectWs, disconnectWs } from "./lib/ws"; import { initTheme } from "./lib/theme"; @@ -121,6 +122,14 @@ export default function App(): React.ReactNode { return ; } + // No credential: never render the chat shell (it would silently show an + // "未登录" shell with no way in). Show the user-side login card instead; its + // onSuccess reloads the page so connectWs + session/persona effects re-run + // with the fresh token. Admin shell keeps its own LoginGate (#/admin). + if (!getAuthToken()) { + return window.location.reload()} />; + } + return (
setMenuOpen(true)} /> diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index 0128f92..4442512 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -170,7 +170,7 @@ function LoginGate(): React.ReactNode {
需要登录

- 管理面板需持有 JWT。请先到用户端 设置 → 连接 登录获取令牌后,再返回本页。 + 管理面板需持有 JWT。请先在用户端 登录页 完成登录后,再返回本页。

diff --git a/frontend/web/src/admin/modules/System.tsx b/frontend/web/src/admin/modules/System.tsx index 08b6d5e..0dea8ee 100644 --- a/frontend/web/src/admin/modules/System.tsx +++ b/frontend/web/src/admin/modules/System.tsx @@ -1,86 +1,54 @@ -import { useState } from "react"; import { getStoredRole } from "../../lib/api"; -import { useAuditStore } from "../audit"; import { KeyValueRow, Notice, PageHeader } from "../components"; -import { Button, Surface, Tag } from "../../components/primitives"; +import { Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; +/** + * 系统设置 (design/10)。后端 M10+ 才提供真实的密钥轮换/备份/环境变量写入等 + * 系统级操作;在此之前只呈现**诚实的只读配置事实**,并把尚未实现的操作合并成 + * 一条「未实现」说明——不做满屏点击后 `alert` 的占位按钮。 + */ export function SystemModule(): React.ReactNode { const isOwner = getStoredRole() === "owner"; - const log = useAuditStore((s) => s.log); - const [logLevel, setLogLevel] = useState("info"); - - const sensitive = (what: string, note: string) => { - if (!isOwner) { - alert(`仅 owner 可执行:${what}。当前角色 ${getStoredRole()}。`); - return; - } - log(`system.${what}`, "ok"); - alert(`${note}(占位):后端 M10+ 提供真实执行。`); - }; return (
- +
- + } title="安全 & 密钥" ownerOnly /> - -
- - -
+
- + } title="存储" /> -
- - -
- + } title="环境变量" /> -
- -
- + } title="运行时" /> -
- 日志级别 - - {["debug", "info", "warn", "error"].map((lvl) => ( - - ))} - -
-
- - -
+ +
+ + 以下系统操作后端暂未实现(M10+ 落地),当前不可用:轮换 JWT 密钥 · 吊销全部 API + key · 备份/恢复 · 编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位。 + + {!isOwner ? 安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。 : null}
); diff --git a/frontend/web/src/components/Login.tsx b/frontend/web/src/components/Login.tsx new file mode 100644 index 0000000..0796739 --- /dev/null +++ b/frontend/web/src/components/Login.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import type { FormEvent, ReactNode } from "react"; +import { ApiError, getStoredRole, login, setAuthToken } from "../lib/api"; +import type { Role } from "../lib/types"; +import { Button, Spinner, Surface, Tag } from "./primitives"; +import { Icon } from "./Icon"; + +/** localStorage key for the optional "记住用户名" toggle. */ +const USERNAME_KEY = "cyrene.username"; + +export interface LoginProps { + /** Called after a successful sign-in; the parent decides how to reflect the + * new session (typically reload so connectWs + session/persona fetch re-run + * with the fresh credential). */ + onSuccess: () => void; +} + +function roleTone(role: Role): "accent" | "ok" | "neutral" { + return role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral"; +} + +/** Map a login failure to a user-safe message. Never echoes credentials, and + * collapses server detail into generic wording so nothing sensitive leaks. */ +function toLoginMessage(err: unknown): string { + if (err instanceof ApiError) { + if (err.status === 401 || err.code === "unauthorized") return "用户名或密码错误"; + if (err.code === "network_error" || err.status === 0) return "网络连接失败,请确认服务已启动"; + return err.message || "登录失败"; + } + return "登录失败"; +} + +const inputCls = + "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm text-[var(--mu-text-hi)] outline-none transition-all duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] placeholder:text-[var(--mu-text-lo)]"; + +/** + * Miuix user-facing login card. Rendered by App when no JWT is present, so the + * user shell (TopBar/Sidebar/chat) stays unreachable until authenticated. It + * carries no routing dependency — App owns what happens after onSuccess. + */ +export function Login({ onSuccess }: LoginProps): ReactNode { + const [username, setUsername] = useState(() => localStorage.getItem(USERNAME_KEY) ?? ""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(() => localStorage.getItem(USERNAME_KEY) !== null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const role = getStoredRole(); + + const submit = async (e: FormEvent): Promise => { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + const res = await login(username.trim(), password); + // Persist the JWT (and decoded role) immediately so App's next render sees + // a real credential and proceeds to the main shell. + setAuthToken(res.token); + if (remember) localStorage.setItem(USERNAME_KEY, username.trim()); + else localStorage.removeItem(USERNAME_KEY); + setPassword(""); // never keep the secret in component state after submit + onSuccess(); + } catch (err: unknown) { + setError(toLoginMessage(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+ +
+
+ +
+
登录 Cyrene
+
输入账号凭证以继续使用用户端
+ {role ? ( + + {role} + + ) : null} +
+ +
+ + + + + + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+
+ ); +} diff --git a/frontend/web/src/components/Nav.tsx b/frontend/web/src/components/Nav.tsx index 68a9eb2..38b7298 100644 --- a/frontend/web/src/components/Nav.tsx +++ b/frontend/web/src/components/Nav.tsx @@ -27,6 +27,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode const [theme, setTheme] = useState(readTheme); const [creating, setCreating] = useState(false); const connected = wsStatus === "connected"; + const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接"; const hasToken = !!getAuthToken(); const toggleTheme = () => { @@ -78,7 +79,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode diff --git a/frontend/web/src/components/TopBar.tsx b/frontend/web/src/components/TopBar.tsx index 606a169..4a8a775 100644 --- a/frontend/web/src/components/TopBar.tsx +++ b/frontend/web/src/components/TopBar.tsx @@ -23,6 +23,7 @@ const wsMeta: Record void }): React.ReactNode { @@ -71,7 +72,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode { - + {meta.label} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index a8a2c22..825328a 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -101,6 +101,38 @@ export function setStoredRole(role: Role | null): void { else localStorage.removeItem(ROLE_KEY); } +/** True when the JWT's `exp` (unix seconds) is already in the past. Used client-side + * to recognise a handshake rejection as "token expired" before the browser ever + * exposes the 401 (WS handshake failures surface only as a generic close). */ +export function isTokenExpired(token: string): boolean { + const parts = token.split("."); + if (parts.length < 2) return false; + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const payload = JSON.parse(atob(padded)) as { exp?: number }; + const exp = payload.exp; + return typeof exp === "number" && exp > 0 && Math.floor(Date.now() / 1000) >= exp; + } catch { + return false; + } +} + +/** Subscribe to an "auth expired" event. The REST layer fires this exactly once + * when a non-login request comes back 401/unauthorized, after already clearing + * the stored token; the WS layer subscribes to stop reconnecting + flag the UI. */ +type AuthExpiredHandler = () => void; +const authExpiredHandlers = new Set(); +export function onAuthExpired(handler: AuthExpiredHandler): () => void { + authExpiredHandlers.add(handler); + return () => { + authExpiredHandlers.delete(handler); + }; +} +function fireAuthExpired(): void { + authExpiredHandlers.forEach((h) => h()); +} + /** Optional API key for admin endpoints (X-API-Key). */ export function getApiKey(): string | null { return localStorage.getItem(API_KEY_KEY); @@ -125,7 +157,8 @@ export interface ChannelConfigSaveResult { async function request( path: string, - init: RequestInit = {} + init: RequestInit = {}, + opts: { skipAuthExpired?: boolean } = {} ): Promise { const headers = new Headers(init.headers); headers.set("Content-Type", "application/json"); @@ -151,9 +184,19 @@ async function request( } if (!res.ok || body.error) { + const code = body.error?.code ?? (res.status === 401 ? "unauthorized" : "unknown"); + // A 401/unauthorized on a logged-in (non-login) call means the stored token is + // gone/expired: clear it and notify the WS layer so it stops reconnecting, then + // throw a dedicated code so the UI can prompt re-login. The login call itself is + // exempt — a bad password is a per-request failure, not a stale session. + if (!opts.skipAuthExpired && (res.status === 401 || code === "unauthorized")) { + setAuthToken(null); + fireAuthExpired(); + throw new ApiError(res.status, "auth_expired", "登录已过期,请重新登录"); + } throw new ApiError( res.status, - body.error?.code ?? "unknown", + code, body.error?.message ?? `请求失败(${res.status})` ); } @@ -162,10 +205,14 @@ async function request( /** POST /api/v1/auth/login — exchanges user/password for a JWT. */ export function login(username: string, password: string): Promise { - return request("/auth/login", { - method: "POST", - body: JSON.stringify({ username, password }), - }); + return request( + "/auth/login", + { + method: "POST", + body: JSON.stringify({ username, password }), + }, + { skipAuthExpired: true } + ); } /** GET /api/v1/health — liveness probe. */ diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 5a19984..70c950d 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -30,7 +30,8 @@ export type WsStatus = | "connecting" | "connected" | "disconnected" - | "reconnecting"; + | "reconnecting" + | "expired"; export interface ToolUsed { name: string; diff --git a/frontend/web/src/lib/ws.ts b/frontend/web/src/lib/ws.ts index fbfb161..c176591 100644 --- a/frontend/web/src/lib/ws.ts +++ b/frontend/web/src/lib/ws.ts @@ -1,8 +1,17 @@ import { useAppStore } from "../store/useAppStore"; import { useModeStore } from "../store/useModeStore"; -import { getAuthToken } from "./api"; -import { MODES, type AgentInfo, type AwakenState, type ChatMessage, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types"; -import { now, uid } from "./util"; +import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api"; +import { + parseAgent, + parseAwakenTool, + parseGraph, + parsePersistedMessage, + parseTool, + parseToolCall, + playVoiceAudio, + toRecord, +} from "./wsParse"; +import { MODES, type AwakenState, type Mode, type WsUpEvent } from "./types"; /** * Realtime client for `/ws/chat`. Reconnects with exponential backoff and @@ -30,157 +39,6 @@ function wsUrl(): string { return `${proto}://${window.location.host}/ws/chat${qs}`; } -function toRecord(v: unknown): Record | null { - if (v !== null && typeof v === "object" && !Array.isArray(v)) { - return v as Record; - } - return null; -} - -/** Decode a base64 string into bytes (used for the voice audio reply). */ -function decodeBase64(b64: string): Uint8Array { - const bin = atob(b64); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return bytes; -} - -/** Best-effort playback of a voice_output audio payload (blocked by autoplay - * policy, so sound may be silent until the user interacts). */ -function playVoiceAudio(payload: unknown): void { - const r = toRecord(payload); - if (!r || typeof r.audio !== "string" || !r.audio) return; - try { - const bytes = decodeBase64(r.audio); - const format = typeof r.format === "string" && r.format ? r.format : "wav"; - const buf = new ArrayBuffer(bytes.byteLength); - new Uint8Array(buf).set(bytes); - const blob = new Blob([buf], { type: `audio/${format}` }); - const url = URL.createObjectURL(blob); - const audio = new Audio(url); - void audio.play().catch(() => {}); - audio.onended = () => URL.revokeObjectURL(url); - } catch { - // Ignore malformed audio payloads. - } -} - -function parseTool(v: unknown): ToolUsed | null { - const r = toRecord(v); - if (!r) return null; - if (typeof r.name !== "string") return null; - return { - name: r.name, - summary: typeof r.summary === "string" ? r.summary : undefined, - detail: typeof r.detail === "string" ? r.detail : undefined, - ok: typeof r.ok === "boolean" ? r.ok : undefined, - at: now(), - }; -} - -/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend - * may send a bare name or a `{name}` object; both reduce to the name. */ -function parseAwakenTool(v: unknown): string | undefined { - if (typeof v === "string") return v || undefined; - const r = toRecord(v); - if (r && typeof r.name === "string" && r.name) return r.name; - return undefined; -} - -/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used - * frame these carry `id` (provider block id) rather than a guaranteed `name`, - * so we label the card by id when no name is present. */ -function parseToolCall(v: unknown): ToolUsed | null { - const r = toRecord(v); - if (!r) return null; - const name = typeof r.name === "string" ? r.name : ""; - const id = typeof r.id === "string" ? r.id : ""; - if (!name && !id) return null; - return { - name: name || `tool_${id.slice(0, 4)}`, - summary: typeof r.summary === "string" ? r.summary : undefined, - detail: typeof r.detail === "string" ? r.detail : undefined, - ok: typeof r.ok === "boolean" ? r.ok : undefined, - at: now(), - }; -} - -function parseAgent(payload: unknown): AgentInfo | null { - const r = toRecord(payload); - if (!r) return null; - const id = typeof r.id === "string" ? r.id : uid(); - const status = - typeof r.status === "string" - ? (r.status as AgentInfo["status"]) - : typeof r.state === "string" - ? (r.state as AgentInfo["status"]) - : "running"; - const tool = parseTool(r.tool); - return { - id, - name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`, - role: typeof r.role === "string" ? r.role : undefined, - mode: typeof r.mode === "string" ? r.mode : undefined, - status, - progress: typeof r.progress === "number" ? r.progress : undefined, - thinking: typeof r.thinking === "string" ? r.thinking : undefined, - message: typeof r.message === "string" ? r.message : undefined, - tools: tool ? [tool] : undefined, - }; -} - -function parseGraph(payload: unknown): FlowGraph | null { - const r = toRecord(payload); - if (!r || !Array.isArray(r.nodes)) return null; - const nodes = r.nodes - .map((n) => toRecord(n)) - .filter((n): n is Record => n !== null) - .map((n) => ({ - id: typeof n.id === "string" ? n.id : uid(), - label: typeof n.label === "string" ? n.label : "节点", - kind: typeof n.kind === "string" ? n.kind : undefined, - status: - typeof n.status === "string" - ? (n.status as FlowGraph["nodes"][number]["status"]) - : undefined, - })); - const edges = Array.isArray(r.edges) - ? r.edges - .map((e) => toRecord(e)) - .filter((e): e is Record => e !== null) - .map((e) => ({ - from: typeof e.from === "string" ? e.from : "", - to: typeof e.to === "string" ? e.to : "", - })) - : []; - return { id: typeof r.id === "string" ? r.id : "graph", nodes, edges }; -} - -/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2): - * the backend broadcast a fully-persisted message carrying the backend-generated - * id. mode is validated against the known modes so a bad value never reaches the - * UI; createdAt falls back to now when the wire omits it. */ -function parsePersistedMessage(v: unknown): ChatMessage | null { - const r = toRecord(v); - if (!r) return null; - const id = typeof r.id === "string" && r.id ? r.id : ""; - const role = r.role; - if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null; - return { - id, - sessionId: typeof r.sessionId === "string" && r.sessionId ? r.sessionId : undefined, - role, - content: typeof r.content === "string" ? r.content : "", - mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined, - mood: typeof r.mood === "string" ? r.mood : undefined, - image: typeof r.image === "string" ? r.image : undefined, - platform: typeof r.platform === "string" && r.platform ? r.platform : undefined, - deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined, - senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined, - createdAt: typeof r.createdAt === "number" ? r.createdAt : now(), - }; -} - function dispatch(ev: { type: string; data: unknown }): void { const s = useAppStore.getState(); const r = toRecord(ev.data); @@ -374,6 +232,13 @@ function dispatch(ev: { type: string; data: unknown }): void { function scheduleReconnect(): void { if (!shouldRun) return; if (reconnectTimer !== null) return; + // Only a live, non-expired credential may retry; otherwise this isn't a network + // blip but a dead session we should surface, not loop on. + const token = getAuthToken(); + if (!token || isTokenExpired(token)) { + markAuthExpired(); + return; + } const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF); attempt += 1; useAppStore.getState().setWsStatus("reconnecting"); @@ -385,6 +250,13 @@ function scheduleReconnect(): void { function openSocket(): void { const s = useAppStore.getState(); + // A locally-expired token means the handshake will be rejected with 401 before + // it ever upgrades; don't even attempt it — flag the expiry instead. + const token = getAuthToken(); + if (token && isTokenExpired(token)) { + markAuthExpired(); + return; + } try { ws = new WebSocket(wsUrl()); } catch { @@ -393,9 +265,15 @@ function openSocket(): void { return; } + // Tracks whether this socket ever completed the handshake (reached OPEN). A + // browser surfaces a handshake 401 only as onerror+onclose (code 1006, no HTTP + // status), so the only way to tell "auth rejected" from "network blip" is to + // compare the staged close against the token's own expiry. + let opened = false; s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting"); ws.onopen = () => { + opened = true; attempt = 0; useAppStore.getState().setWsStatus("connected"); // Sync the current mode on every (re)connect so the agent's shell matches. @@ -415,11 +293,23 @@ function openSocket(): void { }; ws.onerror = () => { + // Handshake/auth rejects land here first. An expired credential is not a + // transient outage, so stop the loop and prompt re-login rather than looping. + const cur = getAuthToken(); + if (cur && isTokenExpired(cur)) { + markAuthExpired(); + return; + } useAppStore.getState().setWsStatus("disconnected"); }; ws.onclose = () => { ws = null; + const cur = getAuthToken(); + if (!opened && cur && isTokenExpired(cur)) { + markAuthExpired(); + return; + } useAppStore.getState().setWsStatus("disconnected"); scheduleReconnect(); }; @@ -450,6 +340,16 @@ export function disconnectWs(): void { useAppStore.getState().setWsStatus("idle"); } +/** Terminal "auth expired" path: stop reconnecting, drop the stale token, and + * flag the UI so it prompts re-login instead of burning backoff on a dead + * credential. Fired from (a) a handshake reject on an expired token and (b) the + * REST 401 handler (via onAuthExpired). */ +function markAuthExpired(): void { + setAuthToken(null); + disconnectWs(); + useAppStore.getState().setWsStatus("expired"); +} + /** Send an upstream frame; returns false when the socket is not open. */ export function sendUp(ev: WsUpEvent): boolean { const cur = ws; @@ -462,3 +362,10 @@ export function sendUp(ev: WsUpEvent): boolean { export function isWsOpen(): boolean { return ws?.readyState === WebSocket.OPEN; } + +// When the REST layer hits a 401/unauthorized on a logged-in call it clears the +// token and fires this handler; mirror it into the same terminal expiry path so +// the reconnect loop stops and the UI shows "登录过期" for both WS and REST 401s. +onAuthExpired(() => { + markAuthExpired(); +}); diff --git a/frontend/web/src/lib/wsParse.ts b/frontend/web/src/lib/wsParse.ts new file mode 100644 index 0000000..b797956 --- /dev/null +++ b/frontend/web/src/lib/wsParse.ts @@ -0,0 +1,160 @@ +import { MODES, type AgentInfo, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types"; +import { now, uid } from "./util"; + +/** + * Pure frame decoders for the `/ws/chat` downstream stream (design/04 §1.1 + + * design/16 §4). Each takes an `unknown` `data` payload, narrows it, and returns a + * typed model (or `null` to drop the frame). None of these touch the store, so the + * socket client (`ws.ts`) stays focused on connection lifecycle. + */ + +export function toRecord(v: unknown): Record | null { + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + return v as Record; + } + return null; +} + +/** Decode a base64 string into bytes (used for the voice audio reply). */ +function decodeBase64(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +/** Best-effort playback of a voice_output audio payload (blocked by autoplay + * policy, so sound may be silent until the user interacts). */ +export function playVoiceAudio(payload: unknown): void { + const r = toRecord(payload); + if (!r || typeof r.audio !== "string" || !r.audio) return; + try { + const bytes = decodeBase64(r.audio); + const format = typeof r.format === "string" && r.format ? r.format : "wav"; + const buf = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buf).set(bytes); + const blob = new Blob([buf], { type: `audio/${format}` }); + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + void audio.play().catch(() => {}); + audio.onended = () => URL.revokeObjectURL(url); + } catch { + // Ignore malformed audio payloads. + } +} + +export function parseTool(v: unknown): ToolUsed | null { + const r = toRecord(v); + if (!r) return null; + if (typeof r.name !== "string") return null; + return { + name: r.name, + summary: typeof r.summary === "string" ? r.summary : undefined, + detail: typeof r.detail === "string" ? r.detail : undefined, + ok: typeof r.ok === "boolean" ? r.ok : undefined, + at: now(), + }; +} + +/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend + * may send a bare name or a `{name}` object; both reduce to the name. */ +export function parseAwakenTool(v: unknown): string | undefined { + if (typeof v === "string") return v || undefined; + const r = toRecord(v); + if (r && typeof r.name === "string" && r.name) return r.name; + return undefined; +} + +/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used + * frame these carry `id` (provider block id) rather than a guaranteed `name`, + * so we label the card by id when no name is present. */ +export function parseToolCall(v: unknown): ToolUsed | null { + const r = toRecord(v); + if (!r) return null; + const name = typeof r.name === "string" ? r.name : ""; + const id = typeof r.id === "string" ? r.id : ""; + if (!name && !id) return null; + return { + name: name || `tool_${id.slice(0, 4)}`, + summary: typeof r.summary === "string" ? r.summary : undefined, + detail: typeof r.detail === "string" ? r.detail : undefined, + ok: typeof r.ok === "boolean" ? r.ok : undefined, + at: now(), + }; +} + +export function parseAgent(payload: unknown): AgentInfo | null { + const r = toRecord(payload); + if (!r) return null; + const id = typeof r.id === "string" ? r.id : uid(); + const status = + typeof r.status === "string" + ? (r.status as AgentInfo["status"]) + : typeof r.state === "string" + ? (r.state as AgentInfo["status"]) + : "running"; + const tool = parseTool(r.tool); + return { + id, + name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`, + role: typeof r.role === "string" ? r.role : undefined, + mode: typeof r.mode === "string" ? r.mode : undefined, + status, + progress: typeof r.progress === "number" ? r.progress : undefined, + thinking: typeof r.thinking === "string" ? r.thinking : undefined, + message: typeof r.message === "string" ? r.message : undefined, + tools: tool ? [tool] : undefined, + }; +} + +export function parseGraph(payload: unknown): FlowGraph | null { + const r = toRecord(payload); + if (!r || !Array.isArray(r.nodes)) return null; + const nodes = r.nodes + .map((n) => toRecord(n)) + .filter((n): n is Record => n !== null) + .map((n) => ({ + id: typeof n.id === "string" ? n.id : uid(), + label: typeof n.label === "string" ? n.label : "节点", + kind: typeof n.kind === "string" ? n.kind : undefined, + status: + typeof n.status === "string" + ? (n.status as FlowGraph["nodes"][number]["status"]) + : undefined, + })); + const edges = Array.isArray(r.edges) + ? r.edges + .map((e) => toRecord(e)) + .filter((e): e is Record => e !== null) + .map((e) => ({ + from: typeof e.from === "string" ? e.from : "", + to: typeof e.to === "string" ? e.to : "", + })) + : []; + return { id: typeof r.id === "string" ? r.id : "graph", nodes, edges }; +} + +/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2): + * the backend broadcast a fully-persisted message carrying the backend-generated + * id. mode is validated against the known modes so a bad value never reaches the + * UI; createdAt falls back to now when the wire omits it. */ +export function parsePersistedMessage(v: unknown): ChatMessage | null { + const r = toRecord(v); + if (!r) return null; + const id = typeof r.id === "string" && r.id ? r.id : ""; + const role = r.role; + if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null; + return { + id, + sessionId: typeof r.sessionId === "string" && r.sessionId ? r.sessionId : undefined, + role, + content: typeof r.content === "string" ? r.content : "", + mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined, + mood: typeof r.mood === "string" ? r.mood : undefined, + image: typeof r.image === "string" ? r.image : undefined, + platform: typeof r.platform === "string" && r.platform ? r.platform : undefined, + deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined, + senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined, + createdAt: typeof r.createdAt === "number" ? r.createdAt : now(), + }; +} diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go index 9f5c55a..951ae8b 100644 --- a/internal/auth/apikey.go +++ b/internal/auth/apikey.go @@ -72,6 +72,17 @@ func (k *APIKey) Revoke(key string) { delete(k.keys, key) } +// RevokeAll removes every currently registered API key and returns how many were +// removed. It is the administrative "revoke all" operation: after a call the +// table is empty, so no previously issued key can authenticate. +func (k *APIKey) RevokeAll() int { + k.mu.Lock() + defer k.mu.Unlock() + n := len(k.keys) + k.keys = make(map[string]Role) + return n +} + // RevokeByMask removes every key whose masked identifier (the same derived id // List returns) matches mask. It returns true when at least one key was revoked. // The mask is lossy, so it is matched by recomputing it for each key; the table diff --git a/internal/auth/apikey_test.go b/internal/auth/apikey_test.go index c054cb3..1cd8ba4 100644 --- a/internal/auth/apikey_test.go +++ b/internal/auth/apikey_test.go @@ -80,3 +80,28 @@ func TestAPIKey_Revoke(t *testing.T) { t.Error("Verify after revoke = true, want false") } } + +func TestAPIKey_RevokeAll(t *testing.T) { + t.Parallel() + k := newTestAPIKey() + keys := make([]string, 0, 3) + for _, role := range []Role{RoleOwner, RoleAdmin, RoleViewer} { + key, err := k.Generate(role) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + keys = append(keys, key) + } + if got := k.RevokeAll(); got != 3 { + t.Fatalf("RevokeAll() = %d, want 3", got) + } + for _, key := range keys { + if k.Verify(key) { + t.Errorf("Verify(%q) after RevokeAll = true, want false", key) + } + } + // An empty table reports zero and is a safe no-op. + if got := k.RevokeAll(); got != 0 { + t.Fatalf("RevokeAll() on empty table = %d, want 0", got) + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 0d70a76..f769227 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" @@ -28,8 +29,11 @@ type jwtHeader struct { Typ string `json:"typ"` } -// JWTService signs and verifies HS256 JWTs using a shared secret. +// JWTService signs and verifies HS256 JWTs using a shared secret. The secret is +// protected by a RWMutex so RotateSecret can swap it while Sign/Verify read it +// concurrently (the gateway rotates the secret live from the admin panel). type JWTService struct { + mu sync.RWMutex secret []byte log *slog.Logger } @@ -76,10 +80,27 @@ func (s *JWTService) Sign(sub string, role Role, ttl time.Duration) (string, err return "", fmt.Errorf("auth: encode payload: %w", err) } signingInput := headerB64 + "." + payloadB64 + s.mu.RLock() sig := hmacSign(s.secret, []byte(signingInput)) + s.mu.RUnlock() return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil } +// RotateSecret replaces the signing secret with newSecret, invalidating every +// token issued under the previous secret. It returns ErrInvalidSecret when +// newSecret is shorter than the 32-character minimum. The new secret is never +// logged or echoed back to the caller. +func (s *JWTService) RotateSecret(newSecret string) error { + if len(newSecret) < minSecretLen { + return fmt.Errorf("%w: got %d chars", ErrInvalidSecret, len(newSecret)) + } + s.mu.Lock() + s.secret = []byte(newSecret) + s.mu.Unlock() + s.log.Info("jwt secret rotated; previously issued tokens are now invalid") + return nil +} + // Verify validates the token signature, algorithm, role, and expiry, returning // the parsed Claims on success. func (s *JWTService) Verify(token string) (*Claims, error) { @@ -94,7 +115,10 @@ func (s *JWTService) Verify(token string) (*Claims, error) { if err != nil { return nil, fmt.Errorf("%w: decode signature: %v", ErrMalformedToken, err) } - if !hmac.Equal(provided, hmacSign(s.secret, []byte(signingInput))) { + s.mu.RLock() + expected := hmacSign(s.secret, []byte(signingInput)) + s.mu.RUnlock() + if !hmac.Equal(provided, expected) { return nil, ErrInvalidSignature } diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index cde0792..82ced65 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -183,6 +183,49 @@ func TestJWTVerify_InvalidRoleClaim(t *testing.T) { } } +func TestJWTService_RotateSecret(t *testing.T) { + t.Parallel() + svc := New(testConfig(strings.Repeat("a", 40))) + token, err := svc.Sign("u", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("Sign error: %v", err) + } + if _, err := svc.Verify(token); err != nil { + t.Fatalf("Verify before rotation error: %v", err) + } + if err := svc.RotateSecret(strings.Repeat("b", 40)); err != nil { + t.Fatalf("RotateSecret error: %v", err) + } + // The previously issued token no longer verifies under the new secret. + if _, err := svc.Verify(token); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("Verify after rotation err = %v, want ErrInvalidSignature", err) + } + // A token signed under the new secret verifies fine. + newToken, err := svc.Sign("u", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("Sign after rotation error: %v", err) + } + if _, err := svc.Verify(newToken); err != nil { + t.Fatalf("Verify under new secret error: %v", err) + } +} + +func TestJWTService_RotateSecretShort(t *testing.T) { + t.Parallel() + svc := New(testConfig(strings.Repeat("a", 40))) + if err := svc.RotateSecret("short"); !errors.Is(err, ErrInvalidSecret) { + t.Fatalf("RotateSecret(short) err = %v, want ErrInvalidSecret", err) + } + // A rejected short secret leaves the previous secret intact. + token, err := svc.Sign("u", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("Sign error: %v", err) + } + if _, err := svc.Verify(token); err != nil { + t.Fatalf("Verify after rejected rotation error: %v", err) + } +} + func TestJWTVerify_Malformed(t *testing.T) { t.Parallel() svc := New(testConfig("")) diff --git a/internal/services/gateway/admin_system.go b/internal/services/gateway/admin_system.go new file mode 100644 index 0000000..5d9c98e --- /dev/null +++ b/internal/services/gateway/admin_system.go @@ -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 +// /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, "'", "''") +} diff --git a/internal/services/gateway/admin_system_test.go b/internal/services/gateway/admin_system_test.go new file mode 100644 index 0000000..58cd858 --- /dev/null +++ b/internal/services/gateway/admin_system_test.go @@ -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/, 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) + } +} diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 6a9aad6..17dde9e 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -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.