diff --git a/docs/specs/simadmin/00-implementation-reference.md b/docs/specs/simadmin/00-implementation-reference.md new file mode 100644 index 0000000..a5e221b --- /dev/null +++ b/docs/specs/simadmin/00-implementation-reference.md @@ -0,0 +1,314 @@ +# SimAdmin 参考实现证据索引 + +本文是 `01`—`06` 六份重建规格的跨文档证据入口。它记录的是固定版本 SimAdmin 的**可观察实现事实**,不把参考实现中的缺陷自动升级为 SmsRelayed 的目标设计。除特别说明外,下文形如 `backend/src/main.rs:511-1015` 的引用均相对于 `/tmp/SimAdmin`,且行号只对本文固定的 commit 有效。 + +## 1. 证据边界、版本与许可证 + +### 1.1 固定参考版本 + +| 项目 | 固定值 | 证据 | +|---|---|---| +| Git commit | `eb6f497ad332e59f1f3899acc3b2b9427661fe5c` | `git -C /tmp/SimAdmin rev-parse HEAD` | +| 分支 | `main`,跟踪 `origin/main`,ahead/behind 均为 `0` | `git -C /tmp/SimAdmin status --short --branch`;`git -C /tmp/SimAdmin rev-list --left-right --count HEAD...@{upstream}` | +| 工作区 | clean | `git -C /tmp/SimAdmin status --porcelain` 无输出 | +| 后端包 | `simadmin 1.1.10` | `backend/Cargo.toml:1-4` | +| Cargo workspace | Rust 2021;后端和 4 个本地 crate | `Cargo.toml:1-20` | + +任何重新考证都应先验证 commit;若 `/tmp/SimAdmin` 已移动,不能继续沿用本文行号。 + +### 1.2 如何使用这些证据 + +本文采用三个标签: + +- **兼容事实**:若目标是替换现有 SimAdmin 客户端,默认需要保留的 HTTP 方法、路径、字段或异步语义。 +- **实现观察**:参考代码如何完成工作,可用于理解设备约束,但不代表推荐架构。 +- **重建决策**:参考实现没有可靠定义、存在明显风险,或 `01`—`06` 已明确要求改进的地方;实现前必须显式定案。 + +证据优先级为:路由注册与后端实际代码 > 后端模型/配置/数据库定义 > 前端调用与类型 > README、环境和开发文档。后端模型位于 `backend/src/models.rs`,前端类型和客户端分别位于 `frontend/src/api/contracts.ts`、`frontend/src/api/current.ts`;第一方开发文档也明确了这三层契约来源(`docs/developer.md:109-137`)。 + +### 1.3 许可证事实与重建边界 + +参考仓库根许可证文本是 GNU GPL Version 3,Cargo 元数据声明 `GPL-3.0-or-later`;README 的“开源协议声明”还明确写出了衍生和分发时的源码、许可证等义务(`LICENSE:1-5`,`Cargo.toml:11-20`,`README.md:203-219`)。本文只记录这一事实,不给法律结论;重建者应自行确认目标产品、分发方式和源码复用方式的合规要求。 + +据此应区分: + +- 从运行行为提炼 HTTP 契约、状态机和测试用例,是**行为兼容规格**; +- 复制或改编 Rust/TypeScript/脚本源码,是另一项需要单独评估的源码复用行为; +- 本索引只摘录符号、路径和少量必要语义,不复制大段源码。 + +## 2. 运行拓扑与模块映射 + +### 2.1 进程与依赖拓扑 + +SimAdmin 是一个以 root 运行的 Axum 后端,托管同目录 `www/` SPA,并通过 system D-Bus、ModemManager 和 NetworkManager 管理设备;默认二进制、SPA、数据库分别位于 `/opt/simadmin/simadmin`、`/opt/simadmin/www/`、`/opt/simadmin/data.db`(`README.md:39-46`,`docs/environment.md:1-39`,`scripts/simadmin.service:6-16`)。 + +后端启动时会连接 system D-Bus、打开 SQLite、加载 JSON 配置、确保 NetworkManager modem profile,然后启动通知、系统事件、设备状态、DDNS、OTA、SMS、数据连接、watchdog、统计和自动化等任务(`backend/src/main.rs:314-508`)。共享状态集中在 `AppState`,其中 cell lock 和若干用户意图只在内存中保存(`backend/src/state.rs:30-90`)。 + +### 2.2 功能到源码映射 + +| 规格 | 后端主入口 | 持久化/配置 | 前端与辅助文件 | +|---|---|---|---| +| `01` eSIM、SIM identity、切换后 SMS resync | `EsimSupervisor`、`run_lpac_command`(`backend/src/esim.rs:60-220,915-1007`);eSIM/SIM handlers(`backend/src/handlers.rs:580-1357`);identity、SMSC、本机号码、基带恢复(`backend/src/modem_manager.rs:723-832,1167-1890,5812-6244`);SMS listener(`backend/src/sms_listener.rs:18-535`) | SIM/eSIM cache schema 与方法(`backend/src/db.rs:539-631,1684-2042`);`WorkMode`/eSIM config(`backend/src/models.rs:39-45`,`backend/src/config.rs:1869-1915`) | `frontend/src/pages/SimCard.tsx`、`frontend/src/pages/EsimManager.tsx`;客户端调用(`frontend/src/api/current.ts:430-538`) | +| `02` 系统事件通知 | 事件目录、信封和 emitter(`backend/src/system_event.rs:13-106,461-662`);资源采样器(`backend/src/system_event_monitor.rs:11-101`);匹配、发送、队列 worker(`backend/src/notification.rs:69-240,700-785,974-1207`);队列 API(`backend/src/notification_queue.rs:23-118`) | 通知日志/队列(`backend/src/db.rs:452-511`);channel/rule/quiet hours/rate limit/retention(`backend/src/config.rs:301-629`) | `frontend/src/pages/NotificationCenter.tsx` 与 `frontend/src/pages/notifications/*`;客户端调用(`frontend/src/api/current.ts:924-990`) | +| `03` modem watchdog 与恢复 | 启动和 15 秒 watchdog(`backend/src/main.rs:446-466`);阈值、发现、恢复、基带重启(`backend/src/modem_manager.rs:56-94,1081-1157,5812-6686`) | 恢复计数、cooldown、重启进度主要为进程内状态(`backend/src/modem_manager.rs:87-94,6256-6271`) | `scripts/simadmin-modem-recovery.service:1-11`、`scripts/simadmin-modem-recovery.sh:1-95`;基带状态客户端(`frontend/src/api/current.ts:574-582`) | +| `04` 自动化调度 | scheduler 与执行器(`backend/src/automation/scheduler.rs:20-245`);handler registry(`backend/src/automation/tasks/mod.rs:10-35`);4 个 task handlers(`backend/src/automation/tasks/*.rs`) | task 配置(`backend/src/config.rs:1446-1503`);仅结果日志表(`backend/src/db.rs:656-673`) | `frontend/src/pages/AutomationCenter.tsx`、`frontend/src/pages/automation/*`;客户端调用(`frontend/src/api/current.ts:1145-1180`) | +| `05` 备份恢复 | 归档、校验、应用、local file handlers(`backend/src/backup.rs:31-770,898-1502,2059-2258`) | component 定义和 manifest(`backend/src/backup.rs:31-183,232-349`);backup config(`backend/src/config.rs:1505-1647`) | `frontend/src/pages/BackupRestore.tsx`、`frontend/src/components/backup/BackupStorageSelector.tsx`;客户端调用(`frontend/src/api/current.ts:1054-1143`) | +| `06` 蜂窝网络 | 状态、cell、radio、band、data、roaming、airplane、operator、APN adapters(`backend/src/modem_manager.rs:233-4886,5574-5760`);HTTP handlers(`backend/src/handlers.rs:1431-1946`) | APN/roaming/data intent 在 JSON 配置(`backend/src/config.rs:1843-1915`);cell lock 仅内存(`backend/src/cell_lock_store.rs:1-68`) | `frontend/src/pages/Network.tsx`;客户端调用(`frontend/src/api/current.ts:540-705`) | + +共享基础设施还包括统一 `ApiResponse`(`backend/src/models.rs:8-37`)、全局设备串行锁 `with_serial`(`backend/src/serial.rs:1-28`)、SQLite wrapper(`backend/src/db.rs:226-229,381-690`)和所有路由的实际注册点(`backend/src/main.rs:511-1015`)。 + +## 3. HTTP 路由与契约来源 + +### 3.1 全局契约 + +- **兼容事实**:普通 JSON envelope 为 `{status,message,data?}`,业务成功使用 `status: "ok"`,业务失败使用 `status: "error"`(`backend/src/models.rs:8-37`)。 +- **兼容事实**:除 health、hub provision 和 auth status/setup/login/logout 外,下面列出的设备 API 都在 `auth_middleware` 之后;CORS 当前允许任意 origin、method 和 header(`backend/src/main.rs:978-1018`)。 +- **兼容事实**:不少业务错误仍返回 HTTP `200`,调用方必须同时检查 envelope;例如 eSIM command error、通知队列错误、cell-lock 校验错误和备份恢复错误(`backend/src/handlers.rs:78-84,1857-1875`,`backend/src/notification_queue.rs:23-118`,`backend/src/backup.rs:642-667`)。eSIM disabled/unavailable 是明确的 `403`/`503` 例外(`backend/src/handlers.rs:78-84`)。 +- **重建决策**:若提供兼容层,可保留旧 envelope/HTTP 行为;新 API 应另行固化语义状态码、稳定 error code、operation ID 和幂等键,不能在同一端点里悄然改变错误判定规则。 + +### 3.2 新实现的跨文档统一契约 + +以下约束优先于 `01`—`06` 中未注明理由的局部写法;具体文档若确需不同契约,必须在该文档写出“覆盖项、理由、兼容影响和迁移方式”。它们是 SmsRelayed 的目标约束,不是 SimAdmin 已有行为: + +1. **事件 registry**:统一使用 `GET /api/system-events/registry`。`/api/notifications/registry` 不是统一路径;`02` 或前端段若仍出现旧路径,应更正或显式标成 versioned compatibility alias。参考实现事实上没有任一路由(完整注册表:`backend/src/main.rs:511-1015`)。 +2. **机器可判定字符串**:所有新增的 `status`、`state`、`error_code` 等单段 token 使用 ASCII `lower_snake_case`;`event_code`、`operation_type` 和 resource key 可以使用点号建立命名空间,但每一段仍必须是 `lower_snake_case`,例如 `delivery.profile_unhealthy`、`registration.manual`、`modem.control`。展示文本放在独立 `message`/`label` 字段。兼容 adapter 可继续解析参考实现的 `ok|error` envelope,但不能把自由文本当错误码(参考 envelope:`backend/src/models.rs:8-37`)。 +3. **长操作**:eSIM profile switch、baseband recovery、operator scan/register、backup export/apply、危险 automation action 等不能用同步成功暗示完成。创建请求统一返回 HTTP `202 Accepted` 和可持久化的 operation resource,至少含 `operation_id`、`operation_type`、`status`、`created_at`、`updated_at`、可选 `progress`/`result`/`error_code`;REST 可重新读取最终状态。领域 `run`(例如 automation run)可以充当 operation resource,但不得再创建一份会漂移的平行状态。旧 eSIM enable 立即 HTTP 200 只作为兼容事实(`backend/src/handlers.rs:839-999`)。 +4. **事实源**:SQLite 中的 durable event/operation/run 记录及其 REST 查询是事实源;SSE 只加速 UI 更新,断线、丢帧或服务重启后必须通过 cursor/REST 补读,不能让浏览器内存或 SSE 到达顺序决定最终状态。参考实现没有这层 durable operation/event source(`backend/src/system_event.rs:461-568`,`backend/src/db.rs:452-511,656-673`)。 +5. **跨功能资源锁顺序**:需要多资源时,统一按 `system.lifecycle > modem.recovery > modem.control > modem.sms > storage.maintenance` 获取,释放顺序相反;禁止在持有较右侧资源时再申请较左侧资源。单一功能文档若要覆盖该顺序,必须给出避免死锁的证明和跨 worker 测试。参考实现只有单进程 `with_serial` 与独立 `lpac_lock`,没有这套 durable/typed lock hierarchy(`backend/src/serial.rs:1-28`,`backend/src/esim.rs:60-70,202-220`)。 + +### 3.3 `01` eSIM 与 SIM identity + +| 方法与路径 | handler / 语义 | 契约证据 | +|---|---|---| +| `GET /api/sim` | 当前 SIM 概览 | 注册:`backend/src/main.rs:523-525`;后端模型:`backend/src/models.rs:292-317`;前端:`frontend/src/api/current.ts:519-523` | +| `POST /api/sim/details/refresh` | 触发 identity 详情刷新 | 注册:`backend/src/main.rs:527-529`;前端 timeout 2.5 秒:`frontend/src/api/current.ts:525-530` | +| `POST /api/sim/cache` | 写 SIM cache | 注册:`backend/src/main.rs:531-533`;请求:`frontend/src/api/current.ts:533-537` | +| `GET/POST /api/work-mode` | 查询/切换 `sim|esim` feature gate | 注册:`backend/src/main.rs:691-695`;`WorkMode`:`backend/src/models.rs:39-45` | +| `GET/POST /api/esim/config` | lpac 路径与内存覆盖配置 | 注册:`backend/src/main.rs:697-701`;配置:`backend/src/config.rs:1869-1884` | +| `GET /api/esim/lpac/status`、`POST /api/esim/lpac/repair` | 探测/安装 lpac | 注册:`backend/src/main.rs:703-709`;超时:`frontend/src/api/current.ts:470-481` | +| `GET /api/esim/euicc` | cached-first;`?live=1` 强制 live | 注册:`backend/src/main.rs:711-713`;前端 query:`frontend/src/api/current.ts:452-455`;handler:`backend/src/handlers.rs:689-750` | +| `GET/POST /api/esim/profiles` | 列表;download | 注册:`backend/src/main.rs:715-719`;前端 cached/live/download:`frontend/src/api/current.ts:458-468,507-512` | +| `POST /api/esim/profiles/{iccid}/enable` | path ICCID;立即返回“后台任务已启动” | 注册:`backend/src/main.rs:721-723`;后台 spawn 与 HTTP 200:`backend/src/handlers.rs:839-999` | +| `POST /api/esim/profiles/{iccid}/rename` | path ICCID + `{name}` | 注册:`backend/src/main.rs:725-727`;handler:`backend/src/handlers.rs:1001-1022`;前端:`frontend/src/api/current.ts:492-497` | +| `DELETE /api/esim/profiles/{iccid}` | path ICCID | 注册:`backend/src/main.rs:729-730`;handler:`backend/src/handlers.rs:1025-1052`;前端:`frontend/src/api/current.ts:500-504` | + +### 3.4 `02` 系统事件通知 + +| 方法与路径 | 语义 | 契约证据 | +|---|---|---| +| `GET/POST /api/notifications/config` | channels、rules、templates、quiet hours、retention | `backend/src/main.rs:839-845`;`backend/src/config.rs:301-629`;`frontend/src/api/current.ts:924-933` | +| `POST /api/notifications/test/{channel}` | 测试单一 channel | `backend/src/main.rs:846-849`;`frontend/src/api/current.ts:935-939` | +| `GET /api/notifications/logs`、`POST .../logs/clear` | 查询/清理投递日志 | `backend/src/main.rs:851-858`;query 字段:`frontend/src/api/current.ts:941-958` | +| `GET /api/notifications/queue` | 查看重试队列 | `backend/src/main.rs:859-862`;`frontend/src/api/current.ts:961-966` | +| `POST .../queue/retry-all`、`POST .../queue/clear` | 批量操作 | `backend/src/main.rs:863-870`;`frontend/src/api/current.ts:980-989` | +| `POST .../queue/{id}/retry`、`DELETE .../queue/{id}` | 单项重试/删除 | `backend/src/main.rs:871-878`;`frontend/src/api/current.ts:968-977` | + +参考实现没有 canonical event 查询 API,也没有 SSE 路由;`SystemEventEmitter` 直接把启用的事件送入 notification path(`backend/src/system_event.rs:532-568`,完整路由表 `backend/src/main.rs:511-1015`)。因此 `02` 中的事件历史、SSE replay、事件 ID 和恢复关联均是目标设计,不是兼容事实。 + +### 3.5 `03` watchdog 与恢复 + +| 方法与路径 | 语义 | 契约证据 | +|---|---|---| +| `POST /api/baseband/restart` | 人工启动基带恢复 | `backend/src/main.rs:682-684`;`frontend/src/api/current.ts:574-579` | +| `GET /api/baseband/restart/status` | 读取进程内全局步骤/状态 | `backend/src/main.rs:685-688`;`backend/src/modem_manager.rs:5812-5876` | + +watchdog 本身没有 enable/config/status/history/cancel API;它在启动时固定以 15 秒间隔运行(`backend/src/main.rs:446-466`,路由表 `backend/src/main.rs:511-1015`)。`03` 中的 recovery operation、lease、审计历史和人工取消因此都是重建新增契约。 + +### 3.6 `04` 自动化 + +| 方法与路径 | 语义 | 契约证据 | +|---|---|---| +| `GET/POST /api/automation/config` | 整体读写 task 数组 | `backend/src/main.rs:879-885`;配置 union:`backend/src/config.rs:1446-1503`;前端:`frontend/src/api/current.ts:1145-1153` | +| `GET /api/automation/logs`、`POST .../logs/clear` | 查询/清理结果日志 | `backend/src/main.rs:886-893`;query:`frontend/src/api/current.ts:1162-1179` | +| `POST /api/automation/test/{task_id}` | 手工测试已配置 task | `backend/src/main.rs:894-897`;`frontend/src/api/current.ts:1156-1159` | + +参考实现没有 definition CRUD、run registry、run detail、cancel/retry API;其持久化事实只有整体 JSON task 配置和完成后的日志行(`backend/src/config.rs:1446-1503`,`backend/src/db.rs:656-673`)。 + +### 3.7 `05` 备份恢复 + +| 方法与路径 | 语义 | 契约证据 | +|---|---|---| +| `GET /api/backup/options` | component 元数据 | `backend/src/main.rs:899-902`;`backend/src/backup.rs:46-190` | +| `GET/POST /api/backup/config` | schedule/cleanup/local storage 配置 | `backend/src/main.rs:903-908`;`backend/src/config.rs:1547-1647` | +| `POST /api/backup/export` | 返回 ZIP 二进制 | `backend/src/main.rs:909-912`;`frontend/src/api/current.ts:1069-1074` | +| `POST /api/backup/export-local` | 保存本地 ZIP | `backend/src/main.rs:913-916`;`frontend/src/api/current.ts:1076-1081` | +| `POST /api/backup/data/clear` | 清理选定组件 | `backend/src/main.rs:917-920`;`frontend/src/api/current.ts:1084-1089` | +| `POST /api/backup/import/preview`、`POST .../apply` | 上传预览/恢复;body 上限 50 MiB | `backend/src/main.rs:921-932`;query 与二进制调用:`frontend/src/api/current.ts:1092-1104` | +| `GET /api/backup/files` | 列本地备份与 pre-restore | `backend/src/main.rs:933-936`;`frontend/src/api/current.ts:1126-1128` | +| `GET .../files/{filename}/preview`、`POST .../apply` | 本地文件预览/恢复 | `backend/src/main.rs:937-944`;`frontend/src/api/current.ts:1107-1123` | +| `GET/DELETE /api/backup/files/{filename}` | 下载/删除 | `backend/src/main.rs:945-950`;`frontend/src/api/current.ts:1130-1142` | + +### 3.8 `06` 蜂窝网络 + +| 能力 | 实际方法与路径 | 契约证据 | +|---|---|---| +| 只读状态 | `GET /api/network`、`/api/cells`、`/api/network/signal-strength`、`/api/location/cell-info` | `backend/src/main.rs:535-539,623-629`;响应模型:`backend/src/models.rs:182-220,320-329,719-750` | +| cell monitor | `POST /api/cell-monitor/start|stop` | `backend/src/main.rs:541-547` | +| radio/band | `GET/POST /api/radio-mode`、`GET/POST /api/band-lock` | `backend/src/main.rs:549-559`;模型:`backend/src/models.rs:331-384` | +| operator | `GET /api/network/operators`、**`GET /api/network/operators/scan`** | `backend/src/main.rs:631-637`;前端同样用 GET:`frontend/src/api/current.ts:645-651` | +| register | `POST /api/network/register-manual|register-auto` | `backend/src/main.rs:639-645` | +| APN | `GET/POST /api/apn` | `backend/src/main.rs:647-651`;模型:`backend/src/models.rs:773-800` | +| cell lock | `GET/POST /api/cell-lock`、`POST /api/cell-lock/unlock-all` | `backend/src/main.rs:653-661`;模型:`backend/src/models.rs:386-417` | +| user policy | `GET/POST /api/data`、`/api/roaming`、`/api/airplane-mode` | `backend/src/main.rs:663-680`;模型:`backend/src/models.rs:233-280` | + +**兼容注意**:operator scan 是 GET,不是 POST;该 GET 可等待 D-Bus Scan 最多 45 秒并继续轮询 cache 20 秒,即使 Scan 失败/超时也可能返回旧 operators(`backend/src/modem_manager.rs:61-63,4324-4358`)。若新设计改为异步 operation,旧 GET 需保留为兼容 adapter 或明确版本化。 + +## 4. 持久化、表与配置来源 + +### 4.1 SQLite + +数据库路径是可执行文件同目录的 `data.db`;连接被包在 `Arc>` 中(`backend/src/main.rs:80-86,320-323`,`backend/src/db.rs:226-229`)。schema 不是编号 migration,而是在 `Database::new` 中执行 `CREATE TABLE IF NOT EXISTS` 和少量按列 `ALTER TABLE`(`backend/src/db.rs:381-678`)。 + +| 表 | 主要用途/字段 | 对应规格与证据 | +|---|---|---| +| `sms_messages` | 收发方向、号码、正文、时间、状态、notification/hub 状态、PDU marker | `01`,`02`,`04`,`05`;`backend/src/db.rs:386-434` | +| `hub_notification_events` | Hub 同步 outbox 风格事件;不是本地 canonical system event history | `02`;`backend/src/db.rs:435-449` | +| `notification_logs` | event/rule/channel/message/result | `02`,`05`;`backend/src/db.rs:452-474` | +| `notification_queue` | 已渲染 title/body、next attempt、attempt/max=5、error、状态 | `02`,`05`;`backend/src/db.rs:478-511` | +| `smsc_cache`、`own_number_cache`、`sms_storage_cache` | `identity_key` 主键下的 SIM 派生缓存 | `01`,`05`;`backend/src/db.rs:538-576` | +| `esim_profile_cache` | ICCID 主键以及 profile、matching ID、SM-DP、状态/权限字段 | `01`,`05`;`backend/src/db.rs:578-616` | +| `esim_euicc_cache` | EID、容量、manufacturer、`raw` | `01`,`05`;`backend/src/db.rs:618-631` | +| `auth_config`、`auth_sessions` | 密码散列/认证配置与 session hash/过期时间 | `05`;`backend/src/db.rs:633-654` | +| `automation_logs` | task ID/name/type、最终 status/detail/time;无 run ID/lease/attempt | `04`,`05`;`backend/src/db.rs:656-673` | + +`call_history` 也在同库中,但不属于 `01`—`06` 本轮重建范围(`backend/src/db.rs:513-536`)。 + +### 4.2 JSON 配置 + +SimAdmin 配置不是 TOML。若 `/data` 存在,路径为 `/data/config.json`,否则回退到可执行文件同目录 `config.json`(`backend/src/config.rs:2317-2330`)。`AppConfig` 包含 notification、device network、security、roaming/data/APN、work mode/eSIM、automation、backup 等(`backend/src/config.rs:1886-1916`)。 + +重要配置来源: + +- 通知渠道、规则、quiet hours、rate limit 和日志清理:`backend/src/config.rs:301-629`; +- automation triggers/actions:`backend/src/config.rs:1446-1503`; +- backup components/schedule/cleanup/local dir:`backend/src/config.rs:1505-1647`; +- APN username/password、lpac path:`backend/src/config.rs:1843-1884`。 + +配置读取/解析失败时进程记录 warning 后使用默认值;保存使用直接 `fs::write`,没有临时文件、rename、fsync 或显式权限设置(`backend/src/config.rs:2077-2112,2298-2313`)。重建不能把这一写入方式当作可靠性目标。 + +### 4.3 备份归档是另一层逻辑 schema + +备份格式为 ZIP,`manifest.json` 记录 format version、app、component、record count、per-file SHA-256、创建时间、SimAdmin version 和敏感标志(`backend/src/backup.rs:31-44,232-249,898-969`)。component 是 `config`、`sms`、notification config/log/queue、automation config/log、SIM/eSIM cache、auth(`backend/src/backup.rs:46-152`);默认不含 notification logs/queue、automation logs、auth(`backend/src/backup.rs:31-44,135-152`)。 + +## 5. 后台任务与并发模型 + +| 任务/资源 | 参考实现并发语义 | 证据与重建含义 | +|---|---|---| +| 全局设备写操作 | `with_serial` 是单进程全局 async mutex;许多 D-Bus/AT/NetworkManager mutation 在持锁期间 await | `backend/src/serial.rs:1-28`;这是 adapter 互斥,不是 durable lease,也没有 operation owner/cancel | +| lpac | `EsimSupervisor` 有独立 `lpac_lock`;与 `with_serial` 不是同一把锁 | `backend/src/esim.rs:60-70,202-220`;重建必须定义跨 eSIM、基带和蜂窝写操作的统一锁序 | +| eSIM enable | handler 重置一个全局 restart progress,spawn 后立即 HTTP 200;没有 operation ID 或原子“已在运行”拒绝 | `backend/src/handlers.rs:839-999`;重复请求可能竞争同一进度面板 | +| SMS listener/resync | 一个 unbounded mpsc handle 驱动单 listener;同时接收 D-Bus Added、15 秒 poll、显式 resync;modem path 变化后重绑 | `backend/src/sms_listener.rs:18-58,400-535` | +| modem watchdog | 每 15 秒循环;只在全局 baseband restart flag 为 true 时暂停;阈值/cooldown 都在内存 | `backend/src/main.rs:446-466`,`backend/src/modem_manager.rs:6256-6283` | +| system resource monitor | 每 60 秒采样;alarm counter/hysteresis 在内存,重启即丢失 | `backend/src/system_event_monitor.rs:11-101` | +| notification queue | 5 秒 tick,每批最多 20 项,每项 spawn;DB 条件更新抢占 `sending`,指数退避,最多 5 attempts | `backend/src/notification.rs:1059-1207`;未见启动时回收遗留 `sending` 的流程 | +| automation | 30 秒 tick;fixed minute dedupe 仅 `HashMap`;interval 查最后日志;每个到期任务独立 spawn | `backend/src/automation/scheduler.rs:20-125`;没有 durable due slot、lease、max concurrency 或资源锁 | +| backup/restore | handler 内同步构建/读写 ZIP 和 DB transaction;没有全局 maintenance barrier 暂停 SMS、通知、scheduler、watchdog | `backend/src/backup.rs:360-770,1336-1502`;`AppState` 没有 restore coordinator(`backend/src/state.rs:33-55`) | + +## 6. 外部命令、设备和权限依赖 + +第一方环境文档要求 Debian/Linux、ARM64 或 x86_64、systemd、root、system D-Bus,并列出 ModemManager/mmcli、NetworkManager/nmcli、qmicli、libqmi/libmbim/libpcsclite、网络诊断和解压工具(`docs/environment.md:1-19`)。实际实现还应按下表核对: + +| 依赖 | 使用位置 | 风险/前置条件 | +|---|---|---| +| ModemManager system D-Bus | modem/SIM/SMS/radio/band/operator/bearer 主接口 | 接口/属性随固件和 ModemManager 版本变化;第一方文档列出使用的 D-Bus interfaces(`docs/developer.md:162-207`) | +| `mmcli` | modem scan、信息/SMSC fallback、boot recovery | discovery 直接取排序后的第一个 modem(`backend/src/modem_manager.rs:1081-1157`);多 modem 不具稳定绑定 | +| `qmicli`、`mbimcli`、AT 端口 | cell/SMSC/SIM fallback、profile power-cycle | helper timeout 和端口类型在 `backend/src/modem_manager.rs:56-78`;需 QMI/MBIM/AT 权限和设备节点 | +| `lpac` | eUICC/profile info/list/download/enable/rename/delete | 默认 `/opt/simadmin/lpac/lpac`;QMI APDU + curl HTTP;自动发现 `/dev/cdc-wdm*`/`/dev/wwan*qmi*`,默认 slot 1 和 `/dev/wwan0at0`(`backend/src/esim.rs:24-36,915-1080`) | +| `nmcli` / NetworkManager | GSM profile 创建、APN、连接/断开/autoconnect | 启动时无条件调用 profile ensure,可能删除旧 unmanaged 文件并重启 NetworkManager(`backend/src/main.rs:333-334`,`backend/src/modem_manager.rs:5574-5688`) | +| `systemctl` / systemd | MM/NM/service/OS 恢复 | 主服务为 root 且 `Restart=always`(`scripts/simadmin.service:6-16`);启动还会写 MM debug override 并重启 ModemManager(`backend/src/main.rs:148-186,314-315`) | +| `ping`/`ping6`、`/sys/class/thermal` | 连通性和温度事件 | 目标硬编码为 `223.5.5.5`、`2400:3200::1`;温度依赖 Linux thermal sysfs(`backend/src/system_event_monitor.rs:447-470`) | +| boot recovery script | `journalctl`、`killall qmi-proxy`、`udevadm`、`logger`、remoteproc sysfs | 会清空 `/var/lib/ModemManager` 子项并 stop/start modem remoteproc;这是高权限、设备专用恢复动作(`scripts/simadmin-modem-recovery.sh:1-95`) | + +## 7. 关键不变量、已知不一致与重建决策 + +### 7.1 应显式保留或提供兼容层的行为 + +1. eSIM 是插在物理卡槽中的 eUICC profile 管理开关,不是板级 SIM 通路切换,也没有常驻 eSIM worker(`backend/src/esim.rs:1-5,73-85`)。 +2. eSIM enable/rename/delete 的 ICCID 在 path 中;enable 是 fire-and-observe,通过全局 baseband progress/status 观察,而不是同步完成(`backend/src/main.rs:721-730`,`backend/src/handlers.rs:839-999`)。 +3. `/api/network/operators/scan` 是可能长时间等待的 GET;多数 API 的 HTTP 200 不代表业务成功(`backend/src/main.rs:631-637`,`backend/src/models.rs:8-37`,`backend/src/handlers.rs:78-84`)。 +4. profile 真正切换成功后执行基带/SIM power-cycle,再请求 SMS resync;即使 recovery 失败也会请求一次 resync(`backend/src/handlers.rs:856-909`)。 +5. radio mode/band/operator registration/APN/airplane/data 等设备 mutation 依赖序列化,空 band list 表示恢复全部支持频段(`backend/src/modem_manager.rs:3269-3290,3436-3467,4385-4417,4833-4886`)。 + +### 7.2 不能照搬为目标不变量的参考行为 + +1. **身份绑定**:`find_modem_path` 取排序后的第一个 modem;没有 USB path、设备序列、IMEI 等稳定 fingerprint 验证(`backend/src/modem_manager.rs:1081-1157`)。 +2. **SMS 去重/转发差异**:无时间戳 marker 含 D-Bus object path;reconcile 还按号码+正文做 legacy 全局去重,可能吞掉合法重复短信。周期 poll 会转发新发现短信,显式 profile-switch resync 不转发(`backend/src/sms_listener.rs:78-103,192-288,497-521`)。 +3. **eSIM operation 竞态与敏感输出**:enable 没有 durable operation/CAS;lpac JSON 解析错误会把完整 stdout 放进 error,cache 还保存 eUICC `raw`、matching ID 和 SM-DP(`backend/src/handlers.rs:839-999`,`backend/src/esim.rs:992-1007`,`backend/src/db.rs:578-631`)。 +4. **事件不是事实流**:SystemEvent 没有 event ID、dedupe/recovery correlation 或本地事件表;未启用通知规则时 emitter 直接跳过事件(`backend/src/system_event.rs:461-568`)。 +5. **通知可靠性**:队列存已渲染 title/body,没有 dedupe key;5 次尝试后失败,且未见进程重启回收 `sending` 的 lease(`backend/src/db.rs:478-511`,`backend/src/notification.rs:1059-1207`)。 +6. **watchdog 用户意图冲突**:`data_user_disabled` 分支会清空 searching/radio recovery 计数并提前返回,导致“只关闭数据”同时停止注册恢复;这与 `03`/`06` 所需的控制面/数据面意图分离不一致(`backend/src/modem_manager.rs:6330-6348`)。 +7. **硬恢复越权面大**:boot helper 固定等 60 秒,然后可能删除全部 ModemManager cache、kill qmi-proxy 和写 remoteproc sysfs;它不与主进程 lease、SMS resync、用户意图或稳定 modem identity 协调(`scripts/simadmin-modem-recovery.sh:12-95`)。 +8. **scheduler 不耐重启**:fixed dedupe 在内存;interval 只看最终日志;并发 task 没有 lease/资源锁;`reboot_device` spawn 后立即返回成功,日志不能证明 reboot 完成(`backend/src/automation/scheduler.rs:20-125,208-245`,`backend/src/automation/tasks/device_reboot.rs:24-33`)。 +9. **短信发送不具 exactly-once 证据**:automation SMS 在 modem send 返回后才写 DB;超时/崩溃落在“可能已发但未记账”的不确定窗口,重试可能重复(`backend/src/automation/tasks/send_sms.rs:94-145`)。 +10. **备份不等于安全快照**:ZIP 只有 SHA-256 完整性,无加密/签名;可包含短信、APN、SIM/eSIM raw/identity 等敏感数据(`backend/src/backup.rs:124-133,232-249,898-1127`)。 +11. **恢复非跨介质原子**:DB transaction 先提交,随后才写配置;配置写失败不会回滚 DB。pre-restore 固定为默认组件(可附 auth),可能没有覆盖本次所选 logs/queue;workers 没暂停(`backend/src/backup.rs:670-714,1336-1502`)。 +12. **归档防御不足**:上传只限制压缩 body 50 MiB;解压把所有 entry 读入内存,没有累计展开大小/entry 数/压缩比限制;同名 entry 由 map 后写覆盖(`backend/src/main.rs:921-932`,`backend/src/backup.rs:1202-1275`)。 +13. **cell lock 是 UI 假状态**:GET/POST 仅读写内存,不下发硬件,重启即丢失(`backend/src/cell_lock_store.rs:1-68`,`backend/src/handlers.rs:1846-1888`)。 +14. **APN secret 暴露**:APN GET 返回 bearer username/password;POST 信任客户端 `context_path` 创建 D-Bus proxy;配置明文保存,NM 修改把凭据放入 `nmcli` argv(`backend/src/modem_manager.rs:4463-4568,4833-4886,5691-5718`,`backend/src/config.rs:1843-1867`)。 +15. **启动默认接管设备**:进程启动会写 ModemManager override、可能重启 MM/NM、确保 GSM profile 并自动初始化数据连接;不能作为 SmsRelayed “安装后只观测、不接管”的默认行为(`backend/src/main.rs:148-186,314-334,425-444`,`backend/src/modem_manager.rs:5574-5688`)。 +16. **配置和本地备份写入非原子**:两者都直接 `fs::write`;掉电可能留下部分文件(`backend/src/config.rs:2298-2313`,`backend/src/backup.rs:2059-2077`)。 +17. **前端真相漂移**:Network 页面把 band 配置另存 localStorage,而真实 band 状态来自 modem;Backup 页面把实际 ZIP pre-restore 名称模拟成 `.db`(`frontend/src/pages/Network.tsx:91,135-148,274-299`,`frontend/src/pages/BackupRestore.tsx:877`)。 + +### 7.3 实现前必须固化的跨文档决策 + +- modem/SIM/eUICC 的稳定 fingerprint、换卡/换 profile 时 cache ownership 和多 modem 选择策略; +- 一把 durable resource lock 是否覆盖 eSIM、基带恢复、watchdog、automation、backup barrier 和人工蜂窝 mutation,以及锁序、租约、超时和取消; +- 旧 HTTP 兼容层与新 versioned API 的边界,特别是 HTTP 200 error envelope、scan GET、eSIM background enable; +- canonical event/run/recovery operation 的 ID、状态、重启恢复、SSE replay、retention 和敏感字段策略; +- SMS reconcile 是否通知、去重键、unknown-send-result 的处理和幂等边界; +- backup 加密/密钥托管、签名/真实性、展开限制、跨 DB+配置原子恢复、worker quiesce 和 rollback; +- NetworkManager 接管是否 opt-in,APN secret 是否 write-only,真实 cell-lock capability 的探测与“不支持”语义; +- root/systemd helper 的最小权限、设备 allowlist、remoteproc/cache 清理开关和审计; +- 引入或改编 GPLv3 参考源码前的合规确认。以上决策分别由 `01`—`06` 展开,本文只维持交叉约束。 + +## 8. `01`—`06` 覆盖与缺口清单 + +这里的“缺口”不是要求模仿 SimAdmin,而是 AI 重建前仍需由规格或 ADR 固化的选择。 + +### `01-esim-and-sim-identity.md` + +- [x] 覆盖 eSIM feature gate、lpac 供应、Euicc/Profile 模型、SIM identity/cache、profile enable、基带恢复、SMS resync、敏感字段和分阶段验收。 +- [x] 已在本文补齐实际 path ICCID、background enable、lpac 独立锁、cache table、周期 poll 与显式 resync 的转发差异证据。 +- [ ] 仍需最终定案:stable modem/SIM/eUICC fingerprint;重复 enable 的 operation 幂等键;把文档内 `SimSwitchCoordinator -> lpac -> Modem -> SMS` 局部 gate 明确嵌入第 3.2 节全局资源顺序;所有 wire status/error code 的 `lower_snake_case` 映射;`raw`、matching ID、SM-DP 和 ICCID 的落盘/返回/日志策略;合法重复 SMS 的去重窗口。 + +### `02-system-event-notifications.md` + +- [x] 覆盖事件信封、category/severity/status、阈值/hysteresis、规则/channel/quiet hours、队列重试、日志、SSE、隐私和测试。 +- [x] 已在本文补齐“SimAdmin emitter 是通知桥而非事实流”、实际路由、queue schema/worker、rate limit 与无事件历史 API 的证据。 +- [x] registry 路径已统一为 `GET /api/system-events/registry`;SQLite/REST 为事实源,SSE 只作加速。 +- [ ] 仍需最终定案:event ID/dedupe key/recovery correlation;`sending` lease 回收;SSE cursor/replay/retention;已渲染正文与原始事件字段的敏感数据边界;批量 retry/clear 的 `202 operation` 与通用 operation schema 对齐。 + +### `03-modem-watchdog-and-recovery.md` + +- [x] 覆盖探测、阈值、状态机、恢复阶梯、cooldown、路径漂移、SMS resync、用户意图、systemd helper 边界、事件和故障注入。 +- [x] 已在本文补齐精确阈值来源、仅在 baseband flag 时暂停、data-off 提前绕过注册恢复、boot helper 清 cache/remoteproc 的证据。 +- [ ] 仍需最终定案:主进程与 helper 的唯一 recovery lease;人工/自动恢复 admission 统一返回 `202 operation resource`(不是只返回裸 epoch);`modem.recovery`/`modem.control`/`modem.sms` 与 `system.lifecycle` 的全局锁顺序;hard recovery capability/allowlist;失败后 SMS reconcile 的完成条件;人工取消;`data disabled`、`airplane`、`maintenance` 三种意图的正交状态;重启后的 cooldown/run 恢复。 + +### `04-automation-scheduler.md` + +- [x] 覆盖 task/trigger/run 模型、时区、durable due slot/lease、资源锁、timeout/cancel/retry/idempotency、首批任务、API/UI、审计和测试。 +- [x] 已在本文补齐 reference 的 30 秒 loop、北京固定时区、fixed 内存 dedupe、interval 最后日志、无并发上限、reboot 过早成功和 SMS unknown-result 证据。 +- [x] `04` 已给出第 3.2 节同一套五级资源锁顺序;automation run 可作为长 action 的唯一 operation resource。 +- [ ] 仍需最终定案:DST/catch-up/misfire policy;run uniqueness key;lease 续租/回收;run wire state/error code 的 `lower_snake_case` 约束;危险 action 的批准与 maintenance window;outbound SMS provider correlation;definition 版本化和删除后的历史保留。 + +### `05-backup-and-restore.md` + +- [x] 覆盖 component archive/manifest、敏感数据、checksum、preview/apply、merge/replace、pre-restore、worker coordination、原子写入、CLI/API/UI、回滚和测试。 +- [x] 已在本文补齐 actual route、50 MiB body、component/schema、未加密 ZIP、展开限制、DB 先提交再写配置、pre-restore 范围和 local `fs::write` 证据。 +- [ ] 仍需最终定案:export/apply 的 domain operation 与通用 operation schema 对齐;quiesce/restart 同时需要 `storage.maintenance` 和 `system.lifecycle` 时遵守全局锁顺序;加密与密钥恢复;archive authenticity/signature;expanded bytes/entry count/ratio limits;一致性 snapshot 和 quiesce barrier;跨 SQLite+TOML/secret store 原子 cutover;自动 rollback 与旧版本 migration matrix。 + +### `06-cellular-network-management.md` + +- [x] 覆盖只读能力到危险 mutation 的分层、ModemManager/NetworkManager/QMI/mmcli/AT adapter、用户意图、并发/rollback、API/config/UI、测试和分阶段交付。 +- [x] 已在本文补齐 operator scan 实际为 GET、65 秒级等待/cache fallback、radio/band 的 D-Bus 真相、cell lock 仅内存、APN secret/path 风险、启动接管 NM 的证据。 +- [ ] 仍需最终定案:cellular domain operation 与通用 operation schema/`lower_snake_case` 状态对齐;安装后 NetworkManager 接管是否 opt-in;capability discovery 与真实 cell lock vendor adapter;APN password write-only/secret store;客户端 bearer path allowlist;配置先写还是设备先写及补偿;与 watchdog/automation/eSIM 的资源冲突矩阵和全局锁顺序。 + +## 9. AI 重建时的最小取证顺序 + +1. 先固定本文第 1 节 commit,并阅读目标功能对应的 `01`—`06`; +2. 从 `backend/src/main.rs:511-1015` 建立旧 API contract tests; +3. 用 `backend/src/models.rs`、相关 handler 和 `backend/src/config.rs`/`db.rs` 核对字段与持久化,不以 TypeScript 类型替代后端事实; +4. 用 `frontend/src/api/current.ts` 核对浏览器真实 method/query/timeout,再用页面代码确认交互期望; +5. 对所有 modem mutation 检查 `backend/src/modem_manager.rs`、`backend/src/serial.rs`、`backend/src/esim.rs` 和 systemd helper,建立 capability/权限/失败矩阵; +6. 将第 7、8 节的未决项转成 ADR 或可执行验收测试后再编码; +7. 行为兼容测试和源码复用合规是两条独立工作流,不应互相替代。 diff --git a/docs/specs/simadmin/01-esim-and-sim-identity.md b/docs/specs/simadmin/01-esim-and-sim-identity.md new file mode 100644 index 0000000..1d0324a --- /dev/null +++ b/docs/specs/simadmin/01-esim-and-sim-identity.md @@ -0,0 +1,735 @@ +# 功能 1:eSIM/eUICC 管理、SIM 身份缓存与 Profile 切换短信重同步 + +状态:设计 spec(不包含实现) +范围:SmsRelayed 单 ModemManager 调制解调器实例 +参考基线:`/tmp/SimAdmin` clean `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c` + +### 文档判读规则 + +- **已确认**:描述上述固定 commit 中可以由源码直接验证的行为。来源使用相对 `/tmp/SimAdmin` 的路径,并在第 17 节列出符号和行号。 +- **目标契约**:SmsRelayed 重建时必须实现的行为。第 5 节及之后若无“需决策”标记,均属于目标契约,而不是声称 SimAdmin 已经如此实现。 +- **重建建议**:给出适合 SmsRelayed 的默认值或内部结构;只要 API、状态机、不变量和验收结果等价,实现名称可以不同。 +- **需决策**:参考实现没有答案、且会影响兼容性或安全边界的产品选择。实现前必须关闭第 16.1 节中的决策项,不能把建议值误记成参考事实。 + +## 1. 目标与非目标 + +### 1.1 目标 + +在 SmsRelayed 现有 Rust/Tokio、ModemManager D-Bus、`mmcli`、SQLite、受保护 HTTP API 和 React 前端架构上增加: + +1. 可靠识别当前 eUICC/SIM、当前 Profile、ICCID、IMSI、本机号码和 SMSC,并按 SIM/Profile 缓存。 +2. 在已安装 lpac 且硬件支持的情况下,读取 eUICC 信息、列出/下载/重命名/删除 Profile,并启用指定 Profile。 +3. 把 Profile enable、基带恢复、ModemManager 重新枚举、Modem 路径重绑定和 SMS 重扫视为一个有界、可观察、可恢复的操作。 +4. 在 Profile 切换后,以新的稳定 Modem 身份和新的 SMS 对象路径重建短信监听,补扫 ModemManager 中已经存在但尚未落库的收到短信。 +5. 使所有可重试请求幂等,避免重复 enable、重复入库、重复转发和把旧 Modem 路径误当成新路径。 +6. 将 EID、ICCID、IMSI、本机号码、SMSC、SM-DP+ 和 Matching ID 的存储、API、日志和前端展示限制在管理员范围内。 + +### 1.2 非目标 + +- 不实现物理 eSIM/eUICC 芯片、ModemManager、QMI/MBIM 驱动或 lpac 本身。 +- 不把“eSIM 模式”误当作硬件切换;是否有 eUICC 由设备和 Modem 能力决定。 +- 不在本功能中实现运营商开户、资费管理、自动续费、远程 SIM provisioning 平台或 QR 码服务。 +- 不自动切换数据连接策略、NetworkManager profile 或 APN;恢复阶段只负责短信所需的 Modem 可用性,数据重连作为后续独立能力。 +- 不在后台持续轮询 lpac;普通状态读取优先使用缓存,显式 live 请求和 Profile 操作才调用 lpac。 +- 不为恢复失败自动启用旧 Profile。切换已经成功后,回滚 Profile 可能再次触发基带断连,必须由管理员明确操作。 +- 本次只写 spec,不修改 Rust、前端、数据库或配置文件。 + +## 2. 术语和不变量 + +| 术语 | 定义 | 关键不变量 | +| --- | --- | --- | +| eUICC | 支持远程 Profile 的物理芯片 | EID 标识芯片,不等于当前 Profile | +| Profile | eUICC 上的运营商订阅 | ICCID 是 enable/delete 等操作的目标键 | +| SIM identity | 当前 SIM/Profile 的观察结果 | 至少以 `modem_fingerprint + ICCID` 隔离不同卡/Profile | +| Modem fingerprint | 由 SmsRelayed 现有 `ModemService` 从设备/设备标识计算的稳定指纹 | 运行时 D-Bus path 变化不能改变它 | +| runtime path | 当前可读 SMS 的 ModemManager D-Bus path | 可随重新枚举变化,不能作为持久身份 | +| action path | 已验证、允许执行 ModemManager 控制操作的 path | 必须和目标 fingerprint 匹配 | +| SMS resync | 在新 SIM identity/path 上重新扫描已有 SMS 对象 | 依赖现有消息持久化去重,不以对象 path 去重 | +| 切换代次 | 一次成功确认的新 identity 观察周期 | 一个代次最多执行一次同条件重扫 | + +以下不变量必须始终成立: + +- 没有通过稳定设备身份核验时,不得对重新枚举后的 Modem 执行控制操作。 +- lpac enable 成功但基带恢复失败时,状态必须明确为“Profile 已可能切换、恢复未完成”,不能报告为完全成功。 +- 当前 Profile 的身份缓存不能覆盖另一个 ICCID 的缓存;空的本机号码/SMSC 结果也可以是合法的负缓存,但必须有时间戳和来源。 +- SMS 重扫必须复用现有 `Messaging::receive`/`Store` 的持久化和去重边界,不直接绕过转发策略写数据库。 + +## 3. SimAdmin 当前行为及来源文件 + +以下是从固定参考基线读取到的**已确认**现状,作为行为参考,不作为对 SmsRelayed 的现成依赖。 + +| 主题 | 当前行为 | 来源 | +| --- | --- | --- | +| 功能开关 | `work_mode = esim` 只开启 eSIM UI/API/lpac 调用;不会改变板卡硬件。普通 SIM 模式隐藏 eSIM 页并对 `/api/esim/*` 返回 403。 | `README.md`;`backend/src/config.rs`;`backend/src/esim.rs` | +| eSIM 进程边界 | `EsimSupervisor` 以一个 `lpac_lock` 串行化 lpac status、chip info、profile list、enable、nickname、delete、download 和 repair。没有后台 lpac worker。 | `backend/src/esim.rs` | +| lpac 状态 | 检查架构/glibc、私有 lpac 路径和 `driver list`;QMI APDU 与 curl HTTP driver 都可用才算 usable。 | `backend/src/esim.rs` | +| eUICC | `chip info` 有 20 秒超时;EID、厂商、内存、状态规范化后缓存。可用配置补充自定义总内存。 | `backend/src/esim.rs`;`backend/src/handlers.rs`;`backend/src/db.rs` | +| Profile 列表 | `profile list` 有 20 秒超时;规范化 ICCID、名称、运营商、state、class、IMSI、MSISDN、SMSC、SM-DP+、MCC/MNC、权限;Matching ID 从缓存补齐。 | `backend/src/esim.rs`;`backend/src/handlers.rs` | +| Profile enable | 先做 5 秒 preflight;目标已 active 时跳过;调用 `profile enable `,默认 refresh=1,遇到宽泛的可重试字符串后等待 800ms、刷新列表,必要时以 refresh=0 重试。操作在后台任务中运行。 | `backend/src/handlers.rs`;`backend/src/esim.rs` | +| enable 后恢复 | enable 成功后停止 ModemManager,寻找 QMI 设备,执行 SIM power off/on,重新启动 ModemManager,轮询重新枚举、Modem state 和注册;注册超时只记 warning。 | `backend/src/modem_manager.rs`;`backend/src/handlers.rs` | +| 路径变化 | 恢复前后重新发现 Modem path;旧 path 不被假设仍有效。`find_modem_path` 会选择排序后的首个 path,并在空列表时触发 `mmcli --scan-modems`;它不核验稳定设备身份。 | `backend/src/modem_manager.rs` | +| SIM identity | 从 ModemManager SIM/3GPP 属性读取 ICCID、IMSI、运营商标识;本机号码和 SMSC 通过 ModemManager、QMI/MBIM、AT、EF_SMSP 等多路径探测。 | `backend/src/modem_manager.rs` | +| identity 缓存 | `smsc_cache` 优先以 ICCID、缺失时以 IMSI/operator key 缓存;`own_number_cache` 只在 ICCID 存在时写入。Profile/eUICC 另有 SQLite cache,profile 详情缺 MSISDN/SMSC 时从当前 identity cache 补齐。 | `backend/src/modem_manager.rs`;`backend/src/handlers.rs`;`backend/src/db.rs` | +| SMS 重同步 | `SmsResyncHandle::request_scan(reason)` 通过无界 channel 通知单一 SMS listener;listener 还每 15 秒扫描一次。切换成功和恢复失败都会请求扫描。参考实现没有 run id、计数、deadline 或合并键。 | `backend/src/state.rs`;`backend/src/sms_listener.rs`;`backend/src/handlers.rs` | +| 下载 | `profile download` 最长 120 秒;下载后读取规范化 Profile,失败时等待并多次 list,通过新 ICCID 差集补缓存;SM-DP+/Matching ID 会写入 profile cache。 | `backend/src/esim.rs`;`backend/src/handlers.rs`;`backend/src/db.rs` | +| lpac 供应链 | 安装器按架构/glibc 选择私有 lpac,检查 qmi/curl 和动态库;可探测 `/dev/cdc-wdm*`、`/dev/wwan*qmi*`,支持 `LPAC_APDU_QMI_DEVICE`;OTA 包不必包含或升级 lpac。 | `docs/environment.md`;`docs/install.md`;`backend/src/esim.rs` | +| 前端 | `EsimManager.tsx` 先读缓存再读 live;Profile enable 显示确认和基带恢复进度,每秒轮询 restart status;Profile 详情显示 ICCID、MSISDN、SMSC、IMSI、MCC/MNC、SM-DP+、Matching ID、ISDP-AID 和权限。 | `frontend/src/pages/EsimManager.tsx`;`frontend/src/api/current.ts` | +| 硬件边界 | “eSIM mode”只是功能/UI gate;物理 eUICC、QMI APDU、UIM slot、ModemManager 和运营商网络必须真实支持。 | `README.md`;`docs/environment.md` | + +SimAdmin 的几个实现教训也应保留为约束:lpac 输出可能包含敏感 raw JSON,不能直接复制到 SmsRelayed;Profile enable、基带恢复和 SMS scan 不能只靠彼此独立的锁;重新枚举后必须重新验证稳定设备身份;空结果需要区分“已探测为空”和“尚未探测”。 + +### 3.1 已确认的 SimAdmin HTTP 兼容事实 + +SimAdmin 所有响应使用 `{ "status": "ok|error", "message": string, "data"?: T }` envelope。以下是参考实现的真实路由,而不是第 8 节的目标 API: + +| 方法与路径 | 请求 | 成功响应/异步语义 | 已确认错误语义 | +| --- | --- | --- | --- | +| `GET /api/esim/lpac/status` | 无 | `200`,`data={installed,usable,path,arch,glibc_version,asset_name,message,source?}` | 非 eSIM mode 为 `403`;binary 不可用为 `503`;lpac command 类错误可能仍是 `200 + status=error` | +| `GET /api/esim/euicc?live=1` | `live` 缺省 false;无 live 时优先返回最新 cache | `200`,`data=EsimEuiccInfo`,其中包含 `raw` | 与上相同 | +| `GET /api/esim/profiles?cached=1` | `cached` 缺省 false;不是 `live` 参数 | `200`,`data={profiles:[EsimProfile]}` | cache 读取失败仍为 `200 + status=error` | +| `POST /api/esim/profiles/{iccid}/enable` | 空 JSON 可接受;ICCID 位于 URL | handler 立即返回 `200` 和“task started”,随后 `tokio::spawn`;没有 operation id、Idempotency-Key 或 per-request result | 后台 enable/recovery 失败不改变已返回的 HTTP 响应;结果只能从全局 baseband progress/通知推断 | +| `GET /api/baseband/restart/status` | 无 | `200`,`data={steps,running,current_registration?}`;这是进程内单例进度,不是持久化 operation | 查询本身不表达 enable 的最终业务状态 | +| `POST /api/esim/profiles/{iccid}/rename` | `{ "name": string }` | 同步 `200` + `EsimCommandResponse` | 空 name 为 `400`;lpac command failure 仍可能是 `200` envelope | +| `DELETE /api/esim/profiles/{iccid}` | 无 confirm body | 同步 `200`;命令成功后删除 cache | lpac command failure 仍可能是 `200` envelope | +| `POST /api/esim/profiles` | `{smdp,matching_id,confirmation_code?,imei?}` | 最长等待 lpac download;`200` + command response | provisioning 值参与缓存回填;业务失败多在 envelope/command code 表达 | +| `POST /api/esim/lpac/repair` | `{proxy_prefix?,asset_url?}` | 最长 120 秒,成功 `200` | 不可用/下载/安装错误按 `EsimApiError` 分流;自定义 asset URL 被接受 | + +为兼容既有客户端可以保留一层 legacy adapter,但目标实现的唯一权威契约是第 8 节:敏感 ICCID 放 body、异步动作返回 `202 + operation_id`,业务错误使用真实 4xx/5xx。legacy adapter 也必须返回相同 operation 的引用,不能另起第二次 enable。 + +### 3.2 已确认的持久化与并发局限 + +- `esim_profile_cache` 以 ICCID 全局主键保存 IMSI、MSISDN、SMSC、SM-DP+、Matching ID 和 raw-derived 字段;没有 modem fingerprint/EID namespace、TTL 或加密。`esim_euicc_cache` 还保存 `raw` JSON。 +- SMSC/本机号码 cache 接受空字符串/空列表作为已探测结果,但没有 `expires_at`;读取无法区分 fresh 与 stale。Profile cache 的 `COALESCE` upsert 会保留旧的非空敏感字段。 +- `lpac_lock` 只串行 lpac 进程;基带恢复依赖另一把全局 serial lock,SMS resync 又是 channel。enable handler 没有 compare-and-set admission,多个请求可能先后排队 lpac、并共享同一个全局 progress buffer。 +- `find_modem_path` 选排序后的第一个 Modem;QMI fallback 也选预设/排序后的第一个设备节点。参考实现没有 fingerprint 绑定,不能把这个选择算法当成目标安全行为。 +- `run_lpac_command` 会把无法解析的完整 stdout 拼入错误,恢复命令还可能把 stdout/stderr 放入 progress/detail;这些都是需要修正的隐私边界,而不是要复刻的输出契约。 + +## 4. SmsRelayed 当前能力和缺口 + +### 4.1 已有能力 + +- `src/dbus.rs`、`src/dbus/inbound.rs` 和 `src/dbus/outbound.rs` 已提供系统 D-Bus SMS 接收/发送抽象、对象管理器快照、信号订阅和超时边界。 +- `src/inbound.rs` 的 `InboundWorker` 会解析配置的 Modem path,订阅初始 SMS 和后续信号;监听断开后按退避重连,并通过 `ModemService` 的 runtime/action targets 区分可读 path 与可控 path。 +- `src/modem.rs` 的 `ModemService` 已有 mmcli 能力探测、健康缓存、action lock、path drift candidate、设备身份提取、fingerprint 和启用/禁用/重置动作。 +- `src/persistence` 的 `Store`/SQLite migrations 已保存消息、入站去重、转发状态和 Modem identity/fingerprint 元数据。 +- `src/messaging.rs`、`src/inbound.rs` 已有统一的入库、转发、重试和 profile routing 边界;新增 reconcile 不应另写一套持久化路径。 +- `src/api/mod.rs` 已有认证 API、消息/配置/服务/Modem 路由;`src/api/modem.rs` 已规定 action 进行中返回 409、需要已验证 action path,并有 reset 限流。 +- `src/events.rs` 已有广播总线,但事件类型目前主要是消息、配置和服务事件;没有 eSIM、SIM identity、基带恢复或重扫事件。 +- `src/config.rs` 已有 `app.modem_path`、SMS、delivery、forwarding、HTTP 等 TOML 配置,配置由 `Arc` 提供给 API/worker;现有结构没有 eSIM/identity/cache section。 +- `src/modem.rs` 的 `ModemDetails` 目前只有有限的 Modem 状态和一个 `own_number`,没有 EID、ICCID、IMSI、MSISDN、SMSC、Profile 列表。 + +### 4.2 缺口 + +1. 没有 lpac runner、eUICC/Profile domain model、QMI APDU 配置或 LPAC 供应链检查。 +2. 没有 SIM identity、MSISDN、本机号码和 SMSC 的按 Profile 缓存;当前消息库也没有这些字段。 +3. 没有 Profile enable 的全局操作协调器。现有 `ModemService.action_lock` 只覆盖已有 mmcli action,不覆盖 lpac、QMI power-cycle、入站 resync 或 eSIM download/repair。 +4. 没有“停止 ModemManager—SIM power-cycle—重新枚举—恢复监听”的基带恢复能力。 +5. `InboundWorker` 没有外部 resync channel;初始扫描/信号订阅在 path 变化后可重建,但没有由 Profile 切换明确触发的 reconcile generation。 +6. 当前去重命名空间和运行时 fingerprint 能处理 Modem identity 变化,但新的 SMS object path 不能成为唯一去重依据;需为重扫定义跨 path 的稳定 key。 +7. 没有 eSIM/SIM API、前端页面、进度事件和操作持久化。 +8. 当前 health API 设计会主动隐藏 SIM ID/电话号码/SMS body;新增字段不能进入 public health、普通 metrics、URL access log 或未授权 SSE。 + +## 5. 推荐领域模型 + +建议新增一个以服务为边界的 `SimAdminService`(名称可按仓库惯例调整),内部由 `LpacClient`、`SimIdentityReader`、`BasebandRecovery`、`SimSwitchCoordinator` 和 `SmsResyncCoordinator` 组成。它们共享 `ModemService` 的稳定身份和 action target,不复制一套 Modem 选择逻辑。 + +### 5.1 `ModemBinding` + +```text +ModemBinding { + modem_fingerprint: String, // 现有 Store/ModemService 的稳定指纹 + configured_path: String, // AppConfig.app.modem_path + runtime_path: String, // 当前 D-Bus path,可变 + action_path: Option, // 已验证后才能控制 + qmi_device: Option, // 与 D-Bus path 分开管理 + observed_at: DateTime, +} +``` + +`runtime_path`、`action_path` 和 QMI character device path 都是运行时定位信息,不作为 SIM identity 的主键。重新枚举后只替换 runtime/action binding,不修改用户的 configured path。 + +### 5.2 `SimIdentity` + +```text +SimIdentity { + modem_fingerprint: String, + eid: Option, + iccid: Option, + imsi: Option, + msisdn: Vec, + smsc: Option, + operator_id: Option, + source: IdentitySource, + observed_at: DateTime, + expires_at: DateTime, +} +``` + +- ICCID 只保留数字并统一规范化;电话号码/SMSC 使用 E.164 或已有 ModemManager 可接受格式规范化。 +- `identity_key` 使用带 domain separation 的稳定摘要,建议由 `modem_fingerprint + eid + iccid` 计算;缺失字段不能用另一个 Profile 的值填补。 +- 同一个 eUICC 的多个 Profile 允许各有独立 identity cache;当前活跃 Profile 由当前 ICCID 和 ModemManager 状态确认,而不是由列表顺序推断。 +- `source` 至少区分 `modemmanager`、`qmi`、`mbim`、`at`、`ef_sms`、`manual`、`cache`;API 返回来源和 age,便于诊断冲突。 + +### 5.3 `EsimProfile` 和 `Euicc` + +Profile 字段分为: + +- 操作必需:ICCID、normalized state、profile class、enable/disable/delete capability。 +- 展示/身份:name、provider、IMSI、MSISDN、SMSC、MCC、MNC、ISDP-AID。 +- provisioning:SM-DP+ 和 Matching ID;默认不持久化明文,见第 8 节。 + +`Euicc` 包含 EID、manufacturer、state、memory total/available、`updated_at` 和 capability flags。lpac 未提供的字段为 `unknown`,不得从旧 Profile 猜测。 + +Profile state 建议统一为 `active`、`inactive`、`disabled`、`error`、`unknown`;保留 lpac 原始数值/字符串作为受限诊断字段,但不把 raw JSON 原样返回。 + +### 5.4 `EsimSwitchOperation` + +```text +EsimSwitchOperation { + operation_id: UUID, + idempotency_key_hash: String, + modem_fingerprint: String, + target_iccid: String, + previous_iccid: Option, + phase: Preflight | LpacEnable | ModemRecovery | Rebind | SmsResync | Succeeded | Failed | Degraded, + status: Queued | Running | Succeeded | Failed | Degraded, + previous_path: Option, + current_path: Option, + resync_run_id: Option, + started_at: DateTime, + finished_at: Option>, + error_code: Option, +} +``` + +`target_iccid` 只在受保护存储中保存;日志和事件使用 operation ID 与摘要。`Degraded` 表示 lpac enable 已返回成功或新 Profile 已确认 active,但 Modem 恢复/监听重绑定未完成。 + +## 6. 配置建议 + +在 `src/config.rs` 的现有 TOML serde 结构中添加带默认值的 section,不引入第二套 JSON 配置,也不要求修改现有 `app.modem_path` 语义。建议配置如下,数值是初始默认值: + +```toml +[esim] +enabled = false +lpac_path = "/opt/sms-relayed/lpac/lpac" +qmi_device = "" # 空值表示按受限探测规则寻找 +qmi_uim_slot = 1 +lpac_http_driver = "curl" +lpac_apdu_driver = "qmi" +allow_lpac_repair = false # 安装/升级仍需显式管理员动作 +profile_list_timeout_secs = 20 +profile_enable_timeout_secs = 60 +profile_download_timeout_secs = 120 +switch_overall_timeout_secs = 240 +recovery_timeout_secs = 120 + +[sim_identity] +live_timeout_secs = 3 +cache_ttl_secs = 900 +negative_cache_ttl_secs = 300 +manual_cache_enabled = false + +[sms_resync] +enabled = true +forward_reconciled = false +reconcile_window_secs = 86400 +resync_timeout_secs = 60 +``` + +实现时应: + +- 对已有配置提供 serde default,旧配置无需手工改写;缺失 `esim.enabled` 时保持关闭。 +- `lpac_path`、QMI device、UIM slot 和 timeout 在启动时做语法校验;路径必须是绝对路径、不可包含 shell 片段。 +- 不把 Matching ID、confirmation code、API token 放进长期配置;下载请求只接收 HTTPS API body 中的短生命周期值。 +- `esim.enabled` 是功能 gate,不代表设备有 eUICC;硬件不满足时 API 返回 `esim_unavailable`。 +- 配置保存沿用现有服务重启/配置事件语义;正在运行的切换操作不因普通配置 reload 改变 timeout 或设备目标。 + +## 7. SQLite 数据模型 + +SmsRelayed 当前由 `src/persistence`/SQLite migrations 管理消息和 delivery 状态;本功能应新增 migration,不在启动代码中散落 `CREATE TABLE IF NOT EXISTS`,也不改写既有消息表的历史数据。 + +### 7.1 `sim_identity_cache` + +```sql +CREATE TABLE sim_identity_cache ( + identity_key TEXT PRIMARY KEY, + modem_fingerprint TEXT NOT NULL, + eid TEXT, + iccid TEXT, + imsi TEXT, + msisdn_json TEXT NOT NULL DEFAULT '[]', + smsc TEXT, + operator_id TEXT, + source TEXT NOT NULL, + observed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (modem_fingerprint, eid, iccid) +); +CREATE INDEX sim_identity_cache_current_idx + ON sim_identity_cache(modem_fingerprint, iccid, updated_at DESC); +``` + +SQLite 的 `NULL` unique 语义需要 migration/写入层明确处理缺失 EID/ICCID,不能依赖这条 unique 约束完成去重。空号码/SMSC 必须以有效的 cache row 表示;读取时按 `expires_at` 区分 fresh、stale、negative 和 missing。 + +### 7.2 `esim_euicc_cache` + +```sql +CREATE TABLE esim_euicc_cache ( + cache_key TEXT PRIMARY KEY, + modem_fingerprint TEXT NOT NULL, + eid TEXT NOT NULL, + manufacturer TEXT, + status TEXT, + memory_total_kb INTEGER, + memory_available_kb INTEGER, + memory_total_customizable INTEGER NOT NULL DEFAULT 0, + capabilities_json TEXT NOT NULL DEFAULT '{}', + source TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +不要保存 lpac 原始 stdout/stderr;若确实需要诊断,保存经过 allowlist 和脱敏的 JSON,并设置短保留期。 + +### 7.3 `esim_profile_cache` + +```sql +CREATE TABLE esim_profile_cache ( + iccid TEXT PRIMARY KEY, + modem_fingerprint TEXT NOT NULL, + eid TEXT, + name TEXT, + provider TEXT, + profile_class TEXT, + state TEXT NOT NULL, + imsi TEXT, + msisdn TEXT, + smsc TEXT, + mcc TEXT, + mnc TEXT, + isdp_aid TEXT, + disable_allowed INTEGER NOT NULL DEFAULT 0, + delete_allowed INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX esim_profile_cache_modem_idx + ON esim_profile_cache(modem_fingerprint, state, updated_at DESC); +``` + +SM-DP+/Matching ID 不放在默认 profile cache;如产品确实要求离线显示,必须使用独立加密 secret store/密钥,并在备份、导出和 API 中明确排除。 + +### 7.4 `esim_switch_operations` 和 `sms_resync_runs` + +```sql +CREATE TABLE esim_switch_operations ( + operation_id TEXT PRIMARY KEY, + idempotency_key_hash TEXT NOT NULL, + modem_fingerprint TEXT NOT NULL, + target_iccid TEXT NOT NULL, + previous_iccid TEXT, + phase TEXT NOT NULL, + status TEXT NOT NULL, + previous_path TEXT, + current_path TEXT, + resync_run_id TEXT, + error_code TEXT, + error_detail_redacted TEXT, + started_at TEXT NOT NULL, + finished_at TEXT +); +CREATE UNIQUE INDEX esim_switch_idempotency_idx + ON esim_switch_operations(modem_fingerprint, idempotency_key_hash); + +CREATE TABLE sms_resync_runs ( + run_id TEXT PRIMARY KEY, + operation_id TEXT, + reason TEXT NOT NULL, + identity_key TEXT, + modem_fingerprint TEXT NOT NULL, + path_before TEXT, + path_after TEXT, + scanned_count INTEGER NOT NULL DEFAULT 0, + persisted_count INTEGER NOT NULL DEFAULT 0, + forwarded_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + error_code TEXT, + started_at TEXT NOT NULL, + finished_at TEXT +); +``` + +操作表用于重启/前端刷新后显示最后状态,不用于恢复一个不确定的外部 lpac 操作。启动时只能把 `Queued/Running` 标为 `unknown`/`degraded`,再做安全的只读 profile/identity probe;不得自动重复 enable。 + +### 7.5 与现有消息去重的关系 + +- 重扫必须调用现有 `Messaging::receive`,让已有消息表、delivery 和 forwarding profile 逻辑继续生效。 +- 对 reconcile 生成一个不依赖 D-Bus SMS object path 的 `reconcile_dedupe_key`,至少基于 `modem_fingerprint + received timestamp + normalized sender + normalized body + storage` 的 domain-separated hash;若 Modem 提供稳定 SMS ID,优先加入稳定 ID。 +- 旧消息已存在时只增加 `last_seen`/resync 统计,不重新创建消息和 delivery。 +- 默认 `forward_reconciled = false`:重扫的未知短信落库但不把历史 SMS 批量转发。若开启,只有本次新落库且通过现有 forwarding policy 的记录才允许进入 delivery queue。 +- 日志只输出计数、operation ID、hash 前缀和错误 code,不输出 body、完整号码或对象 path。 + +## 8. API 与前端交互 + +### 8.1 API + +沿用 `src/api/mod.rs` 的 protected/authenticated router、`ApiError` 和 HTTP 状态码风格;所有新 API 均禁止挂到 public health。 + +目标 API 不沿用 SimAdmin 的 `status/message/data` envelope。成功直接返回资源 JSON;错误严格沿用 SmsRelayed 当前结构: + +```json +{ + "error": { + "code": "profile_not_found", + "message": "target profile was not found" + } +} +``` + +`message` 必须是可展示、已脱敏的稳定文案;客户端只按 `code` 和 HTTP status 分支。异步写操作响应统一包含 `operation_id`、`status`、`phase` 和相对 `poll` URL;查询 operation 不需要客户端保留敏感目标值。 + +| 方法 | 路径 | 成功契约 | 主要错误 | +| --- | --- | --- | --- | +| GET | `/api/sim/identity?live=false&reveal=false` | `200 SimIdentityView`,含 `cache_state=fresh|stale|negative|missing`、`age_seconds`、逐字段 source/observed_at;`reveal=false` mask 敏感值 | `400` 参数;`503 modem_unavailable`;live deadline 为 `504 identity_probe_timeout` | +| POST | `/api/sim/identity/refresh` | body `{}`;同步能在 live deadline 内完成时 `200 SimIdentityView`,否则如实现为后台 operation 则 `202` | 已有刷新可返回相同 operation;请求不等价时 `409 operation_in_progress` | +| GET | `/api/esim/lpac/status` | `200 LpacStatusView`,返回安装、架构、driver、可用性和 safe reason code | gate 关闭 `403 esim_disabled`;不可用本身仍可是 `200 usable=false`,probe 执行失败为 `503` | +| GET | `/api/esim/euicc?live=false&reveal=false` | `200 EuiccView`;cache miss 且 `live=false` 返回 `200` + `cache_state=missing`,不隐式调用 lpac | live deadline `504 lpac_timeout`;硬件不支持 `503 esim_unavailable` | +| GET | `/api/esim/profiles?live=false&reveal=false` | `200 {items:[EsimProfileView],cache_state,updated_at}`;`live=true` 才调用 lpac,并由后端标记 active | 同上;解析失败 `502 lpac_invalid_response` | +| POST | `/api/esim/profile-actions/enable` | body `{ "iccid": "...", "confirm": true }`;要求 `Idempotency-Key`,返回 `202 OperationAccepted`;已 active 返回 `200` 的 terminal operation view | `400 invalid_sensitive_input`;`404 profile_not_found`;`409` action/idempotency conflict;`503/504` preflight | +| POST | `/api/esim/profile-actions/download` | body `{smdp,matching_id,confirmation_code?,imei?,confirm:true}`;`202 OperationAccepted`,响应永不回显凭据 | `400` validation;`409` conflict;网络/lpac 故障作为 operation final error;只有 admission 失败直接 4xx/5xx | +| POST | `/api/esim/profile-actions/nickname` | body `{iccid,name}`;可同步 `200 EsimProfileView` 或统一 `202`,但选定后须固定;与 enable 共享 gate | `400/404/409/503` | +| POST | `/api/esim/profile-actions/delete` | body `{iccid,confirm:true}`;`202 OperationAccepted`;active profile 拒绝 | `400` confirm/validation;`404`;`409 active_profile|operation_in_progress` | +| GET | `/api/esim/operations/{operation_id}` | `200 EsimSwitchOperationView`,返回 phase/status/step timestamps、path 是否 verified、resync 统计和 safe next action | `404 operation_not_found`;无权访问用既有认证错误 | +| GET | `/api/sms/resync/runs/{run_id}` | `200 SmsResyncRunView`,返回计数、status、identity generation digest 和 error code | `404` | +| POST | `/api/sms/resync` | body `{reason:"manual"}` + `Idempotency-Key`;`202 {run_id,status}`;同一 fingerprint + generation 的相同请求返回同一 run | `409` 不等价冲突;`503` listener unavailable | +| GET | `/api/events` | 增加 `esim.operation.updated`、`sim.identity.updated`、`sms.resync.updated`,事件只携带 ID、阶段、状态、计数和脱敏摘要 | 继续使用现有 session/SSE 错误语义 | + +建议错误码:`esim_disabled`(403)、`esim_unavailable`、`lpac_unusable`、`lpac_timeout`、`profile_not_found`、`profile_already_active`、`modem_action_in_progress`、`modem_identity_unverified`、`baseband_recovery_failed`、`sms_resync_failed`、`operation_in_progress`(409)、`idempotency_conflict`(409)、`invalid_sensitive_input`(400)。lpac stderr 只进入内部 redacted detail,不能成为 API message 原文。 + +`profile_already_active` 不作为错误返回;它只能作为 terminal operation 的 `result_code`,HTTP 为 200。`baseband_recovery_failed` 和 `sms_resync_failed` 通常是已接受 operation 的最终状态,poll 返回仍为 200 且 `status=degraded|failed`;它们只在同步 recovery-only 请求无法 admission 时映射 503。这样客户端不会把“查询成功”与“操作成功”混为一谈。 + +启用请求返回示例: + +```json +{ + "operation_id": "uuid", + "status": "queued", + "phase": "preflight", + "poll": "/api/esim/operations/uuid" +} +``` + +### 8.2 前端 + +在当前 `frontend/` 的 React + TanStack Router + Tailwind/shadcn 结构中新增 eSIM/SIM 管理页;不要复制 SimAdmin 的 MUI 页面,也不要手写 `routeTree.gen.ts`。新增路由后使用现有 route generator 生成。 + +页面交互: + +1. SIM identity 卡片显示当前/缓存状态、更新时间、来源和脱敏 EID/ICCID/IMSI/MSISDN/SMSC;“刷新”是显式 live 请求,并显示单独 timeout/不可用原因。 +2. Profile 列表先加载缓存,再按用户点击或过期状态加载 live;active profile 由后端标记,前端不自行根据列表顺序决定 active。 +3. enable 按钮必须先显示“会造成 ModemManager 短暂不可用并触发短信重扫”的确认;active profile 的重复点击显示幂等成功,不启动基带恢复。 +4. enable 后切换到 operation progress:preflight、lpac enable、Modem recovery、重新绑定、SMS resync。前端可用 operation polling,SSE 只作加速通知;刷新页面后根据 operation ID 恢复显示。 +5. `Succeeded`、`Failed`、`Degraded` 分开呈现。`Degraded` 必须提示“Profile 可能已切换,请检查 Modem/短信监听”,提供只读 refresh/recovery retry,不提供自动旧 Profile 回滚。 +6. Matching ID、confirmation code 等 provisioning 字段默认不展示;复制按钮必须是显式管理员动作且不写剪贴板日志。全量号码只在受保护详情中显示,列表始终 mask。 +7. lpac 不可用时显示硬件/依赖诊断,不显示可执行命令的原始 stderr;repair/install 若纳入后续阶段,必须单独确认和权限。 + +## 9. Profile enable 工作流 + +### 9.1 并发控制和锁顺序 + +新增一个按 `modem_fingerprint` 的 `SimSwitchCoordinator`,同时保护以下完整区段: + +```text +preflight -> lpac enable -> ModemManager/QMI recovery -> identity rebind -> SMS resync +``` + +要求: + +- 同一 fingerprint 同时最多一个 Profile enable、download、delete、rename、lpac repair 和 baseband recovery;读 cache 可并发,live profile list 只能与写操作按 lpac gate 排队。 +- `/api/modem/*` 的 reset/enable/disable 和本工作流共享一个 Modem action gate;冲突立即 409,不排队执行。 +- 锁顺序固定为 `SimSwitchCoordinator -> lpac gate -> Modem action/recovery gate -> SMS resync gate`;任何回调不得反向取得外层锁,避免 D-Bus/SMS listener 死锁。 +- `InboundWorker` 的正常信号处理不应被全局暂停;在 recovery 期间暂时丢失的信号由后续 reconcile 补齐。重扫请求可合并但不能丢失。 + +### 9.2 正常流程 + +1. **Admission**:校验管理员、`Idempotency-Key`、ICCID 为纯数字且长度合理、confirm=true、eSIM enabled、设备 fingerprint 已验证;记录 operation row。 +2. **Preflight**:在 5 秒内读取 profile list 和当前 identity。目标不存在则结束失败;目标已经 active 且 ICCID 与当前 identity 一致则直接成功,`recovery_skipped=true`。 +3. **依赖预检**:检查 lpac binary 可执行、架构/glibc、QMI APDU/curl driver、QMI device/UIM slot 和 ModemManager 状态;未通过时不要调用 enable。 +4. **lpac enable**:调用无 shell 的进程 runner,参数为 `profile enable `;单次硬超时 60 秒,进程及子进程必须可终止,stderr 截断并脱敏。 +5. **安全重试**:仅对明确的 transient/refresh-required marker 重试。等待约 800ms 后刷新 profile list;仍未 active 才以 refresh=0 再调用一次。非幂等/认证/目标不存在/硬件错误不重试。 +6. **确认 Profile**:lpac 返回成功后再 list,确认目标 active 或至少确认当前 identity ICCID 已变为目标。无法确认时进入 `Failed`/`Degraded`,不得直接假定成功。 +7. **基带恢复**:进入第 9.4 节的 bounded recovery;enable 之后不再执行旧 Profile 回滚。 +8. **重新绑定**:按 fingerprint 重新发现 ModemManager path 和 QMI device,更新 runtime/action targets;只有 identity 与切换前记录匹配或明确表现为目标 ICCID 时才信任新 path。 +9. **身份刷新**:读取目标 ICCID 的 IMSI、MSISDN、SMSC,写入 identity/profile cache;探测为空的结果也按 negative cache 保存。 +10. **短信重扫**:向 `SmsResyncCoordinator` 提交 `profile-switch`,执行第 9.5 节流程;重扫失败时 operation 为 `Degraded` 而不是假成功。 +11. **完成**:写入 step counts、new path 是否 verified、run ID 和结束状态,广播脱敏事件。 + +### 9.3 超时 + +| 阶段 | 默认超时 | 超时结果 | +| --- | ---: | --- | +| lpac driver probe | 3 秒 | `lpac_unusable` | +| profile preflight/list | 5 秒(普通 list 20 秒) | 失败,不进入 power-cycle | +| lpac enable 单次 | 60 秒 | 只按安全规则重试一次,否则失败 | +| 停止 ModemManager | 10 秒 | 进入 degraded,记录恢复失败 | +| QMI device 重新发现 | 15 秒 | 不使用旧 device path,恢复失败 | +| SIM power off/on | 每步 5 秒,必要 polling | 恢复失败 | +| 启动 ModemManager | 20 秒 | 恢复失败 | +| 新 Modem path/identity | 30 秒 | 恢复失败 | +| Modem ready/短信接口 | 30 秒 | 可标记 degraded,继续有限 resync 尝试 | +| SMS resync | 60 秒 | operation degraded,后台 listener 仍可继续重连 | +| 整体切换 | 240 秒硬 deadline | 取消剩余步骤并保存最终 phase;不得无限等待 | + +所有等待用 deadline + bounded polling(例如 250ms–1s 间隔);不使用无法取消的固定长 sleep。外部命令必须设置 stdout/stderr 上限、环境白名单和 kill-on-drop。 + +### 9.4 Modem 路径变化和基带恢复 + +SmsRelayed 现有 `ModemService` 已区分 configured/runtime/action target 和 fingerprint;本流程必须复用该边界: + +- 操作开始保存 `old_fingerprint`、`old_runtime_path`、`old_action_path`、旧 ICCID 和旧 QMI device。 +- 停止 ModemManager 后旧 D-Bus path 立即视为失效;不能把旧 path 传给后续 `mmcli`/D-Bus 调用。 +- QMI device path 与 D-Bus path 独立变化;优先使用显式 `esim.qmi_device` 或稳定 udev 名称,其次只在唯一设备且能与 fingerprint 关联时探测 `/dev/cdc-wdm*`、`/dev/wwan*qmi*`。不能因为“第一个设备”就执行 power 操作。 +- 启动 ModemManager 后重新枚举所有候选,读取设备标识并与 `old_fingerprint` 比对;只有唯一匹配时设置 runtime/action targets。若旧设备 fingerprint 变化,保留 runtime-only,禁止控制动作并返回 `modem_identity_unverified`。 +- 新 path 不写回 `app.modem_path`;它只是本次 runtime binding。持久配置仍是用户选择的目标,需管理员显式修正。 +- 注册网络不是短信重扫的必要条件;只需 Modem 可用、SIM ready、messaging interface 可读。若现有设备实现要求注册才可读,最多等待配置的 deadline,不因网络注册警告无限重试。 +- 恢复失败后的安全动作是尝试一次有界的 ModemManager restart/重新发现,保存 `Degraded`,提供手工 retry recovery。绝不自动切回旧 Profile。 + +### 9.5 SMS 重扫和监听重绑定 + +新增 `SmsResyncHandle`/channel,接入 `InboundWorker`,而不是从 API 直接创建第二个 D-Bus listener: + +1. 请求携带 `reason=profile-switch`、operation ID、目标 identity key 和目标 fingerprint。 +2. coordinator 让 listener 重新调用现有 `resolve_monitor_path`,取得新的 runtime path;若 path 尚未可读则按 5 秒起始、60 秒上限退避重试。 +3. 当 path 变化时,关闭旧订阅、订阅新 path 的 Added/PropertiesChanged,并先获取新 path 的 received SMS 快照;路径未变化也必须执行一次显式快照扫描。 +4. 对快照中的 received SMS 读取 properties/body,过滤现有 `ignore_storage`,通过统一 `Messaging::receive` 入库。D-Bus object path 只用于本次读取,不用于最终去重。 +5. 以新 identity generation 写入每条新增记录的 reconcile key;已有记录不重新触发 delivery。默认不转发历史 reconcile 记录,除非 `forward_reconciled=true`。 +6. 重扫同时刷新 SIM identity cache;从 SMS 对象取得的 SMSC 只能作为一个来源,不能无条件覆盖更新更近或更可信的 EF_SMSP/ModemManager 值。 +7. 完成后把新 path、identity key、scanned/persisted/forwarded counts 写入 `sms_resync_runs`,广播 `sms.resync.updated`。 +8. 若 listener 在恢复期间已经自行重新订阅,收到的 resync 请求仍执行一次快照;同一个 operation + generation 的重复请求合并为一个 run。 + +## 10. 幂等、失败恢复和状态矩阵 + +### 10.1 HTTP 幂等 + +- enable/download/delete 等写操作要求 `Idempotency-Key`,沿用 SmsRelayed 发送 API 的长度和冲突语义:同 key + 同规范化请求返回同一 operation/result;同 key + 不同 target/参数返回 409。 +- 相同 target 已 active 时返回可重复的成功结果,不创建新的基带恢复。 +- 服务重启后,`Queued/Running` 不自动重放;只读 probe 后标记 `unknown` 或 `Degraded`,用户重新发起新 key。 +- lpac enable 在外部看来不是可任意重放的操作;只有文档化的 transient marker 才允许内部一次 retry。 + +### 10.2 失败矩阵 + +| 失败点 | 状态 | 后续动作 | +| --- | --- | --- | +| eSIM gate/lpac/driver preflight | Failed | 不碰 Modem;修复依赖后重新请求 | +| profile 不存在/参数不合法 | Failed | 不碰 Modem;不暴露原始 lpac 输出 | +| lpac enable 非 transient 失败 | Failed | 保留当前 identity,允许新的明确操作 | +| lpac timeout、状态无法确认 | Failed 或 Degraded(取决于 probe) | 先只读 list/identity;不要盲目重复 enable | +| enable 明确成功,power-cycle 失败 | Degraded | 记录“目标可能已 active”,手动 recovery;不自动回滚 | +| 新 path 多个候选/身份不匹配 | Degraded | runtime-only,禁止控制;提示检查设备和配置 | +| identity 读取为空 | Degraded | 保留旧 cache 但标记 stale;不把旧值冒充当前值 | +| SMS resync 部分失败 | Degraded | 已入库的保持;按 run 重试剩余扫描,去重保证安全 | +| API/进程重启 | Unknown/Degraded | 恢复 listener;只读探测,不重放外部 enable | + +## 11. 敏感字段处理 + +这些字段均按个人/网络身份信息处理;Matching ID、confirmation code 和 SM-DP+/Matching 组合还应按 provisioning credential 处理。 + +| 字段 | 用途 | 存储 | API/前端 | 日志/事件 | +| --- | --- | --- | --- | --- | +| EID | 标识 eUICC、绑定 cache | 0600 SQLite 中按需保存完整值;备份默认脱敏 | 管理员详情可显示,列表 mask | 只用不可逆短摘要 | +| ICCID | Profile 操作和 identity key | 必须保存完整值才能 enable;不放 URL | 操作 body 传入;显示 mask/显式 reveal | 不记录完整值 | +| IMSI | 识别当前订阅、诊断 | identity cache 完整值,按 fingerprint+ICCID 隔离 | admin-only,默认 mask | 不记录 | +| MSISDN/本机号码 | 呼叫/SMS identity | `msisdn_json`,规范化并允许负缓存 | admin-only,默认 mask | 不记录 | +| SMSC | 短信提交/诊断 | identity cache 完整值;来源和时间戳一起存 | admin-only,默认 mask | 不记录 | +| SM-DP+ | 下载服务器 | 可作为非秘密 endpoint,但和 Matching ID 绑定时视为敏感;默认仅短期内存 | 不在 profile list 回显,需显式 reveal | host 可按策略脱敏,凭据不记录 | +| Matching ID/activation code | 下载凭据 | 默认不持久化、不进 cache、不进 operation row;需要重试则由加密 secret store 保存短 TTL | 只在 HTTPS body 中提交,响应不回显 | 永不记录 | + +通用要求: + +- SQLite 文件、导出、备份和诊断 bundle 的权限沿用并加强现有配置文件 0600 约束;默认不把这些字段送入 Sentry、metrics、public health、webhook 或 SSE payload。 +- raw lpac JSON、完整 stdout/stderr、AT 命令及其响应不得保存或日志化;只保留 allowlist 字段和 redacted error code。调试开关也必须先脱敏。 +- API access log、反向代理日志和前端 analytics 不应包含 ICCID/手机号 URL;因此 enable/download 推荐 body 参数而非敏感 path segment。 +- 输入严格做长度、字符集、数字/URL 校验;SMSC/号码不得接受控制字符。对日志需要关联时使用带 domain 的 HMAC/摘要,而不是前缀截断。 +- 前端使用 `data-sensitive`/隐私组件和显式复制动作;页面 state 卸载、登出或超时后清理 full reveal 值。 +- 不以加密“替代”访问控制;如果 SQLite 仍为明文,必须明确文件权限、主机 root 风险、备份保留和恢复文档。 + +## 12. lpac 供应链和硬件依赖 + +### 12.1 软件供应链 + +参考 SimAdmin 的私有 lpac 目录和架构选择,但 SmsRelayed 不应在每次请求时下载二进制: + +- 发布物提供经过固定版本、SHA-256/签名验证、架构/glibc 兼容性检查的 lpac asset、许可证和 SBOM;运行时只执行配置的绝对路径。 +- lpac repair/install 若实现,必须是独立管理员操作:下载超时和大小上限、临时目录校验、原子替换、旧版本保留、probe 失败自动回滚;禁止 shell 拼接。 +- lpac 需要 QMI APDU driver 和 curl HTTP driver;检查动态库、执行权限、`qmi-proxy` 冲突、QMI device 权限和 UIM slot。 +- 生产镜像不把未经审核的用户自定义 URL 当作可信升级来源;custom asset 仅诊断/开发配置,并在 API 返回警告。 +- CI 记录支持的 `aarch64/x86_64`、glibc 最低版本和 lpac 版本;没有对应 asset 时明确 `lpac_unusable`,不降级执行未知本地二进制。 + +### 12.2 硬件/系统依赖 + +- 必须是实际带 eUICC 的 modem/板卡,并支持 ES10/APDU profile management;普通可插拔 SIM 或仅有 UI eSIM 选项不满足。 +- Linux 需要 ModemManager/system D-Bus、libqmi/QMI character device(如 `/dev/cdc-wdm*` 或 `/dev/wwan*qmi*`)、必要的 QMI proxy/权限,以及能访问 SM-DP+ 的 HTTPS 网络。 +- ModemManager 可能独占 QMI device;power-cycle 前必须有明确的 stop/ownership 约定,恢复后再启动并重新枚举,不能与正常 SMS D-Bus 调用并发抢设备。 +- UIM slot、固件、运营商锁、profile policy、SM-DP+ activation code 和网络 TLS 都可能使“硬件可见但 lpac 不可用”;这些作为可诊断原因而非通用重试。 +- 预检必须识别实际设备路径和稳定 Modem fingerprint;不能只检查 `lpac --version` 就允许 enable。 + +## 13. 分阶段交付 + +### Phase 0:契约和安全基础 + +- 完成 domain types、配置 defaults、SQLite migration、redaction policy、错误码和 operation 状态机。 +- 增加 mockable command runner、identity reader 和 clock;不需要真实 eUICC。 +- 加入 protected API 骨架和 frontend loading/error/敏感字段组件,但默认 `esim.enabled=false`。 + +### Phase 1:SIM identity 和缓存 + +- 复用 `ModemService` 的 verified target/fingerprint 读取 ICCID/IMSI;实现 MSISDN/SMSC 多来源读取和 negative cache。 +- 提供 `/api/sim/identity`、refresh、admin UI;只读验证 path drift、缓存隔离和过期策略。 +- 为 SMS 入库附加 identity generation/稳定 reconcile key 所需 metadata,不改变既有正常 live forwarding 行为。 + +### Phase 2:lpac 只读管理 + +- 实现供应链检查、lpac status、chip info、profile list、cache hydration;不做 enable。 +- 完成 eUICC/Profile 页面和 masked detail;测试真实/模拟 QMI driver unavailable、无 eUICC、旧架构。 + +### Phase 3:Profile 非破坏性写操作 + +- 在 coordinator 下实现 nickname/delete/download;所有 provisioning secret ephemeral,写操作幂等。 +- 增加 operation persistence、SSE/polling 和前端恢复显示。 + +### Phase 4:Profile enable、基带恢复和 SMS resync + +- 先在 mock runner 上验证 preflight、lpac retry、path drift、recovery state machine;再接入真实 QMI power-cycle。 +- 接入 `InboundWorker` resync channel、跨 path 去重、identity refresh 和 reconciliation policy。 +- 默认 `forward_reconciled=false`,经真实硬件验收后再开放配置。 + +### Phase 5:生产化 + +- 完成 lpac 签名/哈希发布流程、最小权限/udev/systemd 文档、故障诊断和备份脱敏。 +- 进行长时间重枚举、ModemManager crash、服务重启、网络不可用和多次切换 soak test。 + +## 14. 验收标准 + +### 功能 + +- 无 eSIM/无 lpac/driver 不可用时,系统保持现有 SMS 功能,eSIM API 只返回明确错误,不阻塞主 SMS worker。 +- 能读取并缓存当前 EID/ICCID/IMSI、本机号码和 SMSC;不同 ICCID 的值不会串写,空结果和未读取可区分。 +- 能列出 Profile 并正确标记 active;缓存优先和显式 live 行为可观察。 +- 对已 active Profile 重复 enable 是无副作用幂等成功,不触发 power-cycle 或 resync。 +- 对 inactive Profile enable 至少能完成:lpac enable、基带恢复、重新发现新 path、验证 fingerprint/identity、SMS resync;每一步都有状态。 +- Profile 切换后,新 path 上已有 SMS 能补扫入库;同一 SMS 不重复创建、不重复 delivery;默认不批量转发旧短信。 +- Modem path 变化、D-Bus 断开、ModemManager 重启后,listener 能重新绑定;无法确认设备身份时不执行控制动作。 +- lpac timeout、恢复失败、重扫部分失败均留下可查询 operation/run 状态,服务重启后不盲目重放 enable。 + +### 安全 + +- public health、未授权 API、普通事件、metrics、日志、诊断 bundle 不包含完整 EID/ICCID/IMSI/号码/SMSC/Matching ID 或 SMS body。 +- Matching ID/confirmation code 默认不会出现在 SQLite、operation row、前端缓存和日志。 +- 所有进程参数、外部命令和 timeout 可测试;不存在 shell injection、无限等待和未限制 stdout/stderr。 +- eSIM 写操作与现有 Modem action 互斥,重复/冲突请求返回 409 或已有 operation。 + +### 兼容性 + +- 既有配置无需修改即可启动;`esim.enabled=false` 时行为与当前版本一致。 +- 既有 live SMS 接收、发送、delivery retry、公开 health 和 Modem API 测试不回归。 +- `cargo fmt --check`、`cargo test`、frontend 的 `pnpm check` 和相关构建在无硬件环境下通过;硬件依赖测试以 mock/标记测试隔离。 + +## 15. 测试计划 + +### 15.1 Rust 单元测试 + +- ICCID、EID、IMSI、MSISDN、SMSC、SM-DP+、Matching ID 的规范化、长度/字符集验证和 redaction。 +- `identity_key`/reconcile key 的 domain separation、不同 fingerprint/ICCID 不碰撞、D-Bus path 变化不改变 key。 +- lpac JSON/stdout parser:正常 JSON、progress lines、字段缺失、未知 state、超长/恶意 raw、stderr 不泄漏。 +- `LpacClient` timeout、子进程终止、参数 argv、driver probe 和 safe retry marker;确认 non-transient error 不重试。 +- operation 状态机和 deadline:每个 phase 只允许合法迁移;服务重启后 Running 不自动 replay。 +- idempotency:相同 key 相同请求返回同 operation;不同请求 409;active target 不触发 recovery。 +- path rebinding:旧 path 失效、唯一 fingerprint 候选成功、多候选/身份变化拒绝 action、QMI path 与 D-Bus path 独立变化。 +- cache freshness:fresh/stale/negative/missing、按 Profile 隔离、可信来源优先级和并发 upsert。 + +### 15.2 SQLite/持久化测试 + +- migration 从空库和当前生产 schema 升级成功;索引/unique 语义覆盖缺失字段。 +- 事务中 operation phase、resync counts 和最终状态一致;进程中断后不产生可执行的半截 enable replay。 +- identity/profile cache 不覆盖其他 Modem fingerprint 或 ICCID;备份/导出默认脱敏。 +- reconcile key 在 object path 改变、重复快照、signal+snapshot 交错时保持一次入库。 + +### 15.3 入站和重扫集成测试 + +- fake D-Bus source 返回 initial snapshot、Added signal、path change、PropertiesChanged 和断线;确认 listener 通过现有 `InboundWorker` 重订阅。 +- Profile switch 期间到达的短信在旧 path、新 path 和 resync snapshot 三种来源下只入库一次。 +- `forward_reconciled=false` 不触发 delivery;开启后只对新入库且满足现有 profile policy 的消息投递。 +- SMS body 延迟/空 body、storage ignore、重复时间戳、不同 sender/body 和缓存 SMSC 来源均有测试。 +- resync request 合并、重试退避、超时和部分成功统计可查询。 + +### 15.4 API/前端测试 + +- 未授权和 public health 不可访问敏感 API;错误响应不含 lpac 原文。 +- API 202/409/400/503/504 contract、Idempotency-Key、刷新后 operation polling 和 SSE event schema。 +- 前端缓存优先、live refresh、active 标记、enable confirmation、进度/Degraded/失败状态、刷新页面恢复 operation。 +- 任何列表、toast、analytics 和 error boundary 都不输出完整敏感字段。 + +### 15.5 硬件验收和故障注入 + +至少在一台真实支持 QMI APDU 的 aarch64 和一台 x86_64 设备上验证: + +1. 读取 chip/profile/identity;下载一个测试 Profile;enable inactive Profile。 +2. 观察 ModemManager 停止、QMI power off/on、重新枚举的 path 变化,并确认新 fingerprint/ICCID。 +3. 在切换前、power-cycle 中、恢复后分别注入短信,确认入库/去重/转发策略。 +4. 注入 lpac timeout、QMI device 消失、ModemManager 启动失败、多个候选 modem、网络不可用、D-Bus 断线和服务重启。 +5. 连续切换至少 10 次并执行 soak test;每次都能查到 operation/resync 结果,不能出现旧号码/SMSC/Profile 泄漏或重复 delivery。 + +## 16. 与现有源码的落点 + +这是设计边界,不是本次实现清单: + +- `src/config.rs`:增加默认配置和校验,保持现有 TOML/AppConfig 及 `app.modem_path` 语义。 +- `src/modem.rs`:复用/扩展 verified runtime/action target、fingerprint 和统一 action gate;不让 eSIM 自己枚举第一个 Modem。 +- `src/dbus.rs`、`src/dbus/inbound.rs`:复用 D-Bus timeout、对象快照和订阅适配器。 +- `src/inbound.rs`:增加 resync channel/reconcile 入口,保持现有 `Messaging::receive` 与退避行为。 +- `src/persistence`:以 migration 增加 cache、operation、resync 表和稳定去重所需 metadata。 +- `src/api/mod.rs`:挂载 authenticated SIM/eSIM/operation/resync routes;沿用 `ApiError` 和当前 action 409 语义。 +- `src/events.rs`:只增加脱敏的 operation/identity/resync 事件。 +- `frontend/src/`:按现有 TanStack Router/React 结构新增页面和生成路由;不直接复制 SimAdmin 的 MUI 实现。 + +本 spec 的推荐设计以 SimAdmin 的行为为参考,但以 SmsRelayed 当前的 `ModemService`、D-Bus source、`InboundWorker`、`Messaging`、`Store` 和受保护 API 为唯一集成基础。 + +### 16.1 实现前必须关闭的决策项 + +| ID | 需决策事项 | 本 spec 默认建议 | 未决时的安全行为 | +| --- | --- | --- | --- | +| E1 | 是否保留 SimAdmin URL 形态的 compatibility adapter | 可保留一个 release;内部必须转发到同一 operation coordinator,并返回 operation id | 只发布第 8 节目标 API,不暴露 ICCID path route | +| E2 | nickname 使用同步 200 还是统一异步 202 | 若 lpac 99% 能在 5 秒内完成可同步,否则统一 operation | 不实现双重语义;选定一种并写 contract test | +| E3 | 是否离线显示 SM-DP+/Matching ID | SM-DP+ 可按 endpoint 单独评估;Matching ID/confirmation code 默认永不持久化 | 不显示、不备份、不重试下载凭据 | +| E4 | `forward_reconciled` 是否可由管理员开启 | 首版固定 false,硬件 soak test 后再开放 | reconcile 只入库、不创建 delivery | +| E5 | fingerprint 无法读取但只有一个 Modem 时是否允许 runtime-only | 允许只读 SMS;禁止任何 enable/reset/power 操作 | `ManualRequired`/`modem_identity_unverified` | +| E6 | 真实支持矩阵及 lpac asset 来源 | 在发布前锁定架构、glibc、lpac 版本、hash/签名和 license | `esim.enabled=false`,不提供 repair/download | + +## 17. 参考实现来源索引 + +行号固定到 `eb6f497ad332e59f1f3899acc3b2b9427661fe5c`;后续参考仓变更时应优先按符号搜索,再更新行号和“已确认”结论。 + +| 主题 | 相对 `/tmp/SimAdmin` 的来源(符号/行号) | 支撑的事实 | +| --- | --- | --- | +| API envelope / eSIM models | `backend/src/models.rs: ApiResponse` 8–37;`WorkMode` 39–67;`EsimCommandResponse`/`EsimProfile` 69–137;`BasebandRestartResponse` 266–280 | envelope、字段、work mode、progress shape | +| eSIM gate 与 lpac 串行 | `backend/src/esim.rs: EsimSupervisor` 60–334 | work mode gate、单一 `lpac_lock`、20/60/120 秒命令 timeout | +| lpac probe/driver | `backend/src/esim.rs: probe_lpac_binary` 537–607;`lpac_driver_list_has_required_drivers` 609–625 | 3 秒 probe,要求 qmi APDU + curl HTTP | +| lpac argv/env/解析 | `backend/src/esim.rs: run_lpac_command` 915–1007;`configure_lpac_environment` 1022–1044;`discover_lpac_qmi_device` 1046–1080 | 无 shell argv、默认 env、首设备 fallback、raw stdout 风险 | +| eSIM 路由 | `backend/src/main.rs` 690–730 | 真实 method/path | +| eSIM error HTTP 映射 | `backend/src/handlers.rs: esim_error_response` 78–85 | Disabled=403、Unavailable=503、Command=200 | +| enable preflight/retry | `backend/src/handlers.rs: retry_enable_profile_after_refresh` 388–466;`enable_esim_profile_for_switch` 468–531 | 5 秒 list、800ms、refresh=0 重试、already-active skip | +| enable 后台任务 | `backend/src/handlers.rs: enable_esim_profile_handler` 839–999 | 立即 HTTP 200、spawn、恢复、成功/失败 resync 请求、无 operation id | +| cache-first handler | `backend/src/handlers.rs: get_esim_euicc_handler` 714–750;`get_esim_profiles_handler` 753–836 | eUICC 默认 cache;profiles 以 `cached=1` 取 cache;identity enrichment | +| SIM identity/key | `backend/src/modem_manager.rs: SimIdentity` 722–727;`smsc_identity_keys`/`own_number_identity_key` 729–757;`current_sim_identity` 1167–1207 | ICCID/IMSI/operator 字段与 cache key 选择 | +| 号码/SMSC 探测与负结果 | `backend/src/modem_manager.rs: refresh_sim_details_background_inner` 1780–1833 | 多级 fallback、`source=empty` 写 cache | +| Modem path 发现 | `backend/src/modem_manager.rs: find_modem_path` 1122–1157 | 首 path、scan、5 秒发现、30 秒 failure cache,无 fingerprint | +| QMI device/power | `backend/src/modem_manager.rs: find_qmi_device_path` 2218–2239;`qmicli_sim_power` 2262–2275 | 设备节点优先级、UIM slot 1、20 秒 qmicli timeout | +| Profile 切换恢复 | `backend/src/modem_manager.rs: power_cycle_sim_for_profile_switch_inner` 5916–6131 | stop/start MM、QMI off/on、15 秒枚举、state/registration polling | +| progress singleton | `backend/src/modem_manager.rs: reset_baseband_restart_progress` 5829–5875 | 进程内全局 steps/running,无 operation identity | +| SMS resync channel | `backend/src/sms_listener.rs: SmsResyncHandle` 34–59;`start_sms_listener` 400–535 | 无界请求 channel、15 秒 poll、重绑触发、无 run result | +| SMS reconcile/dedupe | `backend/src/sms_listener.rs: sms_marker` 78–103;`process_sms_path` 192–289;`scan_sms_paths` 297–337 | timestamp/body/path fallback key、DB 多级去重、reconcile 默认不转发、处理后删 modem SMS | +| SQLite cache schema | `backend/src/db.rs` 538–631;`upsert_esim_profile_cache` 1820–1892 | 无 TTL/fingerprint,保存 provisioning fields/raw,COALESCE upsert | +| 前端 API compatibility | `frontend/src/api/current.ts` 441–504、574–583 | `cached=1`、ICCID URL、10 秒 enable request、restart status | +| 前端 progress UI | `frontend/src/pages/EsimManager.tsx` 590–595、939–1000、1003–1060 | 每秒 polling、确认、乐观 active、全局 recovery dialog | +| 环境/安装边界 | `docs/environment.md`;`docs/install.md`;`README.md` | lpac、QMI/APDU、glibc/arch 和 work mode 的部署边界 | diff --git a/docs/specs/simadmin/02-system-event-notifications.md b/docs/specs/simadmin/02-system-event-notifications.md new file mode 100644 index 0000000..a8e86f1 --- /dev/null +++ b/docs/specs/simadmin/02-system-event-notifications.md @@ -0,0 +1,610 @@ +# 功能 2:系统事件通知中心 + +状态:Spec(仅设计,不包含实现) +参考基线:`/tmp/SimAdmin` clean `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c` + +### 文档判读规则 + +- **已确认**:固定参考 commit 的源码事实;第 14 节以相对 `/tmp/SimAdmin` 的路径、符号和行号索引证据。 +- **目标契约**:SmsRelayed 重建时必须达到的外部行为。第 4 节及之后若无“需决策”标记,均是目标设计,不代表 SimAdmin 已实现。 +- **重建建议**:内部表名、模块名、阈值默认值可以按 SmsRelayed 习惯实现,但不能削弱事务、幂等、脱敏和恢复语义。 +- **需决策**:参考实现没有可靠答案的产品选择;实现前关闭第 12.5 节决策表。 + +## 1. 目标与非目标 + +### 1.1 目标 + +为 SmsRelayed 增加一个可持久化、可追踪、可恢复的系统事件通知中心: + +- 统一定义系统事件模型、分类、事件 code、严重度和状态语义。 +- 将 ModemManager/网络、服务、配置、安全、资源、转发投递和通知队列异常转换为结构化事件。 +- 支持按事件 code、分类、严重度、状态、实体和条件匹配规则。 +- 支持连续失败阈值、时间窗口、去抖、冷却和恢复通知,避免单次瞬态故障造成通知风暴。 +- 复用现有 `channel.profile` 转发 profile 作为通知渠道,不复制或重新存储渠道凭据。 +- 提供通用通知队列、租约、重试、幂等、失败日志和清理机制。 +- 通过现有 SSE 能力实时更新系统事件、队列和通知日志,并提供受保护的查询 API 和前端 Notification Center。 +- 保持现有短信接收、短信投递、配置安全和 API 兼容行为不变。 + +### 1.2 非目标 + +- 本功能不替代短信 `deliveries` 的投递状态机,也不把系统通知伪装成一条短信。 +- 不新增渠道供应商;Bark、Telegram、企业微信、钉钉、飞书和 Webhook 继续由现有 forwarding profile 提供。 +- 不在第一阶段实现通用工作流、告警编排、值班轮换、升级策略或外部告警平台集成。 +- 不保存完整短信正文、手机号、token、URL 查询参数或供应商原始响应作为系统事件详情。 +- 不为历史数据库凭空补造系统事件;迁移完成后只记录新发生的事件。 +- 不在通知规则中暴露或复制现有渠道密钥。 + +## 2. 参考实现结论:SimAdmin 的行为与局限 + +### 2.1 SimAdmin 当前保留的设计 + +SimAdmin 已形成一套可复用的产品交互和基础概念: + +- Notification Center 以“日志、规则、渠道”组织配置,并提供队列抽屉、测试渠道、清理日志和手工重试。 +- 事件类型覆盖 SMS、DDNS、版本更新、系统事件、设备状态和自动化;系统事件按 `baseband`、`cellular`、`device_network`、`system_service`、`security`、`esim`、`resource` 分类。 +- 系统事件使用稳定的事件 code;规则可以精确选择 code,非系统事件还支持 Always、Contains、NotContains、Equals、Regex。 +- 渠道实例和规则分离;一个规则可以引用多个渠道,渠道有启用开关、名称和限流配置。 +- 通知日志记录规则、渠道、结果和摘要;队列支持 pending、scheduled、retrying、sending、sent、failed、cancelled 等生命周期操作。 +- 安静时段、DDNS 失败阈值、标题模板、正文模板和自定义 JSON 是有价值的用户能力,应保留其产品意图。 + +### 2.2 SimAdmin 行为摘要 + +- `SystemEvent` 包含 category、event code、label、severity、status、entity、message 和 timestamp。 +- 系统事件默认定义在编译期;只有存在启用且包含该 event code 的 system-event 规则时才向通知发送器转发。 +- 登录失败使用进程内窗口:5 分钟内达到 5 次触发一次 warning,冷却 5 分钟。 +- DDNS 阈值依赖事件携带的 `failure_count`,按失败次数 modulo threshold 发送,而不是维护通用条件状态。 +- 队列每 5 秒轮询一次,单项最多 5 次尝试;失败以 60 秒起步、指数退避至 3600 秒;限流或发送失败进入队列。 +- 日志写入后立即按保留天数或最大条数清理;系统事件本身没有独立的持久化历史表。 +- 前端按系统事件分组展示 code 复选框,日志支持类型、状态、日期和关键字过滤,队列支持逐项及批量重试/取消。 + +### 2.2.1 已确认的 SimAdmin HTTP 契约 + +SimAdmin 使用 `{ "status":"ok|error", "message":string, "data"?:T }` envelope。除认证/JSON extractor 等框架级错误外,下列 handler 的数据库、配置和 provider 业务失败通常仍返回 HTTP 200,由 envelope 或 `data.success` 表达失败。这只是 compatibility fact,不是 SmsRelayed 目标错误语义。 + +| 方法与路径 | 请求 | 响应/行为 | +| --- | --- | --- | +| `GET /api/notifications/config` | 无 | `200`,返回 config v2:`{version:2,channels,rules,log_cleanup}`;渠道 config 中含 provider 配置,因此参考前端能编辑 secret | +| `POST /api/notifications/config` | 完整 config v2 | 成功或保存失败均 `200` envelope;没有 ETag/If-Match,也没有 rule 细粒度 CRUD | +| `POST /api/notifications/test/{channel}` | channel id/type 位于 URL | 始终 `200` success envelope,真正结果在 `data={success,message}` | +| `GET /api/notifications/logs` | `type,status,q,start_date,end_date,limit=50,offset=0` | `200`,`data={logs,total}`;limit 在 DB clamp 到 1..200 | +| `POST /api/notifications/logs/clear` | `{type?,status?,start_date?,end_date?}` | `200`,`data={deleted}`;失败仍是 200 error envelope | +| `GET /api/notifications/queue?limit=100` | limit clamp 到 1..500 | `200`,返回 active/failed items 和 total;item 包含 title/body/last error 派生 reason | +| `POST /api/notifications/queue/{id}/retry` | 无 | `200`,`data={updated}`;会把 attempt_count 清零,甚至允许改写 `sending` | +| `DELETE /api/notifications/queue/{id}` | 无 | `200`,把 active/failed(包括 `sending`)标为 cancelled | +| `POST /api/notifications/queue/retry-all` | 无 | `200`,所有 active/failed(包括 `sending`)改回 pending 并清零 attempt_count | +| `POST /api/notifications/queue/clear` | 无 | `200`,把所有 active/failed(包括 `sending`)标为 cancelled | + +参考实现没有 `/api/system-events`、event registry API、事件详情、cursor/replay、notification job idempotency 或独立 attempts API。系统事件仅在匹配到启用 rule 时才进入通知路由,未形成可查询审计资源。 + +### 2.3 SimAdmin 需要改进的局限 + +以下问题不能原样带入 SmsRelayed: + +1. 编译期事件注册表没有版本化;后端和前端的 system-event code 已出现漂移,例如前端遗漏 profile download 的成功/失败 code。 +2. 规则是否启用同时决定系统事件是否被发出,导致关闭所有通知规则后连审计事件也丢失。 +3. 事件不落库,没有事件 id、关联 id、指纹、来源或跨重启的条件状态。 +4. 登录失败计数只在内存中,重启会清零,也没有真正的恢复事件。 +5. DDNS modulo 阈值不能表达连续失败、时间窗口、恢复阈值和滞回。 +6. 队列只保存“通知发送项”,不是通用事件队列;没有幂等键,`expires_at` 未实际参与处理,轮询时会无界 spawn 任务,手工重试还会清零历史尝试次数。 +7. 日志没有事件/通知尝试的稳定关联,可能把消息正文或敏感摘要写入数据库,清理也只覆盖日志。 +8. quiet hours 主要是直接跳过发送;系统事件的 warning/critical 不应无条件丢弃。 +9. `triggered`、`recovered`、`succeeded`、`failed`、`changed` 与日志结果状态混用,无法区分“条件状态”和“通知投递状态”。 +10. `mask_identifier` 只在少数路径使用;自定义 JSON 和模板变量的敏感信息边界不够明确。 +11. queue item 和 notification log 均保存渲染后的正文/message;queue GET 也把 `body` 返回前端。系统事件模板变量还包含本机号码和运营商,不能把这套 schema 原样用于安全事件审计。 +12. queue 没有 lease owner/lease deadline。虽然 `mark ... sending` 是条件更新,同一进程内可减少重复 claim,但 worker crash 会留下无法自动回收的 `sending`;取消/手工 retry 又可与正在发送的 task 竞态。 +13. rate limit 默认每 channel 60 秒 20 条,仅统计成功日志;quiet hours 命中时直接跳过并写日志,不 defer。日志清理在每次写日志之后同步执行,队列表和事件状态不在清理范围。 + +### 2.4 保留与改进决策 + +| SimAdmin 设计 | 决策 | SmsRelayed 方案 | +| --- | --- | --- | +| 日志/规则/渠道三块 UI | 保留 | 增加事件时间线和队列状态,四者互相可跳转 | +| 稳定 event code 和分类 | 保留并改进 | 建立版本化 code registry,后端返回 registry,前端不再维护独立副本 | +| 精确 code 规则 | 保留 | 以 code 为主,补充 category、severity、status、entity 条件 | +| 渠道实例 + 多渠道规则 | 保留 | 规则只引用 `channel.profile`,复用现有凭据和 dispatcher | +| quiet hours | 保留并改进 | 默认 defer;info 可 drop,warning/critical 可 defer 或 bypass | +| DDNS/登录失败阈值 | 保留意图并改进 | 统一为持久化 condition state、窗口、去抖、恢复和滞回 | +| 5 秒轮询队列 | 改进实现 | 复用 `Notify`、精确 next-at 唤醒和安全扫描,不依赖固定轮询完成工作 | +| 通知日志清理 | 保留 | 改为后台批量维护,同时清理已结束队列和过期事件 | +| 自定义 JSON | 延后并收紧 | 第一阶段只允许 schema 校验后的 allowlist 字段,禁止原始敏感字段 | + +### 2.5 已确认默认值与状态词汇 + +| 项目 | SimAdmin 参考默认/状态 | 目标处理 | +| --- | --- | --- | +| config schema | notification version 2;channels/rules 为空 | 升级后目标通知发送仍默认关闭/空规则 | +| channel rate limit | enabled=true,20 messages / 60s | 可沿用为建议初值,但持久 job/lease 语义优先 | +| log retention | 90 天且最多 10,000 条 | 沿用为目标默认,后台批处理而非 insert 同步删除 | +| queue attempts | 5 次;第一次失败入队 60s;之后 60×2^(attempt-1),最大 3600s | 目标可复用 SmsRelayed delivery 的 30s 初值;两者必须明确区分为“参考事实/目标默认” | +| queue worker | 每 5 秒取最多 20 条并逐条 spawn | 目标为 Notify + next deadline + 30s safety scan + bounded concurrency | +| queue status | pending/scheduled/retrying/sending/sent/failed/cancelled;schema 还有未执行的 expires_at | 目标增加 expired,并真正执行 expires_at/最大年龄 | +| log status | success/failed/queued/quiet_hours/no_available_channel 等发送结果 | 目标与 event status 分离,枚举化 outcome | +| resource monitor | 60s interval;CPU/memory 5 bad/2 good,temperature 1/2,disk 1/1,interface/connectivity 3/2 | 仅是参考事实;目标阈值以第 5 节 registry/condition 配置为准 | + +## 3. SmsRelayed 现有能力与约束 + +### 3.1 EventBus 与 SSE + +当前 `src/events.rs` 提供进程内 Tokio broadcast EventBus,容量为 256;`AppEvent` 覆盖 message.created、message.updated、message.deleted、已读状态变化、conversation 更新、config.saved 和 service.restart_scheduled。发布方不等待消费者,慢消费者可能收到 lag error。 + +当前 `/api/events` 是受会话保护的 SSE 端点,按 `AppEvent::name()` 输出命名事件,前端 `subscribeEvents` 使用 `EventSource` 订阅并在认证失效时重新检查 session。该流没有 replay/cursor,重连期间可能丢事件;当前 AppEvent 某些 payload 含完整 `Message` 或手机号,不能直接作为外部系统通知 payload。 + +系统事件必须在 EventBus 之前经过“结构化、脱敏、持久化”边界:SSE 只发送安全的 SystemEventView,不把内部 `Message` 或转发正文复用到通知事件中。事件写入成功后再广播;广播失败不能回滚数据库写入。 + +### 3.2 Delivery worker、转发和重试 + +当前 delivery worker: + +- 使用 `DeliveryWakeup` 唤醒新投递,配合精确 retry deadline 和 30 秒安全扫描。 +- 以 90 秒 lease claim 投递,按配置的 concurrency(1 至 16)处理,当前默认 2。 +- 初始重试 30 秒,指数退避上限 3600 秒,并基于 delivery id 和 attempt 产生确定性 jitter。 +- 单条投递最大生命周期 24 小时;无效 deadline、消息不存在或超过最大年龄会成为永久失败。 +- 2xx 成功;408、425、429、5xx、连接/超时等为 transient;其他 4xx 和 3xx 为 permanent(各 provider/webhook 保留其当前明确分类)。 +- 每次尝试只记录安全的 outcome、error_code、耗时和 dispatch delay,不记录手机号、正文或密钥。 + +当前 forwarding profile 以 `channel.name` 标识,配置位于 `[channels]`,启用 profile 位于 `[forward].enabled`;配置保存采用原子写入和 0600 权限,`redacted_summary` 会隐藏 token、secret、key、URL 等敏感值。通知渠道应复用这一套 profile key、校验和 dispatcher,不复制 profile 配置。 + +当前系统没有 notification queue、system event 表、通知规则、通知日志和 Notification Center;`forward_attempts` 只描述 SMS delivery 的 provider 尝试。现有 SMS API 的 `Idempotency-Key` 只保证 outbound message 创建语义,不能直接代替通知 job 的幂等。 + +## 4. 目标事件模型 + +### 4.1 事件信封 + +建议引入版本化的 `SystemEvent`,所有字段均为可序列化的稳定 API 字段: + +| 字段 | 说明 | +| --- | --- | +| `event_id` | UUID/ULID,单次事件的不可变 id | +| `schema_version` | payload 版本,初始为 1 | +| `occurred_at` | 来源发生时间,UTC RFC3339 | +| `recorded_at` | SmsRelayed 落库时间,UTC RFC3339 | +| `category` | 稳定分类,例如 `delivery`、`modem`、`network` | +| `code` | 稳定的 namespaced code,例如 `delivery.profile_unhealthy` | +| `severity` | `info`、`warning`、`critical` | +| `status` | 事件语义状态,见下文 | +| `condition_key` | 同一故障条件跨事件的稳定 key | +| `entity_type` / `entity_key` | 作用对象及脱敏标识,例如 profile、worker、interface | +| `correlation_id` | 关联 delivery、message 或操作的内部 id;外部输出时可保留非敏感 opaque id | +| `summary` | 已脱敏、长度受限的人类可读摘要 | +| `details` | 仅包含 allowlist 数值和枚举的 JSON object | +| `fingerprint` | 对 code、entity、condition、规范化 details 的哈希,用于去重 | +| `source` | `delivery_worker`、`api`、`modem_monitor`、`config` 等来源 | + +`details` 只允许计数、阈值、状态、延迟、HTTP status、safe error code、队列长度、资源百分比等字段;事件生产者不得把 SMS body、完整 phone number、认证头、token、webhook URL、IMSI/ICCID/EID 全值写入 `summary` 或 `details`。 + +### 4.2 状态和严重度 + +事件状态与通知投递状态严格分离: + +- `triggered`:条件从 normal/pending 进入 active;同一 active 周期只生成一次首发事件。 +- `recovered`:此前已持久化为 active 的同一 `condition_key` 恢复到 normal;只有确认过 active 才允许生成恢复事件。 +- `succeeded`:一次性操作成功,例如服务启动、profile 检查成功;不代表某个故障恢复。 +- `failed`:一次性操作失败或单条消息永久失败;不自动生成 recovered。 +- `changed`:配置、网络状态或策略发生变化,但不是故障条件。 +- `suppressed`:事件已记录但被规则、去抖或 quiet policy 抑制;这是审计/内部状态,不表示 provider 已发送。 + +严重度只有三档:`info` 用于状态和操作记录,`warning` 用于需要关注但服务仍可用的退化,`critical` 用于服务不可用、数据风险或持续失败。严重度由 code 默认提供,规则可以设置最低严重度但不能把 critical 降级为 info。 + +### 4.3 建议分类与 event code + +首版 registry 至少包含下列 code。code 本身表示条件,不为恢复另造 `*_recovered` code;恢复通过相同 code 加 `status=recovered` 表示。 + +| 分类 | code | 默认严重度 | 触发/恢复语义 | +| --- | --- | --- | --- | +| `delivery` | `delivery.profile_unhealthy` | warning | profile 连续 transient failure 达阈值;后续成功达到恢复阈值时 recovered | +| `delivery` | `delivery.message_failed` | critical | 单条 delivery 永久失败;记录关联 id,不自动恢复 | +| `delivery` | `delivery.profile_missing` | critical | 配置引用不存在或启动校验失败;配置修复并成功投递后 recovered | +| `delivery` | `delivery.worker_unhealthy` | critical | worker 连续 claim/处理错误达到阈值;worker 正常 drain 后 recovered | +| `delivery` | `delivery.queue_backlog_high` | warning | backlog 持续超过阈值;降到恢复线并保持时间后 recovered | +| `sms` | `sms.persistence_failed` | critical | 接收短信无法持久化;数据库恢复后 recovered | +| `modem` | `modem.unavailable` | critical | modem 缺失或不可用达到去抖阈值;健康探测达标后 recovered | +| `modem` | `modem.registration_degraded` | warning | 搜网/注册持续退化;注册稳定后 recovered | +| `network` | `network.interface_error_spike` | warning | 接口错误速率超过阈值;窗口恢复正常后 recovered | +| `network` | `network.connectivity_failed` | warning | 连续探测失败;连续成功后 recovered | +| `security` | `security.login_failure_burst` | warning | 匿名/脱敏实体在窗口内失败次数达到阈值;冷却窗口无失败后 recovered | +| `config` | `config.validation_failed` | warning | 配置保存/加载校验失败;有效配置成功生效后 recovered | +| `service` | `service.started` | info | 服务或 worker 启动成功,状态为 succeeded | +| `service` | `service.restart_scheduled` | info | 重启计划已接受,状态为 succeeded | +| `resource` | `resource.disk_low` | critical | 磁盘低于下限;高于恢复线并稳定后 recovered | +| `resource` | `resource.memory_high` | warning | 内存高于上限;低于恢复线并稳定后 recovered | +| `resource` | `resource.temperature_high` | warning | 温度高于上限;低于恢复线并稳定后 recovered | + +后续 modem/eSIM 能力可以按同一 registry 增加 code。事件 registry 必须由后端作为 API 数据源返回 `code`、label、category、default severity、支持状态和 schema version;前端禁止复制一份可能漂移的静态列表。若需要兼容 SimAdmin 导入,可接受旧的 `modem_missing_threshold` 等别名并在保存时规范化为新 code。 + +## 5. 去抖、阈值、冷却与恢复 + +### 5.1 通用条件状态 + +每个 `(code, entity_type, entity_key)` 维护一个持久化 condition:`normal`、`pending`、`active` 或 `unknown`,并保存 first_seen、last_seen、failure_count、success_count、last_notified_at、next_repeat_at 和状态版本。 + +规则/registry 的条件参数包括: + +- `window`:统计窗口,例如 5 分钟。 +- `trigger_count`:窗口内或连续观察达到多少次进入 active。 +- `trigger_duration`:条件至少持续多久才进入 active。 +- `recover_count` / `recover_duration`:恢复所需的连续成功次数或稳定时长。 +- `cooldown`:同一 active 周期首发后的最短再次通知间隔。 +- `repeat_interval`:仍 active 时是否周期性重复提醒;默认 info 不重复、warning 60 分钟、critical 30 分钟。 +- `recovery_hysteresis`:恢复阈值与触发阈值之间的间隔,防止临界值来回抖动。 + +默认去抖为“3 次连续观测或 30 秒,以 code 的数据源能力较早满足者为准”;高风险 code 必须明确覆盖默认值,不能由调用方临时自定义含义。 + +### 5.2 首版默认阈值 + +| 条件 | 触发默认值 | 恢复默认值 | +| --- | --- | --- | +| profile transient failure | 同一 profile 5 分钟内连续 3 次 | 2 次连续成功,或 5 分钟内成功率回到健康线 | +| worker unhealthy | 5 分钟内 3 次 worker/claim 错误 | 1 个完整成功 drain 周期 | +| queue backlog high | backlog > 50 且持续 5 分钟 | backlog < 10 且持续 2 分钟 | +| login failure burst | 5 分钟内 5 次 | 5 分钟无新失败;不暴露用户名 | +| modem unavailable | 90 秒内 3 次失败观测 | 2 次连续健康观测 | +| disk low | 可配置容量百分比低于下限且 2 分钟稳定 | 高于下限 + 5 个百分点且 2 分钟稳定 | +| memory/temperature high | 连续 3 次或持续 30 秒超过上限 | 低于上限 - 5 个百分点并稳定 2 分钟 | + +一次性 `failed` 事件(例如某条消息永久失败)不应因为后续另一条消息成功而生成恢复;只有定义了 condition_key 的聚合条件才有 recovered。服务重启后,若数据库中 condition 为 active,应恢复计数状态;启动时不因一次健康采样发送虚假的 recovered,须满足该 code 的 recover 条件。 + +## 6. 规则与现有 forwarding profile 复用 + +### 6.1 规则模型 + +建议在现有 TOML 配置中增加 `[notifications]` 区块,规则至少包含: + +```toml +[notifications] +enabled = true + +[[notifications.rules]] +id = "delivery-ops" +name = "转发异常" +enabled = true +event_codes = ["delivery.profile_unhealthy", "delivery.worker_unhealthy"] +min_severity = "warning" +statuses = ["triggered", "recovered", "failed"] +profile_keys = ["telegram.ops", "bark.admin"] +quiet_policy = "defer" +cooldown_seconds = 1800 +``` + +规则还可以提供 category、entity type/key、标签或 safe-details 条件,以及标题/正文模板。事件 code 是主匹配条件;空 `event_codes` 不得隐式匹配全部 critical,若需全量订阅必须显式设置 `all_events=true` 并要求管理员确认。 + +规则的 `profile_keys` 必须使用现有 `channel.name` 形式,如 `telegram.main`、`webhook.ops`。保存时校验 profile 存在、启用状态、渠道类型和当前配置合法;禁用 profile 不创建新的发送任务。一个事件命中多条规则时,使用 `(event_id, rule_id, profile_key)` 去重,而不是把多个规则合并成一个不可追踪任务。 + +### 6.2 兼容既有短信转发 + +`[forward].enabled` 继续只表示新短信的转发目标,不改变现有 `deliveries` 行为。通知规则可引用同一 profile,但不读取或修改 `[forward].enabled` 的语义。为方便部署,可以提供“从当前 enabled profiles 创建系统通知规则”的显式操作;数据库/配置迁移不得默默给用户发送新的系统告警。 + +通知 dispatcher 复用现有 profile 的认证、请求构造、超时和 `ForwardOutcome` 分类;规则层只负责事件筛选、模板和 job,不能在规则配置中再次放 token、secret、URL 或 header。 + +### 6.3 quiet hours 与模板 + +quiet hours 由规则定义时区,默认使用服务器配置时区并在 API 中明确返回。推荐策略: + +- info:默认 `drop`,仍记录 `suppressed`。 +- warning:默认 `defer` 到安静时段结束,并保留 `not_before`。 +- critical:默认 `bypass`;也可由管理员显式改为 defer。 + +模板只允许安全变量,如 `event.code`、`event.category`、`event.severity`、`event.status`、`event.summary`、`entity.masked_key`、`details.failure_count`、`correlation_id`。不得提供 `message.body`、完整号码、profile credential、原始 provider response 等变量。自定义 JSON 必须在保存和发送前验证为 object/array,并再次执行敏感字段扫描和大小限制。 + +## 7. 数据模型与迁移 + +### 7.1 与现有表的边界 + +现有 `messages`、`deliveries`、`forward_attempts`/`forward_attempt_samples`、出站幂等表继续负责短信业务;不要把系统通知插入要求 `message_id` 的 SMS delivery 表。可以复用现有 worker 的 lease、退避、唤醒和 dispatcher 抽象,但通知需要独立 payload 和状态表。 + +### 7.2 建议新增表 + +#### `system_events` + +保存不可变事件历史: + +```text +id TEXT PRIMARY KEY +schema_version INTEGER NOT NULL +occurred_at TEXT NOT NULL +recorded_at TEXT NOT NULL +category TEXT NOT NULL +code TEXT NOT NULL +severity TEXT NOT NULL +status TEXT NOT NULL +condition_key TEXT NULL +entity_type TEXT NULL +entity_key TEXT NULL +correlation_id TEXT NULL +source TEXT NOT NULL +summary TEXT NOT NULL +details_json TEXT NOT NULL DEFAULT '{}' +fingerprint TEXT NOT NULL +created_at TEXT NOT NULL +``` + +建议建立 `(created_at DESC, id DESC)`、`(code, status, created_at)`、`(condition_key, created_at)` 和 `fingerprint` 索引。若来源有天然事件 id,可增加 `source_event_id` 唯一约束,避免重启或重连重复写入。 + +#### `event_conditions` + +保存跨重启阈值状态: + +```text +condition_key TEXT PRIMARY KEY +code TEXT NOT NULL +entity_type TEXT NOT NULL +entity_key TEXT NOT NULL +state TEXT NOT NULL +failure_count INTEGER NOT NULL DEFAULT 0 +success_count INTEGER NOT NULL DEFAULT 0 +first_seen_at TEXT NULL +last_seen_at TEXT NULL +last_notified_at TEXT NULL +next_repeat_at TEXT NULL +state_version INTEGER NOT NULL DEFAULT 1 +updated_at TEXT NOT NULL +``` + +更新必须与生成 `triggered`/`recovered` 事件在同一 SQLite transaction 内完成;状态机使用乐观版本或写事务,防止两个 worker 同时把同一条件重复触发。 + +#### `notification_jobs` + +这是通用通知队列,而不是 SMS delivery 的别名: + +```text +id TEXT PRIMARY KEY +event_id TEXT NOT NULL REFERENCES system_events(id) +rule_id TEXT NOT NULL +profile_key TEXT NOT NULL +status TEXT NOT NULL +title TEXT NOT NULL +body TEXT NOT NULL +summary TEXT NOT NULL +idempotency_key TEXT NOT NULL UNIQUE +attempt_count INTEGER NOT NULL DEFAULT 0 +manual_retry_count INTEGER NOT NULL DEFAULT 0 +next_attempt_at TEXT NOT NULL +expires_at TEXT NOT NULL +lease_owner TEXT NULL +lease_until TEXT NULL +last_error_code TEXT NULL +last_error_at TEXT NULL +created_at TEXT NOT NULL +updated_at TEXT NOT NULL +``` + +状态为 `pending`、`scheduled`、`sending`、`retrying`、`sent`、`failed`、`cancelled` 或 `expired`。索引至少覆盖 `(status, next_attempt_at)`、`(profile_key, status, next_attempt_at)` 和 `(event_id, rule_id)`。 + +#### `notification_attempts` 与 `notification_logs` + +`notification_attempts` 记录每一次 provider 尝试:job id、attempt number、started/completed、latency、dispatch delay、outcome 和 safe error code。`notification_logs` 是面向 UI 的摘要视图:event id、job id、event type/status、rule/profile、结果、summary、message 和 created_at。二者都不能保存完整 body、手机号、credential 或 provider 原始响应;如果需要审计请求,保存 body 的字段白名单摘要或 hash。 + +### 7.3 迁移要求 + +- 按当前 storage migration 方式新增幂等 migration;建议同时使用 SQLite `user_version` 或现有 `meta` key 记录 `system_event_notifications_v1`,不能依赖“表不存在”作为唯一版本判断。 +- migration 在事务中创建新表、约束和索引;重复执行必须安全。失败时整组回滚,不影响现有 SMS 表。 +- 不回填历史系统事件,不改变现有 `messages`、`deliveries`、`forward_attempts` 的状态;新功能首次升级默认 `notifications.enabled=false`、规则为空或仅建立未启用模板,确保升级不会意外发通知。 +- 配置读取应接受没有 `[notifications]` 的旧 TOML,并写回时保留现有 `[channels]`、`[forward].enabled` 和未涉及字段。配置版本递增后仍通过现有原子写入和 0600 权限保存。 +- 系统事件 registry 的版本与数据库 schema 独立;删除/改名 code 必须保留 alias 和迁移说明,不能复用旧 code 表示新语义。 + +## 8. API 与 SSE + +所有接口复用现有会话认证、CSRF/配置并发控制和统一错误格式;日志、事件和队列响应加 `Cache-Control: no-store`,不因业务错误统一返回 HTTP 200。 + +目标成功响应直接返回资源 JSON,不使用 SimAdmin envelope。目标错误严格沿用 SmsRelayed 的现有格式: + +```json +{ + "error": { + "code": "notification_job_conflict", + "message": "notification job is currently sending" + } +} +``` + +`message` 必须已脱敏;客户端只按 HTTP status 和 `code` 分支。未知 provider 文本、URL、响应 body 和 channel secret 都不能进入该结构。 + +### 8.1 事件 API + +- `GET /api/system-events`:按 `category`、`code`、`severity`、`status`、`entity_type`、`entity_key`、`from`、`to`、`cursor`、`limit` 查询;limit 缺省 50、范围 1..200。默认按 `(occurred_at DESC,id DESC)`,返回 `{ "items":[SystemEventView], "next_cursor":string|null }`。cursor 是 opaque、带查询排序边界的编码,参数组合改变后不能复用。 +- `GET /api/system-events/{id}`:`200 SystemEventView`,details 仍是 allowlist;不存在为 `404 system_event_not_found`,不能返回原始消息或渠道正文。 +- `GET /api/system-events/registry`:`200 {schema_version,registry_version,categories,codes}`,每个 code 含 label、category、default severity、supported statuses、condition defaults 和 deprecated aliases,供前端生成选择器。 + +### 8.2 通知配置、规则和测试 + +- `GET /api/notifications/config`:`200 NotificationSettingsView`,只含 enabled、retention、worker limits 等非 secret 设置和当前 config revision;profile 列表只返回 key/type/enabled/safe label。 +- `PUT /api/notifications/config`:body 为完整 `NotificationSettingsInput`,要求 `If-Match`;成功 `200` 返回新 view 和 `ETag`。缺 header 为 `428 precondition_required`,revision 不符为 `412 config_revision_conflict`;校验 code、状态、严重度、profile key、阈值、模板和 quiet hours。 +- `GET /api/notifications/rules`:`200 {items:[NotificationRuleView]}`。`POST` body 不带 id,成功 `201` + Location;`GET/PUT /{id}` 返回/替换单条规则;`DELETE /{id}` 成功 `204`。规则修改/删除不修改历史 event/job/log。 +- `POST /api/notifications/test/{profile_key}`:body `{ "title"?:string }`,不接受任意 body/JSON;成功发送为 `200 {test_id,outcome:"sent"}`,已入队为 `202 {test_id,job_id,outcome:"queued"}`,永久配置错误为 4xx。测试 job 标记 `is_test=true`,不改变 condition。 + +### 8.3 队列和日志 + +- `GET /api/notifications/queue`:按 `status`、`profile_key`、`event_code`、cursor、limit 分页,返回 `{items,next_cursor}`;item 只有 title/summary,不返回渲染后的完整 body,另含 next retry、attempt count、manual retry count、lease 是否 active 和 safe error code。 +- `POST /api/notifications/queue/{id}/retry`:body `{ "reason":string }`(1..200、审计用但需脱敏);成功 `202 NotificationJobView`。保留 attempts,增加 manual retry count;`sending` 为 `409 notification_job_conflict`;sent/expired 必须创建关联的新 job,并返回新 id。 +- `DELETE /api/notifications/queue/{id}`:只允许取消 pending/scheduled/retrying,成功 `204`;sending 为 409,sent/failed/expired 为 `409 invalid_job_transition`。 +- `POST /api/notifications/queue/retry-all`:body 为显式 filter 和 `confirm=true`,`202 {operation_id,matched}`;`POST /clear` 同样要求 filter/confirm,只取消可取消 job。批量操作不能触碰 sending。 +- `GET /api/notifications/logs`:支持 event/rule/profile/outcome/date/query/cursor/limit,返回 `{items,next_cursor}`;query 只能搜 safe summary/code/name。 +- `POST /api/notifications/logs/clear`:body `{filter,confirm:true}`;成功 `200 {deleted,retention_policy}`;非法/过宽请求为 400,普通清理不能删除 event/condition/job。 + +### 8.4 SSE 扩展 + +保留 `/api/events` 的现有客户端事件名和认证行为,新增安全事件名: + +- `system.event.created`:SystemEventView,包含 event id、code、severity、status、masked entity、summary 和 occurred_at。 +- `notification.queue.changed`:job id、状态、attempt、next retry、safe error code 和 summary。 +- `notification.log.created`:日志 id、job/event/rule/profile 引用和结果。 + +事件落库后才广播;为弥补当前 broadcast 无 replay 的限制,客户端收到 SSE 重连或 lag 后按最后 cursor 调用 REST 增量查询。SSE payload 不复用当前包含完整 `Message` 的 AppEvent payload。 + +## 9. 前端 Notification Center + +新增 `/notifications` 页面,沿用 SimAdmin 的信息架构但使用 SmsRelayed 的 profile 模型: + +1. **概览/事件时间线**:按严重度、状态、分类和时间过滤;显示首次发生、最近发生、恢复时间、实体脱敏标识和关联 job。 +2. **规则**:从 `/api/system-events/registry` 读取 code 分组;支持 code、分类、最低严重度、状态、实体条件、阈值、恢复、quiet policy、模板和 profile 多选。profile 选择器显示现有 `channel.name`,不显示密钥。 +3. **队列**:显示 pending/retrying/sending/failed/expired,展示 next retry、尝试次数、safe error code;支持逐项重试/取消和批量操作。 +4. **日志**:展示事件、规则、profile、结果和摘要,可跳转到事件详情;支持保留天数、最大条数和清理操作。 +5. **渠道测试**:复用现有 profile test API,明确标记为 test,不把测试结果当作系统恢复。 + +页面使用 SSE 实时刷新,断线时按 cursor/时间窗口补偿并退回有限频率轮询。空规则、无可用 profile、quiet defer 和 suppressed 必须在 UI 中明确显示,而不是伪装为发送成功。 + +## 10. 队列、重试、幂等与日志清理 + +### 10.1 入队与幂等 + +事件检测、condition 状态变更、命中规则和创建 job 在一个 transaction 内完成。job 的 `idempotency_key` 为稳定哈希: + +```text +hash(event_id, rule_id, profile_key, rendered_schema_version) +``` + +同一 source event 重放、SSE 重连或 worker 崩溃都只能得到一个 job。provider 可能不支持幂等,因此系统仍采用 at-least-once 发送;对 Webhook 可提供 `X-Notification-Idempotency-Key`,并在文档中要求接收端去重。不能宣称“网络重试绝不重复”。 + +### 10.2 worker 与重试 + +通知 worker 复用现有 delivery worker 的可靠性策略:`Notify` 唤醒、精确 next-at、lease、并发上限、30 秒安全扫描、确定性 jitter 和 24 小时最大年龄,但抽象出不依赖 `message_id` 的 `NotificationPayload`。不要把系统通知 job 强行塞入当前 SMS `deliveries`。 + +- claim 只允许 pending/scheduled/retrying 且 next_attempt_at 到期的 job,并原子设置 sending + lease。 +- lease 过期可被其他 worker 收回;完成时校验 lease owner,失去 ownership 的旧 worker 不得覆盖新结果。 +- 复用现有 ForwardOutcome 分类:2xx 成功;408/425/429/5xx、超时、连接和 transport 为 transient;明确的无效 profile、认证配置错误、其他 4xx/3xx 为 permanent,错误码必须经过 safe normalization。 +- 默认 5 次自动尝试、30 秒起步、指数退避上限 3600 秒、最大年龄 24 小时;critical 规则可以配置最大尝试,但必须有上限。 +- manual retry 不清零 attempt_count;它只增加 `manual_retry_count` 并记录审计日志。已 sent/expired 的 job 不得原地重发,必须由管理员明确创建新的 retry job 并保留原 job 关联。 +- 采用 profile 级 rate limit 和 bounded concurrency;不能像 SimAdmin 一样每轮对所有 due 项无界 spawn。 + +### 10.3 日志与清理 + +事件、condition、job、attempt 和 UI log 分层保存: + +- system event 默认保留 90 天,critical 事件可配置更长保留;事件清理前保留计数和 last-state 所需的 condition。 +- notification logs 默认保留 90 天或 10,000 条,以较严格的配置上限为准;attempts 可采用更短保留期。 +- sent/failed/cancelled/expired job 在保留期后批量删除;sending 任务永不被普通清理删除。 +- 清理由受控的后台维护任务按批次执行,并报告删除数量和耗时;不要在每次 insert 的同步路径执行大量 DELETE。 +- 清理操作写一条不含敏感信息的 admin audit entry;UI 显示实际删除数量和过滤条件。 + +### 10.4 崩溃、竞态与恢复矩阵 + +| 故障/竞态 | 持久状态 | 恢复动作 | 幂等边界 | +| --- | --- | --- | --- | +| producer 在 event transaction 提交前崩溃 | 无 event/condition/job | source 若可重放则用 `source_event_id` 重交;否则不补造 | transaction 全回滚 | +| event/condition 已提交、SSE 广播前崩溃 | event/job 存在 | worker 正常 claim;UI 用 cursor 补齐 | SSE 不是真相源,不重复 event | +| 两 producer 同时更新同 condition | 只有一个 state_version 获胜 | loser 重新读取并重算 transition | condition transaction + version 防双 triggered/recovered | +| worker 发送前崩溃 | sending + lease | lease 到期后 reclaim | attempt number/lease owner 条件更新 | +| provider 已接收、worker 记 sent 前崩溃 | sending + lease,外部结果 unknown | at-least-once retry可能重复;携带 notification idempotency header | 不承诺 exactly-once;接收端按 key 去重 | +| 人工 retry 与 worker sending 竞态 | sending | API 409;用户等 lease 结束 | 不清零 attempt、不覆盖 owner | +| rule/profile 在 job 排队后被禁用/删除 | job 仍在 | 发送前重新校验;标 failed/cancelled 的 safe code,不自动改投其他 profile | job 绑定原 rule/profile revision | +| quiet defer 期间条件 recovered | trigger job 仍 scheduled | 策略明确:默认取消未发 trigger,并只发送 recovered 摘要或合并成一次“已恢复” | `(condition active cycle,rule,profile,status)` key | +| 数据库不可用 | 不得只靠内存发送 | producer 返回/记录受控 failure;恢复后从 durable source 重建可重建事件 | 没有 durable event 就不创建外部通知 | +| 服务重启 | active condition/job 从 DB 恢复 | reclaim expired lease、恢复 next deadline;不重发 sent | state/job 均持久化 | + +## 11. 隐私与敏感信息脱敏 + +- 系统事件生产者只能提交 allowlist details;在数据库写入、SSE、REST、日志和通知模板出口重复执行 redaction,不能只信任调用方。 +- 手机号默认只显示末两位或不可逆稳定 hash;profile key 可以显示,因为它本身不含 credential;用户名、邮箱、IMSI、ICCID、EID、设备序列号等统一显示类型名加末四位或 hash。 +- URL 只显示 scheme/host 的安全摘要,去掉 path 中的 token、query、签名和 webhook secret;HTTP header 永不进入事件/日志。 +- provider 错误只允许现有 safe error code、HTTP status 和长度受限的非原始摘要;不得记录 reqwest error 中可能包含 URL 的完整字符串。 +- 标题/body 模板变量不得访问短信正文和完整号码;自定义 JSON 做字段名/值递归扫描、大小限制和控制字符清理。 +- API/日志/前端均不返回 `[channels]` 中的 secret、token、key、完整 webhook URL 或密码。配置写入继续使用当前原子写入和 0600 权限。 +- 不把 `MessageCreated(Message)` 这类内部完整 payload 直接转发到外部渠道;SMS 通知若未来需要,必须先生成单独的脱敏摘要事件。 + +## 12. 分阶段实施 + +### Phase 0:契约和迁移 + +- 冻结 event registry、状态/严重度枚举、脱敏 allowlist、配置 schema 和迁移编号。 +- 明确从 `[forward].enabled` 引用 profile 的校验规则;默认关闭新通知发送。 +- 增加 schema、API、事件和日志的测试样例,不改现有业务行为。 + +### Phase 1:事件模型与持久化 + +- 实现 registry、SystemEvent envelope、condition state machine、SQLite migration 和 safe view。 +- 接入已有 delivery worker、config、service、auth、resource 等可获得的观测点。 +- 将安全事件写入 DB,并通过受保护 SSE 发布;提供事件查询 API。 + +### Phase 2:规则与通用通知队列 + +- 增加 notifications 配置、规则匹配、阈值/恢复、quiet policy、profile 校验和原子入队。 +- 提取可复用的 queue worker core,复用现有重试/lease/dispatcher;实现 notification jobs、attempts、logs 和幂等。 +- 增加队列/日志 API、重试/取消、维护清理和 metrics。 + +### Phase 3:Notification Center 前端 + +- 实现概览、事件时间线、规则、队列、日志和渠道测试页面。 +- 使用后端 registry,SSE + cursor 补偿,不在前端维护 code 副本。 +- 完成空状态、权限、脱敏、quiet defer、失败和恢复展示。 + +### Phase 4:统一与加固 + +- 在不改变既有语义的前提下,让 SMS delivery 与 notification queue 共享 lease、退避、错误分类、指标和维护框架。 +- 增加故障注入、升级回滚、重复投递、慢消费者、provider 超时和数据库重启测试。 +- 评估是否加入可选的发送确认、按 profile 熔断和更多 modem/eSIM/resource code。 + +### 12.5 实现前必须关闭的决策项 + +| ID | 需决策事项 | 本 spec 默认建议 | 未决时的安全行为 | +| --- | --- | --- | --- | +| N1 | notification rules 存 TOML 还是 DB | 保持配置型 rules 在 TOML,并以 revision 快照进 job;事件/condition/job 存 SQLite | 只开放整份 config API,不做双写 | +| N2 | active condition 在 quiet hours 内恢复时如何处理 deferred trigger | 取消 trigger job,发送一条 recovered/短暂故障摘要 | 不发送已经过时的 trigger | +| N3 | sent/expired 的 manual retry 是否原地还是新 job | 新 job,保留 `retry_of_job_id` | 禁止重试 terminal job | +| N4 | critical 的 quiet policy 是否允许 drop | 只允许 bypass/defer,禁止 drop | bypass 并记录审计 | +| N5 | 系统通知是否支持自定义 JSON | 首版关闭;后续用渠道类型 schema + allowlist | 仅发送内置 text payload | +| N6 | 是否兼容 SimAdmin HTTP 200 envelope | 可提供短期 adapter,但目标 router 使用真实 status | 不提供 adapter,前端随目标 API 一起重建 | +| N7 | event retention 后 correlation/job 如何展示 | job/log 保留 event tombstone 的 code/time/hash | UI 显示“事件已按保留策略清理”,不伪造详情 | + +## 13. 验收与测试标准 + +### 13.1 单元测试 + +- registry code 唯一、版本和 alias 规范化;未知 code 有安全 fallback 但不能绕过规则。 +- category/code/severity/status/entity 规则匹配、最低严重度和多规则去重。 +- 连续失败、窗口计数、持续时间、去抖、cooldown、repeat、恢复阈值和滞回;边界时间使用可控 clock。 +- 重启后 active condition 延续,normal condition 不产生虚假 recovered;一次性 failed 不产生恢复。 +- quiet policy、模板 allowlist、JSON schema、敏感字段扫描和 provider safe error normalization。 +- 幂等 key 稳定;相同 event/rule/profile 不重复入队,不同 rule/profile 不误合并。 + +### 13.2 集成与可靠性测试 + +- 从空库和现有库执行 migration,重复执行不报错;现有 SMS 表、投递、配置和 outbound idempotency 行为不变。 +- event + condition + job transaction 在提交前崩溃时全部回滚,提交后 worker 重启能继续处理。 +- 两个 worker 并发 claim 同一 job 只能有一个 owner;lease 过期、取消竞态和旧 owner 完成均有覆盖。 +- 按当前分类验证 2xx、408/425/429/5xx、其他 4xx/3xx、timeout、DNS/connect、profile missing 的成功/重试/永久失败结果。 +- 验证 30 秒安全扫描、精确 retry wakeup、bounded concurrency、最大年龄和退避 jitter;模拟 provider 重复接收并验证 idempotency key。 +- 日志、事件和 job 清理按批次执行,不删除 active/sending 数据,完成后仍能查询 condition last-state。 + +### 13.3 API、SSE 与前端验收 + +- 未认证用户不能读取事件、规则、队列或日志;响应不包含 secret、token、完整号码、短信正文、完整 URL 或原始 provider body。 +- 查询过滤、cursor 分页、ETag 冲突、非法阈值、未知 profile、未知 code 和模板敏感变量返回明确 4xx 错误。 +- 事件先落库再 SSE;SSE lag/断线后前端能够通过 cursor 补齐,不重复显示同一 event/job。 +- 从触发、排队、重试、发送成功、永久失败到恢复的完整链路可在页面按 event id/job id 追踪。 +- 规则保存后只引用现有 profile;禁用 profile、删除规则、quiet defer、suppressed、manual retry 和清理操作均有清晰状态。 + +### 13.4 验收场景 + +至少演示以下场景并保留测试结果: + +1. 同一 profile 连续三次 transient failure 只产生一次 `delivery.profile_unhealthy` triggered 通知,恢复后产生一次 recovered 通知。 +2. 一次永久失败只进入 `delivery.message_failed` 和 notification log,不因下一条短信成功而误发 recovered。 +3. worker 重启、数据库重启或 SSE 断线后,active condition、未完成 job 和事件时间线均可恢复。 +4. quiet hours 内 warning 被 defer、critical 按策略 bypass、info 被 suppressed;三者在日志中可区分。 +5. 同一个 source event 被重复提交时只存在一个 `(event, rule, profile)` job,provider 重试仍记录每次 attempt。 +6. 规则引用 `telegram.main`/`webhook.ops` 时直接使用现有 profile;前端/API/日志任何位置都看不到对应 token 或 webhook secret。 + +## 14. 参考实现来源索引 + +行号固定到 `eb6f497ad332e59f1f3899acc3b2b9427661fe5c`。后续参考仓变更时按符号重新核验,不应只机械平移行号。 + +| 主题 | 相对 `/tmp/SimAdmin` 的来源(符号/行号) | 支撑的事实 | +| --- | --- | --- | +| notification config schema/default | `backend/src/config.rs: NotificationEventType` 319–328;`NotificationRule` 469–501;`NotificationChannelInstance` 503–515;rate/log defaults 517–577;`NotificationConfig` 580–629、1005–1013 | 类型、matcher/rule/channel、20/60 限流、90 天/10k、v2 空默认 | +| system event registry | `backend/src/system_event.rs: category/severity/status/codes` 13–94;`SYSTEM_EVENT_DEFINITIONS` 96–443 | 编译期 code/label/default-enabled 列表 | +| SystemEvent wire shape | `backend/src/system_event.rs: SystemEvent` 461–510 | 无 id/correlation/details/fingerprint 的实际字段 | +| emitter gating/login threshold | `backend/src/system_event.rs: SystemEventEmitter` 519–611 | rule gate、直接通知、5 分钟 5 次、内存窗口/冷却 | +| rule code match | `backend/src/notification.rs: system_event_enabled` 495–505;`rule_matches` 2198–2227 | 启用 rule 决定 event 是否继续,system event 精确 code 匹配 | +| route/quiet behavior | `backend/src/notification.rs: route_event_for_rule` 704–830 | channel fanout、quiet 直接跳过、disabled/missing log | +| DDNS threshold | `backend/src/notification.rs: ddns_failure_threshold_pending` 2229–2244 | failure_count modulo threshold | +| queue enqueue/worker | `backend/src/notification.rs: enqueue_notification` 1059–1089;`run_queue_worker` 1091–1112;`process_notification_queue_item` 1114–1207;`retry_backoff_seconds` 2325–2328 | 5 次、5 秒 poll、batch 20、spawn、60–3600s、无 lease | +| synchronous log cleanup | `backend/src/notification.rs: record_notification_log_raw` 904–949 | 每次 insert 后执行 retention/max delete | +| notification tables | `backend/src/db.rs` 452–511 | log/queue columns、body/error、expires_at 未进入 worker predicate | +| queue mutations | `backend/src/db.rs: retry_notification_queue_item` 1321–1335;`retry_all...` 1349–1362;`clear...` 1364–1373;mark methods 1424–1498 | manual retry 清零、sending 可被 retry/cancel、无 attempt history/lease | +| notification API routes | `backend/src/main.rs` 839–878 | config/test/log/queue method/path | +| notification handlers | `backend/src/handlers.rs` 3911–3975、3979–4030;`backend/src/notification_queue.rs` 13–119 | HTTP 200 envelope、query/default、test data.success | +| resource condition monitor | `backend/src/system_event_monitor.rs` 11–101、173–445 | 60 秒周期、进程内 AlarmCounter、阈值和 recovered code 分离 | +| connectivity dependency | `backend/src/system_event_monitor.rs: ping_connectivity` 447–459 | 固定 `ping/ping6` 目标和 1 秒 timeout | +| frontend code drift | `frontend/src/pages/notifications/systemEventModel.ts` 13–102 | 前端静态 registry,缺 backend 的 profile download success/failure code | +| frontend sensitive variables | `frontend/src/pages/notifications/systemEventModel.ts` 104–116 | 模板提供本机号码/运营商变量 | +| Notification Center UI | `frontend/src/pages/NotificationCenter.tsx` 47–166、196–331、396–447;`frontend/src/pages/notifications/NotificationQueueIndicator.tsx` 21–55 | 日志/规则/渠道 tabs、queue drawer、poll/retry/clear | +| frontend API calls | `frontend/src/api/current.ts` 924–990 | config POST、test path、logs offset、queue mutations | diff --git a/docs/specs/simadmin/03-modem-watchdog-and-recovery.md b/docs/specs/simadmin/03-modem-watchdog-and-recovery.md new file mode 100644 index 0000000..f83dccd --- /dev/null +++ b/docs/specs/simadmin/03-modem-watchdog-and-recovery.md @@ -0,0 +1,639 @@ +# 功能 3:Modem watchdog 与完整故障恢复流程 + +状态:Spec(本文件只定义方案,不包含实现) +范围:SmsRelayed 的 ModemManager、D-Bus SMS 接收、短信重扫、可选数据连接恢复,以及与 systemd 的协作。 + +参考基线:`/tmp/SimAdmin` 的 clean `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c`。后续参考实现变化不自动改变本规格。 + +### 文档判读规则 + +- **已确认**:能由上述固定提交中的代码直接证明,重建兼容层时应原样理解。 +- **目标契约**:SmsRelayed 重建后的必需行为;可有不同内部实现,但外部可观察结果必须一致。 +- **重建建议**:为补齐参考实现的安全性、幂等性或可运维性而提出,不是 SimAdmin 已有能力。 +- **需决策**:参考实现没有唯一答案;实现前必须选定并记录,不能由代码作者临时猜测。 + +## 1. 目标与非目标 + +### 1.1 目标 + +1. 在不丢失入站 SMS、不重复发送出站 SMS、不过度重启硬件的前提下,发现并恢复以下故障: + - ModemManager D-Bus owner 消失或重启; + - Modem 对象暂时消失、对象路径重新编号或路径漂移; + - modem 处于长时间 searching、enabled/idle、connecting 或 disconnecting; + - D-Bus SMS 订阅断开、启动期间漏掉的 SMS 未进入本地数据库; + - SIM、射频或基带恢复后,SMS worker 没有重新绑定; + - 在明确启用数据恢复策略时,数据连接未恢复。 +2. 将恢复动作分成由轻到重的阶梯,并对每一级设置超时、冷却、退避、次数上限和人工接管状态。 +3. 将“运行时 SMS 目标”和“允许执行控制动作的 modem 目标”分开,利用已登记的 modem identity/fingerprint 防止路径漂移后误操作另一台 modem。 +4. 为健康接口、受保护 API、事件流、日志和指标提供可诊断但不泄露 SMS 内容、电话号码、SIM/设备标识或原始命令输出的状态。 +5. 保留用户的手动禁用和飞行模式意图;watchdog 不得在用户明确关闭 modem 或数据连接后偷偷重新打开。 + +### 1.2 非目标 + +- 不把 watchdog 变成通用主机监控器;CPU、内存、磁盘、温度、iptables 和互联网连通性不属于本功能的自动恢复范围。 +- 不自动删除 ModemManager 缓存、不自动操作 remoteproc、不自动 kill 任意进程、不刷新或清空主机防火墙。 +- 不把低信号强度、长时间没有新 SMS 或单次 mmcli 超时直接判定为硬件故障。 +- 不在本功能中重新设计 SMS 转发、投递重试或认证体系;允许为恢复 lease、用户意图、熔断和审计增加独立表,不改写既有消息/投递表语义。 +- 不承诺恢复运营商网络、SIM 欠费、PIN/PUK 锁定、基站覆盖不足、物理 USB/电源故障或运营商侧短信延迟。 +- 不在第一阶段自动启用 SIM power-cycle、USB reset、基带 remoteproc 重启或设备重启;这些动作必须是显式配置、受限的 systemd 辅助服务或人工操作。 + +## 2. 参考实现:SimAdmin 已确认行为 + +以下是对固定参考提交的现状提取。阈值、路由、状态码和副作用属于兼容事实;本文后续标为“目标契约/重建建议”的部分则不要求照抄。SimAdmin 启动后等待 5 秒,无配置开关地启动 15 秒周期的 `data_connection_watchdog`;所有计数器、冷却时间与“问题是否活跃”标记均只在该进程内存中保存。 + +### 2.1 检测指标、阈值与恢复动作 + +| 指标 | SimAdmin 判定 | 恢复动作 | 备注 | +| --- | --- | --- | --- | +| 找不到 Modem 对象 | 连续 3 次扫描约 45 秒 | mmcli --scan-modems,发出扫描事件 | 发现失败缓存 30 秒,并用互斥避免并发扫描 | +| 找不到 Modem 对象 | 连续 5 次约 75 秒 | systemctl restart ModemManager | ModemManager 重启冷却 300 秒;冷却内只记录,不重复重启 | +| enabled 但长期 idle | 连续 8 次约 120 秒 | 射频 disable/enable cycle | 计数归零后执行;该分支没有复用 searching 的 300 秒冷却 | +| searching | 连续 4 次约 60 秒 | 自动注册运营商一次 | 自动注册请求超时 45 秒,轮询缓存 | +| searching | 连续 8 次约 120 秒 | 射频 cycle,并允许重新注册 | 射频恢复冷却 300 秒 | +| connecting/disconnecting | 连续 6 次约 90 秒 | 射频 cycle | 用于处理卡住的状态迁移;该分支也没有共享 300 秒冷却 | +| 已注册但数据未连接 | 数据激活重试冷却 120 秒 | 尝试 NetworkManager activation | watchdog 不刷新主机 iptables;只读诊断已有规则 | +| 注册/基带恢复 | 状态恢复后 | 可选 NM 重新连接,并发送恢复事件 | 状态恢复通过轮询确认,不依赖固定长 sleep | + +SimAdmin 还保留了以下较重动作:基带软重启会串行执行 RF disable、等待约 5 秒、RF enable,再轮询搜索和注册(约 45/60 秒);eSIM/profile 恢复会停 ModemManager、选择首个可用 QMI 设备节点、对 SIM 做 power off/on、再启动 ModemManager 并等待 modem 重新枚举(最长 15 秒)。它在 profile/baseband 恢复后请求检查未同步 SMS,并依赖持久化去重减少重复和漏收;但 resync 请求通道是无界内存队列,没有 operation id、完成状态或持久化恢复。 + +### 2.2 已确认的 HTTP 兼容契约 + +SimAdmin 暴露 `POST /api/baseband/restart` 与 `GET /api/baseband/restart/status`。POST 是同步长请求:handler 等 `restart_baseband` 整体结束才响应;成功与失败都使用 HTTP 200,JSON 包装统一为 `{ "status": "ok|error", "message": string, "data"?: T }`。状态端点同样总是 HTTP 200。 + +`restart/status` 返回的是进程级单例 `steps`、`running` 和 `current_registration`,没有 operation id 或调用者隔离。开始一次基带重启会清空旧步骤并设置全局 `running=true`;进程退出会丢失所有进度。eSIM profile enable 的异步后台任务也复用这套全局进度,因此并发调用可能覆盖/混合观察结果。参考实现没有通用 watchdog 状态 API、恢复历史 API、恢复取消 API 或幂等键。 + +兼容适配器若必须服务旧前端,应保留上述路由、HTTP 200 envelope 与全局进度的读取形状,但其内部必须转接到目标 coordinator,并在 `message` 中提供稳定错误码映射。新客户端不得继承该语义,必须使用第 7.3 节的真实 HTTP 状态码、operation/epoch 与幂等接口。 + +### 2.3 已确认的用户意图、并发和外部命令局限 + +- “关闭蜂窝数据”会写入配置,并在进程启动时初始化 `data_user_disabled`;飞行模式请求仅存于 `AtomicBool`,重启即丢失。二者都不是持久化的 modem 级 `user_disabled` 状态。 +- watchdog 只在全局 `BASEBAND_RESTART_RUNNING` 为 true 时暂停;eSIM/profile power-cycle、人工 data/airplane 操作、SMS 扫描、NetworkManager activation 与 watchdog 动作之间没有一个共同 recovery lease。 +- `run_recovery_command` 用于 `mmcli --scan-modems`、`systemctl restart ModemManager` 等动作,但没有 timeout;它把 stdout/stderr 直接变成成功或错误字符串,调用者可能继续写入日志/事件。 +- `find_modem_path`/QMI 节点选择偏向排序后的首个候选,未按持久化 fingerprint 绑定动作设备。参考实现的 `serial::with_serial` 只有一把进程内异步互斥锁,不能防多进程或 systemd 脚本竞态,也不持久化 owner/deadline。 +- watchdog 会读取 iptables 规则数量用于诊断,但明确不 flush;数据恢复通过 NetworkManager,120 秒内避免再次 activation。只有 searching 射频恢复与 modem 缺失 restart 有 300 秒冷却,idle/transition 射频 cycle 没有同等护栏。 +- SMS listener 注册 `Messaging.Added` 后做 initial scan,此后每 15 秒再扫描并处理无界 resync 请求;每次 scan 串行遍历所有 SMS path。它没有 owner-change 专用状态、run deadline、候选上限、结果计数或显式完成通知。 + +### 2.4 SimAdmin 的 systemd 辅助服务 + +`scripts/simadmin-modem-recovery.service` 是一次性服务: + +- After=network.target ModemManager.service; +- Type=oneshot、ExecStart=/usr/local/bin/simadmin-modem-recovery.sh、RemainAfterExit=yes; +- 脚本启动后等待约 60 秒;用 mmcli -m 0 检查 modem;发现无 modem、状态为 failed,或近期日志包含 UimUninitialized 时进入救援; +- 救援阶段停止 ModemManager、停止 qmi-proxy、清理 /var/lib/ModemManager 下的子项、触发 udev、启动 ModemManager 并等待; +- 若仍失败,再尝试停止/启动名称包含 mss/modem 的 remoteproc,随后再次启动 ModemManager。 + +这些行为暴露出 SmsRelayed 需要避免的风险:脚本固定使用 modem 0,缓存清理具有破坏性,二阶段失败仍可能返回 0,RemainAfterExit=yes 只覆盖启动时而非持续 watchdog,也没有和应用内动作共享锁。因此本功能只吸收“启动后健康检查”和“二阶段恢复”的思路,不直接复制脚本。 + +## 3. SmsRelayed 当前能力与缺口 + +### 3.1 当前健康判定 + +src/api/health.rs 的公开 /api/health 同时检查 SQLite 和 ModemService::public_health。ModemService 当前通过 mmcli 取得 modem 快照:单次命令超时 5 秒,健康结果缓存 5 秒;刷新并发时可在最多 30 秒内返回旧快照。公开结果只有 status、首个 reason 和 checked_at。 + +src/modem.rs 的健康分类主要是: + +- 配置路径不能解析:Error/modem_path_unresolved; +- modem disabled:Degraded/modem_disabled; +- SIM 未 ready:Degraded/sim_not_ready; +- Messaging 不可用:Degraded/messaging_unavailable; +- failed、locked、unavailable 等状态:Degraded/modem_state_unhealthy; +- 只有状态、SIM、enabled 和 Messaging 条件满足时才为 Ok。 + +这是一份 dashboard/health 快照,不是故障恢复判定:它不包含 D-Bus owner 是否仍存活、SMS worker 是否在消费信号、最近一次 SMS snapshot/resync 是否成功、数据连接/路由/互联网状态、恢复次数或用户禁用意图。 + +### 3.2 ModemService、路径和动作能力 + +- ModemService 封装 mmcli 能力探测、JSON/text fallback、状态解析、Messaging/SIM 补充查询、IMS 探测和公开健康缓存。 +- action_lock 使用 try-lock,当前保护 enable/disable/reset 之间的 mmcli 控制动作;reset 有每 session 60 秒限流。 +- ModemTargets 已把 runtime_path 和 action_path 分开。SMS worker 可以仅绑定 runtime path;控制动作必须使用 verified action path。 +- 路径解析使用精确路径匹配,不使用前缀匹配;一个候选路径可作为运行时目标,但只有已登记 identity/fingerprint 或明确验证的路径才可作为控制目标。 +- modem identity 使用 domain-separated SHA-256 fingerprint 持久化;检测到配置路径上的 identity 变化时会撤销 verified action target,并保存 mismatch/quarantine 状态。 +- 当前 run_action 能执行 mmcli 的 enable、disable、reset,但没有 watchdog action、systemd restart、扫描、恢复 epoch 或恢复次数账本。 + +### 3.3 D-Bus 与 SMS worker 能力 + +src/dbus/connection.rs 提供共享状态的 system-bus connection cache,并能在失败连接仍为当前连接时丢弃它。当前入站实现 src/dbus/inbound.rs 在每次订阅中建立 system D-Bus connection: + +- 在解析 ModemManager owner 后订阅 NameOwnerChanged、该 modem 的 Messaging.Added 和 InterfacesRemoved; +- 先 Messaging.List,再读取 SMS State,将已有的 received SMS 作为启动 snapshot; +- owner 变化、被监视 modem object 被移除或 signal stream 结束时终止本次订阅,让上层重连; +- 添加信号会按 sender、modem path 和 received 标志过滤。 + +src/inbound.rs 的 InboundWorker 当前具备: + +- modem 解析失败时 5 秒起步、最多 60 秒的指数重连退避; +- 订阅建立后先处理 snapshot,并继续消费 live Added;snapshot 与 live 信号重叠时由持久化去重; +- 最多 16 个并行入站处理任务;SMS properties 最多重试 5 次;Text 为空时每 100 ms 轮询,最多 600 次(约 60 秒); +- 持久化失败采用 100 ms 起步、最多 30 秒的持续退避; +- 每 60 秒刷新 runtime identity,支持 runtime-only/action-only/separate targets,并在 identity mismatch 时隔离控制动作; +- SMS 入库和 delivery 创建在 Messaging/Store 中保持幂等;outbound worker 有 durable lease,重启后可恢复过期 lease。 + +src/runtime.rs 启动 inbound、delivery、retention、outbound worker 和 API;任一受监督 worker/API 退出会使 forwarding runtime 退出。当前没有独立 modem watchdog,也没有在 ModemManager 重启后统一暂停/恢复所有 worker 的协调层。 + +### 3.4 当前缺口 + +1. 没有周期性、带迟滞和熔断的 modem watchdog。 +2. 没有应用层对 mmcli --scan-modems、ModemManager restart、udev 或 systemd helper 的编排。 +3. inbound worker 的重连会自然触发 snapshot,但没有明确的“恢复后重扫”接口、状态、事件和结果计数。 +4. action_lock 没有覆盖 D-Bus resubscribe、SMS resync、systemd restart、outbound SMS 临界区和未来的数据动作;存在竞态风险。 +5. 健康缓存可能暂时陈旧,且没有 worker liveness、SMS freshness 或恢复阶段信息。 +6. 当前没有数据连接控制器;SmsRelayed 不能据此安全地实现 SimAdmin 的 NetworkManager data watchdog。 +7. API 只有受保护的 modem status/enable/disable/reset;EventBus 当前只有消息、配置和服务事件,没有恢复事件。 +8. 配置只有固定 app.modem_path 和独立的 [monitoring] Sentry 开关;配置变更要求服务重启。 + +## 4. 建议的整体设计 + +### 4.1 核心原则 + +- “可观察”与“可恢复”分离:每次 probe 先记录证据,只有达到连续阈值才执行动作。 +- “运行时可消费”不等于“可以控制”:路径漂移时可以安全接收 SMS,但未经 identity 验证不能 enable/disable/reset 或重启硬件。 +- 恢复动作必须可取消、可超时、可回滚到安全状态;最重动作默认关闭。 +- 恢复成功必须同时满足 modem 状态稳定、D-Bus subscription 已恢复、SMS reconcile 完成;单次 mmcli 成功不等于服务恢复。 +- 用户意图优先级高于自动恢复:手动 disable、airplane mode 和“禁止数据自动连接”是硬 inhibit。 + +### 4.2 状态机 + +~~~text +BootGrace + └─ probe owner/path/identity + ├─ UserDisabled/AirplaneInhibit ───────────────┐ + ├─ healthy + subscription healthy → Healthy │ + └─ evidence threshold → Degraded │ + └─ S0 D-Bus rebind + SMS reconcile │ + ├─ success → Healthy │ + └─ threshold → S1 modem rescan │ + ├─ success → AwaitingStable │ + └─ threshold → S2 MM restart │ + ├─ re-enumerated → S3 SMS │ + │ reconcile → Healthy │ + └─ failed/backoff ────────┤ + ├─ allowed → S3 RF │ + │ cycle → reconcile│ + └─ circuit open → │ + ManualRequired │ +~~~ + +状态定义: + +| 状态 | 含义与进入条件 | 自动动作 | +| --- | --- | --- | +| BootGrace | 启动或 ModemManager 重启后的观察窗口 | 只 probe,不重启硬件 | +| Healthy | modem、owner、worker subscription 和最近 reconcile 均成功 | 正常工作 | +| Degraded | 有限的连续失败,但尚未达到恢复阈值 | 轻量 probe、重连、记录事件 | +| Recovering(stage) | 持有恢复 lease,正在执行一个阶梯动作 | 暂停同类 watchdog 触发,保留入站/出站协调 | +| AwaitingStable | 动作完成但尚未通过稳定窗口 | 轮询路径、状态、owner、SMS reconcile | +| UserDisabled / AirplaneInhibit | 用户明确禁用 modem 或开启飞行模式 | 禁止自动 enable、RF cycle、数据 reconnect;仍可报告状态 | +| ManualRequired | 熔断、identity mismatch、路径多义、硬恢复需要人工确认 | 不再自动升级;提供诊断和受保护人工动作 | + +UserDisabled 和 AirplaneInhibit 是硬状态,覆盖所有自动恢复分支。identity mismatch 不是普通 degraded,而是控制动作 quarantine;runtime-only SMS 可以继续工作。 + +### 4.3 探测周期与证据 + +推荐默认值: + +- 基础周期 15 秒;每轮增加 0--2 秒确定性抖动,避免多个实例同时调用 mmcli。 +- 启动和每次 ModemManager restart 后先进入 60 秒 BootGrace。 +- 每轮低成本检查 D-Bus owner、对象管理器中的 modem 数量、runtime/action target、modem state、SIM/Messaging 可用性和 inbound subscription liveness。 +- mmcli 完整 status 使用现有 5 秒命令超时,建议至少 30 秒缓存;健康 API 可返回最近快照,但必须标出 checked_at 和 stale。 +- 不以“最近没有收到 SMS”作为故障指标。SMS liveness 由 owner、signal stream、List/GetAll 成功和 resync 结果判定。 +- 计数器按故障原因分开;连续两次完整健康 probe 后清零。一次成功的 scan/restart 只清除“对象缺失”计数,不直接宣告 SMS 或数据恢复。 + +### 4.4 恢复阶梯、阈值与退避 + +以下为 SmsRelayed 建议的初始策略,数字必须可配置并写入事件/指标;它们不是当前实现行为。 + +| 阶段 | 触发 | 动作 | 单次超时/完成条件 | +| --- | --- | --- | --- | +| S0 rebind | owner 变化、subscription 终止、路径暂时不可用 | 丢弃失效 connection,重新解析 owner/path,重新订阅并做 bounded SMS reconcile | 新 owner、已验证 runtime target、snapshot 完成 | +| S1 scan | 无 modem path 连续 3 次(约 45 秒) | 调用 mmcli --scan-modems 或等价受限 D-Bus scan;不重启服务 | scan 5 秒,随后最多等待 30 秒重新枚举 | +| S2 MM restart | 无 modem path 连续 5 次(约 75 秒),且不在冷却/熔断 | 请求受限 systemd helper 重启 ModemManager;不在应用内直接执行任意 systemctl | helper 45 秒;owner/path/resubscribe/reconcile 完成后才算成功 | +| S3 RF cycle | searching 连续 8 次、enabled/idle 连续 8 次或 transition 连续 6 次;仅 verified action target | disable/enable 或等价受限动作,随后重新注册并 reconcile | 90 秒;成功需连续两次稳定 probe | +| S4 data reconnect | 已注册但 data bearer 未连接,且用户允许自动连接 | 仅调用受限 data adapter/NM activation 一次;不删 profile、不改防火墙 | 120 秒冷却;以 bearer/IP/route 证据确认,不只看 modem state | +| S5 hard recovery | S2/S3 反复失败,且显式开启并通过人工/策略授权 | SIM power-cycle、remoteproc 或专用硬件脚本 | 默认关闭;每次都需单独审计、超时和人工可见结果 | + +searching 连续 4 次(约 60 秒)可以先做一次非破坏性的 operator re-register;它不能和 RF cycle 同一轮并发。S3 触发后至少冷却 300 秒。S2/S3 使用指数退避:5、10、20、30 分钟封顶;默认每小时最多 3 次 MM restart/RF cycle 合计。达到上限后进入 ManualRequired,至少经过 30 分钟观察窗口和一次明确人工确认才可清除熔断。 + +恢复动作只有在完成该阶段的后置验证后才算成功。动作超时、owner 未恢复、路径多义、identity mismatch 或 SMS reconcile 失败均不得自动把状态标成 Healthy。 + +### 4.5 并发、互斥与恢复 lease + +引入一个逻辑上的 RecoveryCoordinator(名称仅为设计概念): + +1. 所有 watchdog、人工 modem API、D-Bus resync、mmcli scan、ModemManager restart、RF cycle、SIM power-cycle 和未来 data action 先取得同一全局 lease;同一时间最多一个 state-changing recovery。 +2. lease 携带 recovery_epoch、stage、owner、deadline、reason。lease 失效后只能由 coordinator 恢复,不允许遗留任务继续执行下一阶段。 +3. 现有 ModemService.action_lock 应成为该协调层的一部分,而不是另设一把只保护 mmcli 的锁;API 在冲突时返回 409,watchdog 在冲突时跳过本轮并记录一次受抑制事件。 +4. ModemManager restart 前暂停新的 SMS subscription 建立、mmcli action 和 data activation;等待正在执行的 outbound SMS 到达可恢复边界。对已进入 sending/未知结果的短信不得因恢复自动重发,交由 durable lease/人工确认处理。 +5. restart 后先恢复共享 system-bus owner/connection,再恢复 inbound subscription,最后运行 snapshot/reconcile;不能让旧 connection 或旧 owner 的 SMS handle 跌落到新 session。 +6. watchdog probe 可以并发读取缓存,但不能在 recovery lease 持有期间触发第二个动作。人工 disable/airplane 请求优先级高于自动恢复,必须使当前 epoch 的后续 enable/data reconnect 失效。 + +### 4.6 目标持久化模型与进程恢复 + +这是 **目标契约**,不是 SimAdmin 现有 schema。使用 UTC RFC 3339 时间或整数 epoch 毫秒,SQLite 写入必须和对应状态跃迁在同一事务中。建议增加: + +~~~sql +CREATE TABLE modem_recovery_state ( + modem_fingerprint TEXT PRIMARY KEY, + watchdog_state TEXT NOT NULL, + current_epoch INTEGER NOT NULL DEFAULT 0, + intent_generation INTEGER NOT NULL DEFAULT 0, + modem_user_disabled INTEGER NOT NULL DEFAULT 0, + airplane_inhibit INTEGER NOT NULL DEFAULT 0, + data_user_disabled INTEGER NOT NULL DEFAULT 0, + circuit_open_until TEXT, + consecutive_missing INTEGER NOT NULL DEFAULT 0, + consecutive_searching INTEGER NOT NULL DEFAULT 0, + consecutive_idle INTEGER NOT NULL DEFAULT 0, + consecutive_transition INTEGER NOT NULL DEFAULT 0, + last_probe_at TEXT, + last_healthy_at TEXT, + last_resync_at TEXT, + last_resync_status TEXT, + updated_at TEXT NOT NULL +); + +CREATE TABLE modem_recovery_attempts ( + epoch INTEGER PRIMARY KEY, + modem_fingerprint TEXT NOT NULL, + idempotency_key_hash TEXT, + trigger_kind TEXT NOT NULL, + reason_code TEXT NOT NULL, + stage TEXT NOT NULL, + status TEXT NOT NULL, + intent_generation INTEGER NOT NULL, + started_at TEXT NOT NULL, + deadline_at TEXT NOT NULL, + finished_at TEXT, + result_code TEXT, + counters_json TEXT NOT NULL DEFAULT '{}', + UNIQUE(modem_fingerprint, idempotency_key_hash) +); +~~~ + +要求: + +1. 原始 IMEI/IMSI/ICCID、电话号码、SMS 内容、D-Bus path、stdout/stderr 不进入这两张表;`modem_fingerprint` 使用现有 domain-separated digest,幂等键只保存 digest。 +2. action lease 由 `status='running'`、`epoch`、`deadline_at` 和事务性 compare-and-set 表示。若未来允许多个实例,共享 SQLite 只能协调同一数据库;特权 helper 仍须由 systemd/OS 级锁防跨进程竞态。 +3. watchdog 的短期连续采样可留在内存,但用户意图、circuit、当前 epoch、动作次数窗口和最近 resync 结论必须落盘。进程重启不能重置小时上限或解除 inhibit。 +4. 启动时把 deadline 已过或 owner 已消失的 `running` attempt 标为 `interrupted`,进入 BootGrace,重新 probe 和 reconcile;不得盲目重放 RF cycle、MM restart、SIM power-cycle 或 outbound SMS。 +5. 仍在 deadline 内的旧 attempt 也不能被新进程视为本进程持有;先通过 helper/owner/target 证据判定外部动作是否结束,再标 `interrupted` 或 `unknown_result`。未知结果进入 AwaitingStable/ManualRequired,不直接升级。 +6. 每个新人工请求带 `Idempotency-Key`。同一 fingerprint、相同 key 与相同规范化 body 返回原 epoch;相同 key 配不同 body 返回 409 `idempotency_conflict`。自动 watchdog 使用由故障类别、采样窗口和 intent generation 派生的内部 key。 +7. 保留最近至少 1,000 次 attempt 或 30 天(先到者),清理不得删除当前 running、ManualRequired 的根因或仍用于小时限流的记录。实际保留值属于第 9.5 节待决策项。 + +## 5. ModemManager 重启与路径漂移 + +### 5.1 重启语义 + +ModemManager 重启只保证服务进程重启,不保证 modem 硬件、USB port、object path 或 bearer 不变。实现必须: + +- 监听 NameOwnerChanged,把旧 owner、旧 object path 和旧 SMS handles 标成 stale; +- 清空 runtime target,重建 system D-Bus connection,重新获取 owner 和 modem 列表; +- 不使用 /Modem/0 假设,不用路径前缀匹配,也不把 mmcli 输出里的 id 当作稳定 identity; +- 在 action target 上重新校验持久化 fingerprint。只有 exact enrolled match 或显式验证的配置路径可控制; +- runtime 只找到一个但 identity 暂不可读时,可以临时 runtime-only 接收 SMS,但不得自动控制它;多 modem 且无法区分时不绑定 runtime,也不执行 action; +- old path 恢复为同一 fingerprint 后清除 mismatch quarantine;不同 fingerprint 时保留 quarantine 并进入 ManualRequired。 + +### 5.2 systemd 辅助服务边界 + +推荐新增一个受限的 privileged helper(例如 sms-relayed-modem-recovery.service),由 coordinator 通过明确参数/本地 IPC 请求,不从业务代码拼接任意 shell 命令。服务应满足: + +- Type=oneshot,单次 TimeoutStartSec,失败返回非零;不使用会掩盖失败的无条件 exit 0; +- 由 systemd 控制 StartLimitIntervalSec/StartLimitBurst,与应用的每小时熔断双重保护; +- 只允许列明的动作:restart ModemManager、可选 udev trigger、可选已知 modem 的有限恢复;不允许任意路径删除、任意进程 kill 或 remoteproc glob; +- 不默认清理 /var/lib/ModemManager。若未来必须清理,必须是单独人工确认的服务、明确备份/恢复策略和 dry-run 结果; +- 记录结构化的 stage/result/exit code,不记录命令行、完整 journal、SMS 内容、电话号码、IMSI/IMEI 或原始 mmcli 输出; +- helper 只负责特权动作,健康判定、路径验证、SMS reconcile 和用户意图仍由 coordinator 负责。 + +### 5.3 外部命令、设备与权限契约 + +下表是目标实现的依赖边界。可用 D-Bus API 时优先 D-Bus;命令必须通过参数数组调用,不经过 shell。 + +| 能力 | 依赖 | 最低权限/目标约束 | 默认 timeout | 缺失或失败时 | +| --- | --- | --- | --- | --- | +| owner/path/state probe | system D-Bus,`org.freedesktop.ModemManager1` | 只读;runtime/action target 分离 | 单轮 5 秒 | 记 probe 证据;未达阈值不动作 | +| modem scan | `mmcli --scan-modems` 或等价 D-Bus | coordinator lease;不接受用户传入参数 | 5 秒 + 30 秒枚举窗口 | 记 `scan_failed`,留在 Degraded | +| MM restart | 固定 systemd unit/helper | helper 只允许 `ModemManager.service` | helper 45 秒 + BootGrace | 503/`helper_failed`;不直接进入 Healthy | +| RF cycle | ModemManager Enable(false/true) | 仅 exact verified action target | 整体 90 秒 | 停止本 epoch,按次数/冷却决定 ManualRequired | +| SIM power-cycle | 可选 `qmicli` 与已验证 control port | 默认关闭;设备节点必须从 verified modem 映射,不扫描后取首个 | 每步 20 秒 | 停止并人工接管;仍尝试把 MM 恢复为 running | +| data reconnect | 可选 NetworkManager D-Bus/`nmcli` adapter | 独立 data intent 与已登记 profile | 30 秒,120 秒冷却 | 只标 data degraded,不升级硬件恢复 | +| SMS reconcile | ModemManager Messaging D-Bus + SQLite | 不需 root;复用 Store 幂等事务 | 总时限由配置,建议 120 秒 | `partial`/`failed`,保持非 Healthy | + +helper 的标准输出只能返回有上限的结构化结果,例如 `{stage,result,exit_code,duration_ms}`;应用丢弃或摘要化额外输出。设备节点、unit 名、远程处理器路径和缓存目录不得来自 HTTP body。生产部署必须用 systemd `ProtectSystem`、`NoNewPrivileges`(在所需特权允许的范围内)、能力白名单和明确的 polkit/sudo 边界;不允许把 Web 服务整体以不受限 root 身份运行。 + +## 6. 短信漏收、重扫与数据连接风险 + +### 6.1 SMS resync contract + +每次以下事件发生后必须执行一次 bounded resync:ModemManager owner 变化、modem object 重建、inbound subscription 重连、S2/S3/S5 成功或恢复流程中收到明确的“SMS service ready”信号。 + +resync 顺序: + +1. 先建立新 owner 的 signal match,再调用 Messaging.List,避免 List 期间新到达的 Added 丢失。 +2. 对 snapshot 过滤 State=received,复用现有 SMS properties retry、空 body poll、ignore_storage 和 16 个并发上限。 +3. snapshot 与 live signal 允许重叠;Store 的 modem fingerprint/dedupe namespace 和事务性入库是唯一去重依据,不用“本次 resync 已见路径”的内存集合代替持久化去重。 +4. 给 resync 设置候选数量、总时限和逐条错误上限;单条 properties 失败不能阻塞后续 SMS。失败候选应在下一轮 reconciliation 重试,并把未完成数量计入健康状态。 +5. 不删除 modem SMS storage,不因为空 body 立即丢弃;达到现有约 60 秒 body poll 上限后记录脱敏错误码,保留下一轮重扫机会。 + +resync 结果至少分为 completed、partial、failed,并带 snapshot_count、persisted_count、duplicate_count、deferred_count、failed_count;只记录计数,不记录正文、号码或完整 SMS object path。 + +### 6.2 数据连接风险 + +SmsRelayed 当前没有 SimAdmin 那样的 NetworkManager data controller,ModemManager connected 也不等价于已有 IP、默认路由或互联网可用。因此: + +- 第一阶段 watchdog 不自动触碰数据连接;只报告 modem registration 和 SMS health。 +- 后续若加入 data adapter,必须有独立的 data_user_intent、airplane_inhibit、bearer/profile identity 和 activation lease;不能把 modem watchdog 的 RF cycle 当作数据恢复的默认动作。 +- ModemManager restart 可能使 bearer、接口名、IP、路由和 DNS 短暂消失;恢复顺序应是 modem registered → bearer present → IP/route confirmed → optional application probe,并且每一步都有超时。 +- 不删除或重建用户的 NM profile,不刷新全局 iptables/ip6tables,不以一次 ping 失败触发 modem restart。 +- 数据 activation 期间 SMS worker 仍应保持可重连;activation 失败只进入 data degraded,不提升为硬件 recovery,除非同时有独立的 modem/object evidence。 + +### 6.3 用户手动禁用语义 + +- 手动 disable 成功后立即持久化 user_disabled=true,自动 enable、RF cycle、MM restart 后的自动 enable 和 data reconnect 全部禁止;允许只读 probe、事件和手动查询。 +- 手动开启飞行模式是更高优先级的硬 inhibit;退出飞行模式不会自动恢复用户此前明确禁用的 modem。 +- 手动 enable 只有在 action path verified 且动作成功后才清除 user_disabled;失败不改变原意图。 +- 手动 reset 不自动清除 disable/airplane 意图,也不自动启动数据连接;它应与 recovery lease 冲突时返回 409。 +- 用户在恢复进行中执行 disable/airplane 时,coordinator 必须递增 intent generation,使当前 epoch 的后续 enable、RF cycle 和 data activation 完成后不得继续下一步;能安全取消的动作立即取消,不能取消的动作只等待其 deadline,不再升级。 + +## 7. 配置、事件、API 与观测性 + +### 7.1 配置建议 + +建议新增独立 [watchdog],不要复用当前用于 Sentry 的 [monitoring]: + +~~~toml +[watchdog] +enabled = false +probe_interval_secs = 15 +startup_grace_secs = 60 +scan_after_missing_probes = 3 +modemmanager_restart_after_missing_probes = 5 +action_cooldown_secs = 300 +max_recovery_actions_per_hour = 3 +sms_resync_enabled = true +sms_reconcile_interval_secs = 300 +auto_operator_reregister = true +auto_radio_cycle = true +auto_data_reconnect = false +allow_sim_power_cycle = false +allow_remoteproc_recovery = false +~~~ + +字段要求: + +- 所有周期、阈值、上限均校验非零且有合理上限;restart_after >= scan_after; +- allow_sim_power_cycle、allow_remoteproc_recovery 和未来的 cache cleanup 必须分别授权,不能由一个 force=true 总开关覆盖; +- 默认先以 observer-only/enabled=false 灰度,确认指标和 fault injection 通过后再启用自动 S1/S2; +- 配置变更沿用当前“保存后重启服务”语义;运行中的 user_disabled、airplane 和 recovery epoch 不得因普通配置 reload 被清除; +- 配置文件继续按现有权限保护;敏感的 helper 凭证/IPC token 不进事件和日志。 + +### 7.2 事件模型 + +扩展 EventBus/SSE 事件,事件 payload 只含稳定枚举、计数和时间: + +- modem.watchdog_state_changed; +- modem.probe_failed / modem.probe_recovered; +- modem.recovery_started / modem.recovery_step / modem.recovery_succeeded / modem.recovery_failed; +- modem.path_rebound / modem.identity_quarantined; +- modemmanager.scan_requested / modemmanager.restart_requested / modemmanager.restarted; +- sms.resync_started / sms.resync_completed / sms.resync_partial / sms.resync_failed; +- data.reconnect_suppressed / data.reconnect_started / data.reconnect_recovered / data.reconnect_failed; +- modem.recovery_manual_required 和 modem.recovery_inhibited。 + +每个事件带 recovery_epoch、stage、reason_code、attempt、duration_ms、status 和必要的计数。恢复事件必须有对应的 recovered/succeeded 或 manual-required 终点,避免只有“触发”没有结论。 + +### 7.3 API 与健康响应 + +以下是 **目标契约**。除 `GET /api/health` 外均沿用现有认证/CSRF 保护。成功体直接返回资源 JSON,不使用 SimAdmin envelope;错误体沿用 SmsRelayed 已有形状: + +~~~json +{ "error": { "code": "recovery_busy", "message": "another recovery action is running" } } +~~~ + +错误 `message` 只用于展示,客户端只按稳定 `code` 分支。不得把底层命令输出拼入 message。 + +#### 7.3.1 查询恢复状态 + +`GET /api/modem/recovery` 返回 HTTP 200: + +~~~json +{ + "watchdog_state": "healthy", + "stage": null, + "epoch": 42, + "intent_generation": 7, + "user_inhibit": { "modem_disabled": false, "airplane": false, "data_disabled": true }, + "target": { "runtime_available": true, "action_verified": true, "quarantined": false }, + "evidence": { + "reason_code": null, + "consecutive_missing": 0, + "consecutive_searching": 0, + "consecutive_idle": 0, + "consecutive_transition": 0, + "last_probe_at": "2026-08-24T01:02:03Z", + "last_healthy_at": "2026-08-24T01:02:03Z", + "stale": false + }, + "cooldown_until": null, + "circuit_open_until": null, + "last_attempt": { "epoch": 41, "trigger": "watchdog", "stage": "s1_scan", "status": "succeeded", "result_code": "modem_reappeared" }, + "last_resync": { "status": "completed", "snapshot_count": 2, "persisted_count": 1, "duplicate_count": 1, "deferred_count": 0, "failed_count": 0, "finished_at": "2026-08-24T00:59:00Z" } +} +~~~ + +时间字段均为 UTC RFC 3339;未知值为 `null` 而非空串。状态枚举必须来自 4.2/6.1,额外字段可向后兼容增加,既有字段不得换义。 + +#### 7.3.2 异步操作 + +所有 POST 接受 `Idempotency-Key` header(1--128 个可打印 ASCII 字符),成功受理返回 HTTP 202: + +~~~json +{ "operation": { "epoch": 43, "kind": "sms_resync", "status": "queued", "status_url": "/api/modem/recovery/operations/43" } } +~~~ + +| 路由 | 请求 JSON | 作用与限制 | +| --- | --- | --- | +| `POST /api/modem/recovery/resync-sms` | `{ "reason": "manual" }` | 仅 reconcile,不操作 RF/MM;相同幂等键返回原 operation | +| `POST /api/modem/recovery/actions` | `{ "action": "scan|restart_modemmanager|radio_cycle", "reason": "manual", "confirm": true }` | 人工执行列明 stage;`radio_cycle` 与 `restart_modemmanager` 必须 `confirm=true`,且 target verified、无 inhibit | +| `POST /api/modem/recovery/clear-quarantine` | `{ "confirm": true, "expected_fingerprint_digest": "..." }` | 只在当前读取 fingerprint 与期望 digest 完全相同时清除 identity quarantine/circuit;不 reset、不 enable、不清用户意图 | + +`GET /api/modem/recovery/operations/{epoch}` 返回 200,体为 `{ "operation": { "epoch", "kind", "stage", "status": "queued|running|awaiting_stable|succeeded|partial|failed|interrupted|manual_required", "reason_code", "started_at", "deadline_at", "finished_at", "result" } }`。不存在或不属于当前实例授权范围返回 404 `operation_not_found`。异步失败仍通过该资源的 HTTP 200 + `status=failed` 表示;只有读取 operation 本身失败才用非 2xx。 + +统一状态码与错误码: + +| HTTP | 稳定 code | 条件 | +| --- | --- | --- | +| 400 | `bad_request` / `confirmation_required` | JSON、枚举、幂等键格式错误或危险动作未确认 | +| 401/403 | `unauthorized` / `forbidden` | 未认证、CSRF/权限不足 | +| 404 | `operation_not_found` | epoch 不存在或不可见 | +| 409 | `recovery_busy` | 另一个 state-changing epoch 正在运行 | +| 409 | `target_unverified` / `identity_mismatch` | action target 未验证或 digest 不匹配 | +| 409 | `user_inhibited` / `idempotency_conflict` | 用户意图禁止动作,或 key 被不同请求复用 | +| 429 | `recovery_rate_limited` / `circuit_open` | stage 冷却、小时上限或熔断 | +| 503 | `watchdog_disabled` / `helper_unavailable` | 功能关闭或受限 helper/依赖不可用 | +| 504 | `helper_timeout` | 请求在受理前需要的 helper handshake 超时;已受理 operation 的后续超时写入其状态 | + +保留现有受保护接口 `GET /api/modem/status`、`POST /api/modem/enable|disable|reset`,但接入 coordinator:disable 成功事务性写用户意图;enable 成功才清除;reset 与 active recovery 冲突返回 409。是否让这些旧接口也强制 `Idempotency-Key` 属于第 9.5 节决策,未决前不得静默重试 state-changing POST。 + +`GET /api/health` 继续公开,但在现有 service/modem 字段内只增加非敏感摘要:`watchdog_state`、`last_probe_at`、`last_healthy_at`、`consecutive_failures`、`current_stage`、`recovery_epoch`、`last_resync_at`、`resync_status`、`stale` 和 `user_inhibited`。公开响应不得返回 raw D-Bus path、IMSI/IMEI、电话号码、operator、SMS body、mmcli stdout/stderr 或 shell command。 + +### 7.4 指标与日志脱敏 + +建议指标: + +- sms_relayed_modem_probe_total{result,reason}; +- sms_relayed_modem_recovery_total{stage,result}; +- sms_relayed_modem_recovery_duration_seconds{stage}; +- sms_relayed_modem_recovery_circuit_open; +- sms_relayed_modem_path_rebind_total{verified}; +- sms_relayed_sms_resync_total{result}、sms_relayed_sms_resync_messages_total{result}; +- sms_relayed_inbound_subscription_reconnect_total{reason}; +- sms_relayed_data_reconnect_total{result}(仅实现 data adapter 后)。 + +指标 labels 必须来自有限枚举,不能把 path、phone number、IMSI、IMEI、operator 或 SMS body 作为 label。日志只允许记录 stage、reason_code、epoch、attempt、duration_ms、有限的 modem_slot/fingerprint hash 和结果;禁止记录 SMS body、电话号码、SIM/设备标识、完整 object path、原始 mmcli/qmicli/AT 输出、journal 文本和带 secret 的命令行。Sentry 继续使用固定 error code 和现有 scrubber,不把底层错误字符串直接作为事件 payload。 + +### 7.5 UI 行为 + +这是目标管理界面的最小行为,不限定组件库: + +1. 状态卡同时显示 modem、watchdog、SMS subscription/resync 与用户 inhibit;`stale=true` 时明确显示“数据可能过期”和最后探测时间,不能继续显示绿色 Healthy。 +2. 当前 operation 用阶段时间线展示 queued/running/awaiting stable/终态,页面刷新后通过 `status_url` 恢复。SSE 只用于加速,断线后以 2 秒轮询 active operation、稳定时 15 秒轮询恢复状态;连续错误使用 2/4/8/15 秒退避。 +3. “重扫短信”是低风险独立按钮;重复点击复用同一幂等键并禁用按钮,直到 operation 终态。UI 只展示计数,不列出 SMS 内容、号码、object path 或 fingerprint。 +4. “扫描 modem”“重启 ModemManager”“射频 cycle”“清除隔离”按风险分组。后三者必须二次确认,确认框说明可能造成短时离线;clear quarantine 要求用户确认当前设备摘要,但 UI 不回显完整标识。 +5. 409 busy 时跳转/附着到服务器返回的 active epoch(若响应提供);429 显示 `Retry-After`/冷却截止时间;503/504 显示依赖不可用,不提供无限自动重试。 +6. UserDisabled/AirplaneInhibit 时控制按钮禁用并解释来源。关闭飞行模式不得在 UI 里隐式打开此前关闭的 modem/data;手动 enable 成功前开关保持原意图状态。 +7. 兼容 SimAdmin 页面若轮询 `/api/baseband/restart/status`,adapter 可从 coordinator 投影最近 active epoch;新 UI 只使用 operation API,避免看见另一个调用者的全局步骤。 + +## 8. 避免 watchdog 反复重启的安全护栏 + +1. 启动 grace、连续失败阈值、恢复后稳定窗口和原因分离计数;单次超时不能升级。 +2. 每个 stage 独立 cooldown;S2/S3 共享小时级 token bucket,并在进程重启后由 systemd StartLimit 再兜底。 +3. 一个全局 recovery lease、一个 recovery epoch、每步 deadline;禁止重入、并发 enable/disable、并发 resync 和 recovery/action 交叉执行。 +4. identity mismatch、多 modem 歧义、action path 未验证、SIM locked/PUK、用户 inhibit 和 helper 返回未知结果时立即停止升级,进入 ManualRequired。 +5. 只读 probe 不触发 RF;不以低信号、没有新 SMS、单次 mmcli 失败或数据库短暂错误作为重启证据。 +6. ModemManager restart、RF cycle、SIM power-cycle 的成功判据必须包含 owner/path 重建、状态稳定和 SMS reconcile;失败不清零计数、不重复执行同一阶段。 +7. 默认不做 cache cleanup、remoteproc、USB reset、设备 reboot;任何启用都必须是独立配置、独立计数和人工可见事件。 +8. systemd helper 使用最小权限、明确参数白名单、非零失败码、Timeout、StartLimit 和审计日志;不得用无条件成功掩盖恢复失败。 +9. watchdog 自身异常、数据库不可用或 API 重启不能改变用户禁用意图;watchdog 退出时也不得遗留一个继续 enable modem 的 detached task。 +10. 对出站 SMS 保持至少一次和“未知结果不盲重发”语义;恢复流程不得为了清空队列而重试未知发送。 + +### 8.1 失败、恢复和幂等矩阵 + +| 故障点 | 必须记录的终态 | 自动后续 | 客户端/运维可见结果 | +| --- | --- | --- | --- | +| probe 单次超时 | `degraded` + 原因计数 1 | 下轮再 probe,不执行动作 | 状态 stale/原因码;无告警风暴 | +| scan 失败/超时 | attempt `failed:scan_failed` | 保留 missing 计数;达到 S2 阈值且未限流才升级 | operation/事件有终点,不含 stderr | +| MM restart helper 非零 | `failed:helper_failed` | 不假定 MM 已停止或已启动;probe 外部状态,必要时 ManualRequired | 503(同步受理失败)或 operation failed | +| helper 超时/连接断开 | `unknown_result`/`awaiting_stable` | 只观察 owner/path,不重复发同一动作 | 相同幂等键返回原 epoch | +| restart 后 path 改变且 identity 相同 | `awaiting_stable` → reconcile | 更新 runtime/action binding;稳定两次后成功 | path 不对外泄露,事件为 path_rebound | +| path identity 不同/不可验证 | `manual_required` + quarantine | runtime-only 可继续;禁止 RF/reset/hard recovery | 409 `identity_mismatch`/`target_unverified` | +| subscription 已恢复但 resync partial | recovery 不得 Healthy | 下个 bounded reconcile 窗口重试 deferred | 计数与 `partial`,不泄露消息 | +| 数据库写失败 | attempt 状态不能假成功 | 停止 state-changing 后续;保留/恢复 lease,进入 degraded | 503 `storage_unavailable`,重启后审计 interrupted | +| 恢复中收到 disable/airplane | 当前 epoch `interrupted` 或安全结束后 `inhibited` | intent generation 失效后续 enable/data;不升级 | 新意图立即可见;冲突请求 409 | +| 相同幂等键同请求 | 不创建新 attempt | 返回原 operation(包括终态) | 200(已有终态资源)或 202(仍 queued/running) | +| 相同幂等键不同请求 | 无状态变化 | 不执行 | 409 `idempotency_conflict` | +| 进程在动作中退出 | 启动时旧 attempt `interrupted`/`unknown_result` | BootGrace + probe + reconcile;绝不盲重放 | 历史可查,新的 epoch 需重新受理 | +| circuit/小时上限触发 | `manual_required`,持久化截止时间 | 只 probe,不升级 | 429 + `Retry-After`,UI 显示人工接管 | + +## 9. 分阶段交付 + +### Phase 0:观测与契约 + +- 增加 coordinator 状态、probe reason、worker liveness、resync 计数和脱敏日志,但默认 observer-only; +- 定义 ModemTargets、recovery epoch、user intent 和事件/API schema; +- 为时间、command runner、D-Bus source、systemd helper 建立可注入接口。 + +### Phase 1:安全重绑与 SMS reconcile + +- 将 owner/path 变化统一送入 coordinator; +- 实现新 owner 订阅、List snapshot、live signal overlap、持久化去重和 resync 结果; +- 健康接口增加 stale/worker/resync 摘要;不做 ModemManager restart。 + +### Phase 2:扫描与 ModemManager restart + +- 实现 S1 scan 和受限 systemd helper 的 S2 restart; +- 完成 path re-enumeration、fingerprint 验证、runtime/action 隔离、冷却/熔断; +- 通过 fault injection 验证 restart 期间 SMS、outbound lease 和用户 inhibit。 + +### Phase 3:RF 与人工恢复控制 + +- 在 verified action path 上实现 searching/idle/transition 的 RF cycle; +- 增加受保护 recovery status/resync/quarantine API、冲突码和审计事件; +- 继续默认关闭 S5,只有明确运行环境和硬件测试通过后才允许开启。 + +### Phase 4:可选数据和重硬件恢复 + +- 在独立 data adapter 下实现 bearer/IP/route 证据、用户意图和一次性 reconnect; +- 如确有硬件需求,再设计独立 SIM/remoteproc helper;不把它们并入普通 S2/S3 计数器。 + +### 9.5 需决策项(实现前关闭) + +| ID | 决策 | 推荐默认 | 未决定时的安全行为 | +| --- | --- | --- | --- | +| W1 | observer-only 观察期多长、何时启用 S1/S2/S3 | 现场至少 7 天;S1、S2、S3 分批开放 | `enabled=false`,只观测 | +| W2 | 单机还是多实例部署 | 单实例 + systemd 级互斥 | 不允许自动特权动作 | +| W3 | recovery attempt 保留期/上限 | 30 天或 1,000 条,先到者 | 不清理当前/限流所需记录 | +| W4 | 既有 enable/disable/reset 是否强制幂等键 | state-changing POST 全部要求 | 客户端不得自动重试 | +| W5 | clear-quarantine 是否只清 circuit,还是也允许重新登记 identity | 首版只清 circuit;identity 重新登记走独立人工流程 | identity mismatch 保持隔离 | +| W6 | S5 是否允许、具体硬件和设备映射方式 | 默认全部关闭 | 返回 503 `helper_unavailable`,不降级为首个设备 | +| W7 | 数据健康的权威证据与所有权 | 第一阶段不实现 data adapter | 只报告 registration/SMS,不自动连数据 | +| W8 | resync 候选上限、总时限、周期 | 建议 1,000 条、120 秒、300 秒 | 超限为 partial,下一窗口继续 | +| W9 | 公开 `/api/health` 暴露的恢复粒度 | 仅有限枚举、时间和计数 | 敏感/高基数字段一律省略 | + +## 10. 验收标准 + +### 10.1 功能验收 + +- 健康 modem 在至少 24 小时运行中不触发 recovery;健康 API 的 stale 行为符合缓存契约。 +- owner 变化、ModemManager 重启、InterfacesRemoved 和 signal stream 结束都能重建 subscription;新旧 owner 的 SMS handle 不交叉读取。 +- 启动 snapshot、resync snapshot 与 live Added 重叠时每条 SMS 只入库和创建 delivery 一次;不同 SMS object path 的同一短信仍按持久化 dedupe 规则去重。 +- modem path 从 /Modem/0 漂移到其他编号时,已登记 fingerprint 可恢复 runtime/action;未验证或 identity 不同只能 runtime-only/quarantine,不能执行控制动作。 +- 3 次缺失只 scan,5 次缺失才可能 restart;冷却、小时上限、systemd StartLimit 和 ManualRequired 均可观察且生效。 +- searching/idle/transition 阈值和 S3 冷却生效;单次 probe、低信号或无新 SMS 不会触发 RF cycle。 +- ModemManager restart 后 SMS reconcile 完成才报告 recovered;partial/failed resync 不得伪装成 healthy。 +- 手动 disable、airplane 和禁止 data reconnect 始终阻止自动 enable/RF/data action;手动 enable 的语义和失败回滚符合本 spec。 +- outbox 在 recovery 期间不产生重复发送;未知发送结果保留 durable lease/人工处理路径。 +- 日志、事件、指标、Sentry 和公开 API 均不含 SMS body、号码、SIM/设备标识、原始路径或命令输出。 + +### 10.2 故障注入测试 + +必须使用 fake runner/clock/D-Bus source/systemd helper;CI 不执行真实 ModemManager restart、cache 删除、remoteproc 或设备重启。至少覆盖: + +1. mmcli 缺失、5 秒超时、JSON 不支持、status parse 失败和短暂恢复; +2. D-Bus 无法连接、owner 变化、owner 不变的 NameOwnerChanged、stream end、InterfacesRemoved、错误 sender/path 的噪声 signal; +3. 旧 path 消失后新 path 同编号/不同编号、单候选、多候选、identity 暂不可读、fingerprint mismatch 和 fingerprint 恢复; +4. scan 成功但 object 延迟出现、scan 连续失败、restart helper 超时/非零/返回未知结果; +5. searching、enabled/idle、connecting/disconnecting 各自在阈值前后变化,验证不会提前或重复 RF cycle; +6. snapshot 与 live Added 同一 SMS、重启期间到达 SMS、空 body 延迟、properties 五次失败、16 个并发满载和第 17 条 backpressure; +7. D-Bus subscription 恢复但 snapshot 部分失败、重复 resync 请求、resync 与恢复动作并发; +8. outbound SMS 在 prepare/send/未知结果时触发 restart,验证不会盲目重发; +9. recovery 进行中手动 disable、airplane、enable、reset,验证 lease、generation、409 和用户意图; +10. 连续恢复失败触发 300 秒冷却、指数退避、小时 token bucket、circuit open 和 ManualRequired,并验证恢复后稳定窗口才清零; +11. 可选 data adapter 的 bearer/IP/route 缺失、NM activation 超时、用户禁用和恢复后一次 reconnect; +12. 进程重启、数据库临时不可用、API 客户端断开和 watchdog task cancellation,验证无 detached recovery action 和无丢失的用户 inhibit。 + +## 11. 交付结论 + +SmsRelayed 已经有较可靠的 D-Bus owner 重连、启动 SMS snapshot、并发限制、持久化去重和 path/identity 隔离基础;缺少的是统一 coordinator、连续证据驱动的恢复阶梯、ModemManager/systemd 边界、恢复后 SMS reconcile、数据连接所有权和可观测状态。本功能应先交付 Phase 0/1,再在故障注入和现场观察通过后逐步开放 S2/S3;S5 永远保持显式授权和人工可见。 + +## 12. 参考实现来源索引 + +全部位置均相对于 `/tmp/SimAdmin`,基线为 `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c`;行号用于定位,若移植提交应同时以符号名核对。 + +| 事实 | 来源(路径 + 符号/行号) | +| --- | --- | +| watchdog 启动延迟、15 秒周期、data/airplane flags | `backend/src/main.rs` `data_user_disabled`/`airplane_mode_requested` 约 328–329 行;watchdog spawn 约 425–467 行 | +| 阈值和冷却常量 | `backend/src/modem_manager.rs` `MODEM_SCAN_THRESHOLD` 等约 56–66 行;`TRANSITION_STUCK_THRESHOLD`/`ENABLED_IDLE_RECOVERY_THRESHOLD` 约 6246–6272 行 | +| watchdog 全流程、内存计数与恢复分支 | `backend/src/modem_manager.rs` `data_connection_watchdog` 约 6246–6686 行 | +| iptables 只读诊断、基带运行时暂停 | `backend/src/modem_manager.rs` `data_connection_watchdog` 约 6273–6300 行 | +| searching/idle/transition 射频 cycle 与不一致冷却 | `backend/src/modem_manager.rs` `data_connection_watchdog` 约 6330–6535 行 | +| scan、MM restart 与 300 秒冷却 | `backend/src/modem_manager.rs` `data_connection_watchdog` 约 6585–6677 行 | +| 无 timeout 的命令 runner 与有限时 runner | `backend/src/modem_manager.rs` `run_recovery_command`/`run_recovery_command_owned` 约 5763–5810 行 | +| 进程级全局 restart progress | `backend/src/modem_manager.rs` `reset_baseband_restart_progress`/`get_baseband_restart_progress` 约 5829–5875 行 | +| 同步基带 restart API、成功失败均 HTTP 200 | `backend/src/main.rs` routes 约 682–687 行;`backend/src/handlers.rs` `restart_baseband_handler`/`get_baseband_restart_status_handler` 约 2406–2441 行 | +| profile 恢复停启 MM、QMI power、15 秒重新枚举 | `backend/src/modem_manager.rs` `power_cycle_sim_for_profile_switch_inner` 约 5916–6131 行;`find_qmi_device_path`/`qmicli_sim_power` 约 2218–2275 行 | +| 全局进程内 D-Bus/AT 互斥 | `backend/src/serial.rs` `DBUS_LOCK`/`with_serial` 约 1–29 行 | +| 数据用户意图持久化、飞行请求仅内存 | `backend/src/main.rs` 约 328–329 行;`backend/src/handlers.rs` `set_data_status` 约 2337–2404 行、airplane handlers 约 2520–2601 行 | +| 无界 resync channel、SMS marker/持久化去重 | `backend/src/sms_listener.rs` `sms_resync_channel`/`request_scan` 约 34–59 行;`sms_marker`/`process_sms_path` 约 78–103、192–289 行 | +| initial/poll/requested SMS scan 与 15 秒轮询 | `backend/src/sms_listener.rs` `scan_sms_paths`/`start_sms_listener` 约 297–337、400–535 行 | +| 启动救援的固定 modem 0、缓存删除、remoteproc、无条件成功 | `scripts/simadmin-modem-recovery.sh` 约 1–95 行 | +| oneshot + RemainAfterExit、无 Timeout/StartLimit | `scripts/simadmin-modem-recovery.service` 约 1–11 行 | diff --git a/docs/specs/simadmin/04-automation-scheduler.md b/docs/specs/simadmin/04-automation-scheduler.md new file mode 100644 index 0000000..d65e512 --- /dev/null +++ b/docs/specs/simadmin/04-automation-scheduler.md @@ -0,0 +1,696 @@ +# 功能 4:自动化调度中心 + +状态:设计规格,未实现 + +## 0. 证据边界与阅读约定 + +本文核对的 SimAdmin 基线是 `/tmp/SimAdmin` 的 clean `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c`。除非另有说明: + +- 标题或段落中写明“SimAdmin 已确认”的内容,表示可由该提交的代码直接证明,是兼容性参考,不自动成为 SmsRelayed 的目标行为。 +- “重建契约”“必须”“应”表示本文为 SmsRelayed 规定的目标语义;它可以有意修正参考实现的并发、幂等、安全或恢复缺陷。 +- “建议”“推荐默认值”“可选”表示尚可在实现评审时调整;调整后必须连同 API schema、migration 和测试 fixture 一起固化,不能让前后端各自猜测。 +- 本文不要求复刻 SimAdmin 的 HTTP 200 错误 envelope、固定北京时间、进程内去重或直接 Modem 发送路径。需要兼容旧客户端时,应增加显式 compatibility adapter,不能污染下述新领域模型。 + +## 1. 背景与设计结论 + +本规格把 SimAdmin 的自动化中心迁移为适合 SmsRelayed 的“持久化调度 + 既有业务 worker 执行”模型。调度器只负责判断何时运行、创建一次执行记录、取得资源准入并调用领域 handler;它不复制短信发送、转发、Modem 恢复或消息恢复逻辑。 + +核心结论如下: + +1. 所有自动发送必须进入现有 `Messaging` 出站状态机,不能直接调用 ModemManager 或另建一条发送路径。 +2. 调度执行与短信消息是两个不同的实体。一次执行可以关联一个出站 `message_id`,但不能用“插入一条消息”代替执行记录。 +3. `Pending`、`uncertain`、`unknown` 是“结果尚未确定”,不是失败;任何超时或进程重启都不得据此盲目重发。 +4. 转发 worker、出站短信 worker、Modem/eSIM 恢复和设备重启必须通过明确的资源准入协议协作,不能靠任务名称或 UI 约定避免冲突。 +5. 运行日志是审计事实,内存中的轮询去重只能是优化,不能是正确性保证。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 提供固定时间、固定间隔和手动触发三种统一模型。 +- 在服务重启、调度器重启、网络抖动、Modem 重连和时钟变化后保持可解释、可恢复的执行状态。 +- 为每次执行提供持久化的 run ID、状态、尝试、触发来源、调度时刻、锁冲突、错误码和关联消息。 +- 复用 SmsRelayed 已有的短信幂等、出站 lease、Modem 对象恢复、转发 lease 和 retention 语义。 +- 通过 HTTP API、SSE 和前端 Automation Center 管理任务、立即执行、取消、查看运行记录和调度健康状态。 +- 让自动任务、人工发送、转发 worker 和 Modem/eSIM 恢复在并发下有确定的行为。 +- 为后续安全地加入基带重启、eSIM 切换和设备重启建立资源锁、确认和恢复基础。 + +### 2.2 非目标 + +- 第一阶段不提供任意 shell/脚本执行、任意 D-Bus 方法调用或用户自定义插件。 +- 不替换 `Messaging`、`DeliveryWorker`、`InboundWorker`、出站恢复逻辑或 retention worker。 +- 不把短信转发 profile 当作自动化通知渠道,也不把自动化结果发送成短信形成自触发循环。 +- 不实现 SimAdmin 的 Hub 中央调度、设备本地 fallback 或多节点 leader 选举;SmsRelayed 当前按单进程、单 Modem 实例设计。 +- 不承诺“发送超时后自动再发”;无法证明未发送时,必须停在未确定状态。 +- 不在第一批任务中默认启用设备重启、Modem reset、基带重启或 eSIM profile 切换。 + +## 3. 参考实现与现状差异 + +### 3.1 SimAdmin 的触发模型 + +SimAdmin 当前的 scheduler 每 30 秒轮询一次配置: + +- 固定时间任务以星期一至星期日和 `HH:MM` 匹配,使用固定的北京时间 `+08:00`;`fixed_last_run` 只在内存中防止同一分钟重复触发。 +- 间隔任务以该任务最后一条执行日志的时间为基准;没有日志时立即执行。失败日志也会成为下一次间隔的基准。 +- 任务配置只有 fixed 和 interval,没有持久化的 manual trigger 类型;手动测试通过另一个 API handler 后台启动。 +- handler 通过 `AutomationTaskHandler` trait 注册,现有类型包括 `restart_baseband`、`reboot_device`、`backup_data`、`send_sms`。 +- scheduler 为每个任务直接 `tokio::spawn`,没有统一的任务锁、资源锁、重叠策略或持久化调度 slot。 +- 每次执行写入 SQLite automation log,状态只有 success/failed;随后构造 `AutomationEvent`,交给通知规则、quiet hours、限流和通知队列。 +- 默认执行超时为 60 秒,短信的随机延迟还会叠加在等待时间上;设备重启 handler 在真正重启前就返回成功,因此日志可能早于实际结果。 +- `/automation/test/{task_id}` 与 scheduler 执行路径没有统一的并发控制,可能和定时执行同时运行。 +- Hub 在线时由 Hub 负责调度;设备本地任务在 Hub 离线超过 fallback 时间后恢复,Hub 规则不会下发并保存到设备本地。 + +这些行为适合作为产品交互参考,但不能原样复制:固定 offset、内存去重、以最后日志推导 interval、直接 spawn 和“重启已成功”都会在 SmsRelayed 的未知发送状态、设备重启和恢复场景下产生错误结论。 + +#### 3.1.1 SimAdmin 已确认的配置与 HTTP 形状 + +参考实现把全部任务作为一个配置对象整体读写;没有单任务 CRUD、revision、run resource、取消或 scheduler health API: + +```json +{ + "enabled": true, + "tasks": [ + { + "id": "task-...", + "name": "...", + "enabled": true, + "trigger": { + "type": "fixed", + "config": { "weekdays": [1, 2, 3, 4, 5, 6, 7], "times": ["04:00"] } + }, + "action": { "type": "restart_baseband", "config": null } + } + ] +} +``` + +`trigger` 只接受 `fixed {weekdays,times}` 或 `interval {interval_value,interval_unit}`。`action` 只接受 `restart_baseband`、`reboot_device {delay_seconds}`、`backup_data {components,storage.local_dir}`、`send_sms {phone_number,content,random_delay_seconds?,retry_limit?}`。Rust 默认只有 `automation.enabled=true,tasks=[]`;业务范围和字符串格式没有统一后端 validation 层。 + +| 方法与路径 | 已确认请求 | 已确认成功 data | 已确认失败兼容语义 | +| --- | --- | --- | --- | +| `GET /api/automation/config` | 无 | 上述完整配置 | 通常仍是 HTTP 200 envelope | +| `POST /api/automation/config` | 完整 `AutomationConfig` JSON;覆盖整个数组 | `{}` | 保存失败仍为 HTTP 200,envelope `status:error` | +| `GET /api/automation/logs` | query:`type,status,q,start_date,end_date,limit,offset`;limit 默认 100,DB 层 clamp 为 1..200,offset 最小 0 | `{logs,total}`;倒序 | 查询失败仍为 HTTP 200 error envelope | +| `POST /api/automation/logs/clear` | 可选 JSON:`type,status,start_date,end_date` | `{deleted}` | 同上 | +| `POST /api/automation/test/{task_id}` | 无 body 要求 | 立即返回 `{}`,只表示后台 future 已 spawn | task 不存在仍 HTTP 200 error;没有 run ID | + +通用 envelope 是 `{status,message,data}`;因此兼容客户端必须先检查 body 中的 `status`,不能只检查 HTTP status。SmsRelayed 的重建 API 不沿用这一歧义。 + +#### 3.1.2 SimAdmin 已确认的精确执行边界 + +- fixed 匹配时先把 `task_id -> YYYY-MM-DD HH:MM` 写进进程内 map,再 spawn handler;同一分钟内即使 handler 失败也不会由 scheduler 再触发。进程重启会丢失 map,同一分钟内重启可能再运行一次。 +- interval 从最后一条已完成 automation log 的北京时间字符串计算。没有日志、时间无法解析时立即触发;未知 unit 被当作 180 天。run 执行期间尚无新日志,因此一个运行超过 30 秒的 interval task 可能被重复 spawn。 +- scheduler 首次评估发生在启动约 30 秒后,之后每 30 秒扫描。它没有 durable slot、scheduler lease、全局并发上限、per-task mutex 或资源锁。 +- scheduler 仅在全局 enabled、task enabled 且本地拥有 Hub 调度权时运行;手动 test 路径不检查全局 enabled、task enabled 或 Hub ownership,也不复用 scheduler 的 `execute_task` 准入检查。 +- handler 外层 deadline 是 `60 + configured_random_delay_seconds` 秒,不是“实际随机延迟 + 60 秒”;超时详情固定写“超过60秒限制”。日志只产生 `success|failed`,没有 queued/running/unknown。 +- `send_sms` 支持的模板变量只有 `{{时间}}` 和 `{{随机字符串}}`;发送前随机等待为 `[0,random_delay_seconds)`,总尝试次数是 `retry_limit + 1`,失败间隔固定 5 秒。它直接调用 ModemManager,之后另行插入 SMS 行,不具备稳定幂等 key。 +- `reboot_device` 只 spawn 延迟重启 future 就返回 `Ok(())`,所以 run log 可在机器真正重启或失败前写 success。`backup_data` 同步生成本地 ZIP;`restart_baseband` 复用当前数据/漫游/APN 状态。 +- 每次完成后先插入 SQLite `automation_logs`,再同步调用 automation notification;通知失败只 warning,不改变业务 log 的 success/failed。 + +#### 3.1.3 SimAdmin 已确认的前端行为 + +- 新任务默认 action 为 `restart_baseband`;设备重启延迟 5 秒;短信随机延迟 120 秒、retry limit 3;fixed 默认为每天 04:00;interval 默认为 180 天。 +- task ID 由浏览器时间戳和 `Math.random()` 生成。新增、编辑、启停和删除都会重发完整任务数组;保存时前端强制全局 `enabled=true`,没有 ETag 或并发编辑保护。 +- “测试执行”拿到 HTTP 响应即显示“指令已下发”,1.5 秒后刷新配置/日志;长任务可能尚无日志,页面也没有可继续跟踪的 run ID。 +- 卡片状态来自最近 100 条日志在前端按 task ID 聚合;因此历史超过窗口或并发执行时,卡片不是可靠的当前运行状态。 + +### 3.2 SmsRelayed 当前运行能力 + +`src/runtime.rs` 当前启动并行的长期工作包括: + +- `InboundWorker`:从 Modem 接收短信,持久化 inbound 消息并用 dedupe key 抑制重复;插入时可在同一事务创建 forwarding deliveries。 +- `DeliveryWorker`:只负责把 inbound 消息投递到配置的 forwarding profiles。每条 delivery 有数据库 lease、owner token、重试时间、attempt sample 和 terminal 状态;转发语义是 at-least-once,网络超时可能导致重复转发。 +- `Messaging` outbound worker:处理创建、准备 Modem SMS 对象、发送、恢复和出站事件发布。 +- retention worker:约每 6 小时运行;无论消息 retention 是否启用,都会清理超过 30 天且已解除关联的 outbound idempotency key。 +- HTTP API、SSE EventBus、Modem 状态/动作和 service restart control。 + +当前没有 automation 配置、automation 表、scheduler lease 或通用 automation notification。`AppEvent` 目前包含消息、配置保存和 service restart 事件;SSE 是进程内 broadcast 的实时视图,不是可靠审计存储。 + +### 3.3 短信幂等与未确定状态 + +现有 `Messaging::send` 会先持久化出站消息并返回 `Sent`、`Failed` 或 `Pending`。出站状态机包含 `created`、`prepared`、`send_started`、`uncertain`、`unknown`、`complete` 等阶段,并用 owner/lease 防止多个 worker 同时推进同一消息。 + +- Web API 的 `/api/messages/send` 要求 `Idempotency-Key`。同 key 且 phone/body/source 相同会复用同一消息;同 key 不同请求会返回冲突。 +- Modem 已接受请求但本地没有可靠结果时,消息保持 `sending`,出站恢复会检查同一个 Modem SMS 对象;不会盲目创建第二个对象重发。 +- `Pending` 的含义是 outcome unresolved;CLI 也会在存在未确定出站消息时提醒用户,不应直接再次发送。 +- 服务重启恢复时,尚未准备 Modem 对象的创建阶段可标记为中断失败;已准备、已开始、uncertain 或 unknown 的消息必须先 reconcile,无法证明未发送就保持 unknown/pending。 +- 删除规则禁止删除大多数仍在发送中的消息;unknown 是可供人工处理的特殊状态。消息删除后,关联 idempotency key 可能暂时保留,但超过保留窗口或关联消息不可用后,旧 key 不保证可 replay。 + +### 3.4 retention 与 API 能力 + +SmsRelayed 使用 SQLite/WAL、外键、状态约束和事务。消息 retention 按 `max_age_days` 分批清理 terminal 消息,但只要存在 pending/in_flight/retry_wait 的 forward delivery 就保留消息;发送中的消息不会被当作普通 terminal 数据清理。当前配置使用 TOML,HTTP 配置变更通过 revision/ETag 防止覆盖并可能需要 service restart。 + +现有 API 能力包括: + +- `/api/messages`、`/api/conversations`:消息和会话查询、读取、收藏、删除。 +- `/api/messages/send`:带 idempotency key 的发送。 +- `/api/events`:SSE 消息/配置/服务事件。 +- `/api/config`、`/api/status`、`/api/health`、`/api/service/restart`。 +- `/api/modem/status`、`/api/modem/enable`、`/api/modem/disable`、`/api/modem/reset`;Modem 动作已有 `action_in_progress`、reset rate limit、确认和同源防护。 +- `/api/forwarding/attempts`:forwarding profile 最近尝试样本。 + +因此,automation 应增加编排 API,而不是把任务数组塞入现有 `/api/config` 或复制 `/api/messages/send` 的实现。 + +## 4. 推荐领域模型 + +### 4.0 术语 + +| 术语 | 重建契约中的唯一含义 | +| --- | --- | +| task definition | 可编辑、带 revision 的长期计划与 action 定义;不是一次执行 | +| slot | scheduler 根据某个 task revision 算出的一个应触发 UTC instant | +| run | 对一个 slot 或一次 manual 请求的持久化执行实例,拥有不可变 run ID | +| attempt | run 在取得准入后的一次 handler 尝试;retry 会新增 attempt,不新增同一 slot 的 run | +| trigger source | `scheduled|manual|recovery`;manual 是 run 来源,不是持久 schedule 类型 | +| waiting | 外部 durable worker 已接受工作、结果尚未 terminal | +| unknown | 外部副作用是否发生无法证明;既不是 failed,也不能自动重试 | +| blocked | 当前不满足资源、维护窗口或 capability 准入;必须带稳定 reason code | +| skipped | 策略明确决定不执行该 slot,例如 misfire/overlap=skip;不是异常失败 | +| resource lease | orchestrator 对共享资源的有期限所有权;不同于 Messaging/DeliveryWorker 自己的数据行 lease | +| action fingerprint | task revision、规范化 action type/params 和 actor category 的稳定摘要,用于检测幂等 key 参数漂移 | + +### 4.1 TaskDefinition + +任务定义是用户管理的长期对象,建议字段如下: + +```text +TaskDefinition + id: opaque UUID/string, immutable + revision: monotonically increasing integer + name: display name + enabled: bool + timezone: IANA timezone, explicit per task + trigger: Fixed | Interval + action: registered action payload + policy: + max_concurrency = 1 by default + overlap = skip | coalesce | queue, default skip + timeout + retry policy + dangerous confirmation/maintenance-window policy + created_at, updated_at: UTC RFC3339 + deleted_at: optional UTC RFC3339, preferably soft delete +``` + +Manual 不是 task definition 的第三种永久 schedule,而是一个合法的 `trigger_source=manual`。同一个 task 可以被固定/间隔调度,也可以被 API 手动运行;两者必须进入同一 run admission 和锁路径。 + +### 4.2 Trigger + +#### Fixed + +```text +Fixed { + timezone: "Area/Location", + weekdays: [1..7], + local_times: ["HH:MM"], + misfire: skip | fire_once | next_valid_time +} +``` + +固定时间按照 task timezone 的民用时间计算,但 `scheduled_for` 永远保存为 UTC instant。一个实际触发 slot 使用 `(task_id, task_revision, scheduled_for)` 唯一约束,不能用进程内 HashMap 作为唯一去重手段。 + +#### Interval + +```text +Interval { + every: positive duration, + anchor: created_at | explicit_start, + misfire: skip | fire_once +} +``` + +间隔是经过时间的 duration,以 UTC instant/单调时钟计算,不从“最后一条日志”推导;失败、跳过或锁冲突是否推进计划由持久化的 `scheduled_for` 和 misfire policy 决定。默认 anchor 为任务创建/启用时刻,停机期间不补发一串历史 interval,只按 `fire_once` 最多补一个。 + +#### Manual + +`POST /api/automation/tasks/{id}/runs` 创建一次 run。请求可以带调用方 idempotency key、参数覆盖和 `dry_run`(只对支持的 handler 有效),但不能绕过 task 的 schema、资源锁和危险动作保护。 + +### 4.3 时区、DST 和时间存储 + +- 全部数据库时间使用带 `Z` 或明确 offset 的 RFC3339;UI 可按浏览器时区展示,但不改变任务语义。 +- 每个固定时间任务必须保存 IANA timezone,不能只保存 `+08:00`。全局默认建议为 `Etc/UTC`,创建任务时前端应明确显示并要求确认;使用中国时间时保存 `Asia/Shanghai`,不能保存 `+08:00` 代替它。 +- DST 春季跳过的 local time 默认 `skip` 并记录 `dst_nonexistent`;`next_valid_time` 必须显式选择,不能静默提前或延后。 +- DST 秋季重复的 local time 默认只触发一次,使用较早的有效 instant,并记录 `dst_ambiguous`;如确实需要两次,任务必须选择 `both_occurrences`,且 slot key 包含 offset。 +- interval 不受夏令时前进/回拨影响;固定时间才受 wall-clock 规则影响。 +- 系统时钟回拨、NTP 校时或睡眠唤醒不能导致同一 slot 重跑。scheduler 每次计算应记录 `observed_at`、`scheduled_for` 和 misfire 原因,避免以当前时间直接覆盖事实。 + +### 4.4 Execution/Run + +每次触发创建一个持久化 `AutomationRun`: + +```text +AutomationRun + id: UUID + task_id, task_revision + trigger_source: scheduled | manual | recovery + scheduled_for: nullable UTC instant + state: queued | running | waiting | succeeded | failed | + timed_out | cancelled | skipped | blocked | unknown + attempt, max_attempts + idempotency_key / action_fingerprint + linked_message_id: optional + resource_snapshot: names acquired or conflict reason + started_at, finished_at, next_retry_at + error_code, redacted_detail + cancellation_requested +``` + +`waiting` 表示 handler 已提交到既有 durable worker,正在等待可观察的 terminal 结果;`unknown` 表示无法判断外部副作用是否发生;二者都不能被 UI 误显示为失败。`skipped`/`blocked` 必须记录是 disabled、重复 slot、锁冲突、维护窗口、Modem 不可用还是 misfire。 + +`AutomationRunAttempt` 保存每次准入和 handler 尝试的开始/结束、超时、错误码、重试决策和锁信息。日志可以被 retention 清理,但 run ID、关联消息 ID 和最终状态必须足以解释一次动作。 + +### 4.5 Handler 注册与契约 + +保留 SimAdmin `AutomationTaskHandler` 的注册思想,但 handler 应遵守统一异步契约: + +```text +validate(params, capabilities) -> normalized params +resources(params) -> ordered resource set +execute(ctx {run_id, deadline, cancellation, store, messaging, modem}) + -> Succeeded | Waiting(link) | Failed(code) | Unknown(code) +``` + +handler 不自行创建 scheduler task、不直接写 automation log、不直接发通知、不自行实现无限重试。所有结果、取消、超时、attempt 和事件由 orchestrator 统一落库。短信 handler 只能依赖 `Messaging` 接口;retention handler 只能调用现有 store retention;Modem handler 只能依赖带 action gate 的 Modem service。 + +## 5. 调度、锁、并发与故障语义 + +### 5.1 Scheduler loop 与 durable lease + +- scheduler 可以使用短轮询加精确 next wake,但每轮必须从数据库读取 enabled task 和持久化 next slot;30 秒轮询只能作为安全扫描。 +- 进程启动时生成 owner UUID,在 `scheduler_leases` 取得带过期时间的单实例 lease;失去 lease 后停止领取新任务,仅允许已提交的业务 worker完成恢复。 +- 领取 slot 使用 SQLite `BEGIN IMMEDIATE`/条件更新和唯一约束。先写 queued run,再提交;commit 成功才可 spawn handler。 +- 重新启动时,过期的 scheduler lease、queued run 和 running run 都要被扫描。queued 可重新排队;running 不能直接改成 failed,应依照 handler 类型标记 `interrupted`/`unknown` 或进入 reconcile。 +- 不使用无限 `tokio::spawn`。scheduler 有全局 max running、每 task max concurrency 和资源锁;排队、跳过和阻塞都写 run。 + +### 5.2 资源锁 + +建议使用统一的资源名称和固定获取顺序: + +1. `system.lifecycle`:设备/系统重启和服务生命周期。 +2. `modem.recovery`:Modem/eSIM 恢复、profile 切换、基带恢复。 +3. `modem.control`:enable、disable、reset、基带控制。 +4. `modem.sms`:通过 Modem 发出站短信。 +5. `storage.maintenance`:长时间或批量数据库维护;实际 SQLite transaction 必须短,不能持有它等待网络或 Modem。 + +锁是带 owner、过期时间和诊断信息的 lease。任何 handler 只能声明自己需要的集合;禁止先取得低序锁再等待高序锁。`modem.sms` 的并发默认 1,人工发送和自动发送共用同一准入队列;这不改变现有 outbound worker 的 durable lease,只限制 Modem 交互。 + +同一 task 默认禁止重叠。固定任务默认 `skip`,interval 默认 `coalesce` 一个待运行 slot;用户若选择 queue,必须有上限,队列满时记录 blocked。手动执行不能绕过同 task 的重叠策略。 + +### 5.3 超时、取消与重试 + +- 每个 run 有排队超时、handler deadline 和整体超时;超时后发送取消通知,handler 必须尽力停止等待。 +- 取消只取消“尚未产生外部副作用”的工作。短信已进入 `send_started` 后不能因为 HTTP timeout/cancel 停止并重发,必须等待 `Messaging` reconcile;run 进入 waiting/unknown。 +- Modem reset、基带操作和设备重启不能在不确定阶段自动重试。取消窗口只存在于危险动作 commit 前。 +- handler 错误需分类为 `validation`、`blocked`、`not_attempted`、`transient`、`permanent`、`unknown`。只有明确 `not_attempted` 或可证明无副作用的 transient 才可自动重试。 +- retry 使用持久化次数、指数退避和确定性 jitter,并有总时限;不能同时叠加 SimAdmin 式 handler 内部盲重试与 scheduler 重试。 +- 对短信,首选一次 `Messaging::send` 并等待/关联出站消息;其内部的 finalizer、outbound worker 和恢复机制是发送重试边界。已知 failed 后若要重新发送,应创建新的显式 action attempt,并先记录前一次已确定终止,不能复用一个可能已发送的操作。 + +### 5.4 幂等 + +- scheduled run 的唯一键为 `(task_id, task_revision, scheduled_for)`;同 slot 再次被扫描时返回原 run。 +- manual run 支持 `Idempotency-Key`;同 key 必须绑定 canonical task revision、action 参数和 actor category,不同参数返回 409。 +- 短信 action 为每个 run/step 生成稳定的 outbound idempotency key,并传给 `Messaging`。重启、scheduler 重试或客户端重复请求都只能得到同一 `message_id`,不得创建第二条短信。 +- automation run 的 key 生命周期不能短于它的 pending/unknown 处理窗口;消息 retention 删除关联消息后,不应假装旧 key 仍可 replay,而是向 UI 返回 replay unavailable,需要人工创建新的 run。 +- retention、日志清理和 schedule 计算都必须是可重复的。备份类动作若以后加入,应使用 snapshot ID、临时文件和原子 rename;不能通过“再次执行”覆盖未知的半成品。 + +### 5.5 设备重启和其他危险动作 + +设备重启、Modem reset、基带重启和 eSIM profile 切换属于不可逆或会中断通信的动作: + +- 第一批默认不开放;必须有全局 `allow_dangerous_actions=false`、任务级 enabled、维护窗口、冷却时间、明确 confirmation phrase 和 authenticated actor。 +- 执行前写入 durable plan/commit marker,阻止新的短信和 Modem destructive action;没有 commit 的任务可以取消,已有 commit 的任务不可撤销。 +- `reboot_device` 不得在发出 reboot command 后立即记 success。成功需要 post-boot marker、服务重新启动并恢复数据库后确认;否则为 `unknown`/`interrupted`,等待人工检查。 +- reboot 前不得尝试“为了完成任务”重发所有 pending SMS。出站恢复仍按已有 Modem 对象和 unknown 规则处理。 +- 恢复后清理过期 lock,检查本次 generation、Modem fingerprint/path 和未完成 run;任何旧 owner 不能继续操作新 Modem。 + +## 6. 第一批适合 SmsRelayed 的任务 + +第一阶段只交付低风险、可用既有状态机解释的任务: + +### 6.1 `send_sms` + +支持 fixed、interval、manual。参数为目标、正文模板和可选业务 metadata;模板只允许白名单变量(例如计划时间、run ID 的短 hash),不允许执行表达式或读取任意文件。 + +执行流程: + +1. 校验目标和正文大小,在创建 run 前完成脱敏/规范化。 +2. 取得 `modem.sms` 准入;通过 `Messaging::send` 创建带稳定 idempotency key 的 outbound message。 +3. run 保存 `linked_message_id`,等待 `MessageUpdated` 或重新从 store 查询。 +4. `sent` 映射为 succeeded,已知 `failed` 映射为 failed;`Pending`、`sending`、`uncertain`、`unknown` 映射为 waiting/unknown,禁止自动盲重发。 + +自动任务不能直接复制 SimAdmin `send_sms` 中的随机延迟、逐次 Modem 直发和无限外层 retry。若需要发送节流,应在 `modem.sms` admission queue 中实现并记录排队时间。 + +### 6.2 `run_retention` + +支持 fixed、interval、manual。它调用既有 `MessageStore::run_retention`,沿用 terminal message、active forwarding delivery 和 batch size 规则,不删除 pending/unknown outbound。该任务只取得短时 `storage.maintenance`,不持有锁等待网络,不暂停 DeliveryWorker。 + +automation run 的保留策略独立于消息 retention:默认可沿用 90 天/最多 10000 条,但 automation log 的清理不能改变短信 idempotency 或出站消息安全窗口。 + +### 6.3 `reconcile_outbound` + +可选地提供只读/低风险手动任务,调用现有出站恢复/状态检查,报告仍为 sending/unknown 的 message ID 数量和错误码,不创建发送动作。它不能替代启动时的 outbound worker,也不能由 interval 高频运行。 + +### 6.4 后续任务 + +以下任务在资源协作和恢复验收完成后再开放: + +- `modem_reset`/`restart_baseband`:需要 `modem.recovery + modem.control`,与现有 Modem action gate 合并。 +- `esim_profile_switch`:需要 Modem generation、profile 验证和恢复后 capability 检查。 +- `device_reboot`:需要 `system.lifecycle`、计划/commit/post-boot 机制和人工确认。 +- `backup_data`:只有在确认 SmsRelayed 的数据库/配置备份边界、敏感数据保护和原子写入后再加入;不能照搬 SimAdmin 的本地备份路径而暴露 password、auth key 或短信正文。 + +## 7. 如何避免与现有 worker 和人工操作冲突 + +| 参与者 | 统一入口/资源 | 自动化规则 | 其他规则 | +| --- | --- | --- | --- | +| 人工 Web/CLI 发送 | `Messaging`、`modem.sms` | 自动任务不得抢占或另建 Modem 发送路径 | CLI 无 key 时仍由现有 pending 提示保护;Web key 冲突按现有 409 语义处理 | +| 自动发送 | `Messaging`、`modem.sms` | 使用稳定 outbound idempotency key;等待出站 terminal 或标记 unknown | scheduler timeout 不取消已开始的副作用 | +| Inbound/forwarding worker | `forward_deliveries` lease、网络 profile | automation 不调用 DeliveryWorker,也不把 forward delivery 当短信发送结果 | at-least-once 和网络重复转发保持原语义;retention 不删 active delivery | +| Modem/eSIM recovery | `modem.recovery`、`modem.control` | recovery 期间阻止新的 destructive task 和新的 Modem SMS admission | 已有 outbound 仍由 Messaging reconcile;不因 recovery 失败而重发 unknown | +| `/api/modem/*` 人工动作 | 现有 Modem action gate | automation 必须使用同一个 gate;`action_in_progress` 转为 blocked/409 | reset 的确认、限流、同源检查不能被 scheduler 绕过 | +| 设备/服务重启 | `system.lifecycle` | 先 durable plan,再 commit;阻止新的 destructive action | service restart 与 device reboot 状态分开记录;不提前写 succeeded | +| retention/DB maintenance | `storage.maintenance`、短 SQLite transaction | 不等待 Modem/HTTP,不改消息发送状态机 | automation log 单独 retention;使用 SQLite 事务和 batch | + +锁冲突只会导致 `blocked`/`skipped` 或可观测排队,不会让 handler 私自绕过锁。手动操作和自动操作的 priority 可区分,但必须公平并设置最大等待时间,防止 interval 任务饥饿人工发送,也防止连续人工操作永久饿死定时任务。 + +## 8. 配置与数据库持久化 + +### 8.1 TOML 全局配置 + +建议只在现有 TOML 增加全局策略和安全默认值,不把完整任务数组存进 `/api/config`: + +```toml +[automation] +enabled = true +default_timezone = "Etc/UTC" +max_running = 2 +sms_lane_concurrency = 1 +run_retention_days = 90 +run_retention_max_entries = 10000 +allow_dangerous_actions = false +``` + +现有配置保存采用 revision/ETag、原子安全写入并可能需要 restart;automation 任务的 CRUD 和启停应实时生效,不能要求用户通过保存整个 AppConfig 来修改任务。全局策略变更若需要 restart,API 必须明确返回,不得静默使用旧配置。 + +### 8.2 SQLite 表建议 + +通过 schema migration 增加以下表和索引: + +- `automation_tasks`:任务定义、revision、trigger/action/policy JSON(或规范化列)、timezone、enabled、soft delete、created/updated。 +- `automation_runs`:run ID、task revision、trigger source、scheduled_for、state、attempt、idempotency key、linked message ID、deadline、next retry、redacted detail、时间戳。 +- `automation_run_attempts`:每次尝试、错误码、等待/锁诊断、开始/结束和 retry decision。 +- `automation_schedule_slots` 或 `automation_runs` 上的唯一索引:保证 task revision/scheduled slot 只产生一个 run。 +- `automation_scheduler_leases`:单实例 owner、lease expiry、last heartbeat、schema version。 +- 可选 `automation_event_outbox`:需要可靠外部通知时持久化事件;不能只依赖 `EventBus` broadcast。 + +写 task、创建 scheduled run、更新 slot 状态必须在短事务中完成。所有 JSON 进入数据库前由 handler registry 做 schema validation;禁止将未校验的 action type 直接反序列化后执行。 + +任务删除默认 soft delete:新 slot 不再触发,历史 run 和审计仍可查询。硬删除必须有明确管理 API,并且不能删除仍有 waiting/unknown run 的 definition metadata。 + +automation log retention 单独按天数和最大条数批量清理,保留 non-terminal run,或先转换为 `interrupted/unknown` 再按审计策略处理。不得复用消息 retention 的删除条件清理 automation run。 + +## 9. API 与前端交互 + +### 9.1 API + +所有 automation endpoint 需要现有 session authentication;修改任务、手动运行、取消和危险动作还要通过 JSON content type、同源和 CSRF 等价保护。建议 API: + +```text +GET /api/automation/status +GET /api/automation/tasks +POST /api/automation/tasks +GET /api/automation/tasks/{id} +PUT /api/automation/tasks/{id} If-Match: task revision +POST /api/automation/tasks/{id}/enable +POST /api/automation/tasks/{id}/disable +DELETE /api/automation/tasks/{id} soft delete +POST /api/automation/tasks/{id}/runs manual trigger, 202 + run_id +GET /api/automation/runs filters/pagination +GET /api/automation/runs/{run_id} +POST /api/automation/runs/{run_id}/cancel +POST /api/automation/preview next slots/DST/misfire preview +``` + +约定: + +- 创建 manual run 支持 `Idempotency-Key`;同 key 重复请求返回原 run,参数冲突返回 409。 +- task 更新使用 revision/ETag;旧 revision 返回 412,避免两个浏览器覆盖任务。 +- 立即执行返回 202 和 run ID,而不是声称已发送;前端通过详情查询/SSE 展示 queued、waiting 或 unknown。 +- 列表分页、按 task/state/source/time/error code 过滤;默认不返回 phone number、正文、完整 idempotency key 或 access token。 +- `/api/automation/status` 返回 scheduler owner 状态、最近 heartbeat、queued/running/blocked 数量、下一次 wake 和 recovery barrier;不把“API 可用”当作 scheduler 健康。 + +#### 9.1.1 重建 API 的最小 JSON 契约 + +任务 create/update 的 action 和 trigger 必须是 discriminated union;未知字段是否拒绝必须统一,本文规定服务端对 write request 使用严格 schema、拒绝未知 action/trigger/policy 字段: + +```json +{ + "name": "daily-report", + "enabled": true, + "trigger": { + "type": "fixed", + "timezone": "Asia/Shanghai", + "weekdays": [1, 2, 3, 4, 5, 6, 7], + "local_times": ["04:00"], + "misfire": "skip" + }, + "action": { + "type": "send_sms", + "params": { "phone_number": "+...", "body_template": "..." } + }, + "policy": { + "overlap": "skip", + "max_concurrency": 1, + "timeout_seconds": 120, + "max_attempts": 1 + } +} +``` + +创建成功返回 `201` 和完整 task(含 `id,revision,created_at,updated_at,next_scheduled_for`)。更新要求 `If-Match: ""`,成功返回新 revision;创建/更新时不得把 phone/body 原文复制到通用 audit log。 + +manual run 请求与响应: + +```http +POST /api/automation/tasks/{id}/runs +Idempotency-Key: +Content-Type: application/json + +{"expected_task_revision":7,"dry_run":false} +``` + +```json +{ + "run": { + "id": "...", + "task_id": "...", + "task_revision": 7, + "trigger_source": "manual", + "scheduled_for": null, + "state": "queued", + "attempt": 0, + "linked_message_id": null, + "created_at": "...Z" + } +} +``` + +首次接受返回 `202`;同 key、同 fingerprint 返回原 run(`200` 或 `202` 取决于是否 terminal,并始终带相同 ID);同 key 不同 fingerprint 返回 `409 idempotency_conflict`。不允许通过 manual body 覆盖 phone/body 等 action 参数,除非该 handler 的 schema 显式声明可覆盖字段,并把覆盖值纳入 fingerprint。 + +run 详情至少返回 `state,state_reason,state_history,attempts,resources,linked_message_id,cancellation_requested,started_at,finished_at,next_retry_at,error`。`POST .../cancel` 接受可取消请求时返回 `202`;已 terminal 返回当前 run(幂等);外部副作用不可撤销时返回 `409 effect_already_started`,但可以把“停止等待”作为单独、明确的操作,不能把它叫撤回短信。 + +#### 9.1.2 重建错误 envelope + +所有错误使用合适 HTTP status 和稳定、无敏感内容的结构: + +```json +{ + "error": { + "code": "operation_in_progress", + "message": "automation resource is busy", + "request_id": "...", + "retryable": true, + "details": { "resource": "modem.sms", "run_id": "..." } + } +} +``` + +| HTTP | 稳定 code 示例 | 场景 | +| --- | --- | --- | +| 400 | `invalid_json`,`missing_idempotency_key` | 请求不可解析/缺 header | +| 401/403 | `authentication_required`,`confirmation_required`,`dangerous_action_disabled` | 身份或危险动作保护 | +| 404 | `task_not_found`,`run_not_found` | opaque ID 不存在;不泄漏敏感详情 | +| 409 | `idempotency_conflict`,`operation_in_progress`,`effect_already_started`,`replay_unavailable` | 并发/幂等/副作用边界 | +| 412 | `revision_mismatch` | If-Match 或 expected revision 过期 | +| 422 | `invalid_trigger`,`invalid_timezone`,`invalid_action_params`,`unsupported_action` | schema/capability 校验 | +| 429 | `rate_limited` | 手动或危险操作限流 | +| 503 | `scheduler_unavailable`,`modem_unavailable`,`maintenance_active` | 依赖或恢复 barrier | + +handler 的 `failed|unknown|timed_out|blocked` 是已创建 run 的业务状态,通常通过 run resource 返回,不把底层 D-Bus stderr 直接变成 HTTP message。SSE payload 只携带 ID、状态和安全 code;详情由受保护的 run API 读取。 + +### 9.2 前端 Automation Center + +前端应分为任务、运行记录和调度状态三个视图: + +- 任务卡显示 enabled、action、trigger timezone、下一次 UTC/local 时间、DST/misfire 提示、上次 run 状态和当前锁冲突。 +- 编辑器提供 fixed、interval、manual run 入口;固定时间使用 IANA timezone 选择器,显示下一个实际 instant;interval 明确 anchor 和停机补发策略。 +- “立即执行”走与 scheduler 完全相同的 run API;按钮显示 run ID 和“已排队/等待发送”,不在 1.5 秒后自行猜测结果。 +- 运行详情显示 trigger source、scheduled_for、attempt、linked message ID、state history、锁名、错误码和 redacted detail。phone/body 只在已有消息详情权限范围内展示,不复制到 automation log。 +- 取消按钮根据状态显示:queued 可取消,waiting/已开始的短信只能请求取消等待而不能撤销外部发送;unknown 需要人工 resolve/inspect,不提供“再次发送”快捷按钮。 +- 危险动作创建/启用/手动运行均需二次确认、影响说明、维护窗口和 confirmation phrase;默认隐藏或 disabled。 +- SSE 重连后必须用 cursor/时间范围重新拉取 run 列表,不能把 broadcast 丢失误判成没有执行。 + +## 10. 日志与通知 + +### 10.1 结构化日志 + +使用 tracing/结构化字段记录:`run_id`、`task_id`、`task_revision`、`trigger_source`、`action_type`、`state`、`error_code`、`scheduled_for`、`resource`、`attempt`、`duration_ms`、`linked_message_id`。允许记录目标的不可逆短 hash 作为排障关联,但默认不记录完整号码。 + +严禁在 automation log、普通日志、SSE automation payload 或 notification body 中记录完整 phone number、短信正文、auth password、session token、idempotency key 原文或 webhook secret。错误信息也要经过 redaction,不能把第三方响应中的正文带回日志。 + +### 10.2 AppEvent/SSE + +扩展 `AppEvent`/SSE 的建议事件: + +```text +AutomationTaskChanged +AutomationRunChanged { run_id, task_id, state, source, linked_message_id, error_code } +AutomationSchedulerChanged { lease_state, next_wake, blocked_count } +``` + +事件是实时 UI 提示,不是事实来源;事实来源仍是 SQLite run/attempt。事件 payload 使用枚举 state 和 redacted detail,不能直接序列化整个 handler 参数。 + +### 10.3 外部通知 + +第一阶段至少提供日志和 SSE。若增加 webhook/通知规则,应单独定义 automation notification profile/outbox: + +- 不复用 `forward_deliveries`,不把 automation result 当 inbound SMS forward。 +- 不通过 Modem 发通知短信,避免与 `modem.sms` 冲突和自动化自触发。 +- 通知失败只能重试通知 outbox,不得改变 task run 的业务结果。 +- 通知模板只允许 task name、run ID、action type、state、error code、time、message ID 等安全变量。 +- 支持 quiet hours、rate limit、失败重试和通知 retention,但这些策略不能删除业务 run。 + +## 11. 分阶段交付 + +### Phase 0:契约与安全基线 + +- 定义 task/action/trigger/run state、错误码、资源名、状态转移图和 redaction 规则。 +- 增加 schema migration 方案、handler registry、统一 run context 和测试 clock。 +- 明确 scheduler lease、slot 唯一性、锁顺序和 `Pending/unknown` 的产品文案。 + +### Phase 1:持久化与低风险手动任务 + +- 实现 task/run/attempt 表、manual run API、审计查询和 SSE 事件。 +- 实现 `send_sms` 与 `run_retention` handler,但短信必须走 `Messaging`,retention 必须走现有 store。 +- 加入统一资源准入、同 task 不重叠、run idempotency、超时/取消状态。 +- 前端完成任务 CRUD、立即执行、运行详情和敏感字段隐藏。 + +### Phase 2:fixed/interval durable scheduler + +- 实现 IANA timezone、DST、misfire、interval anchor、schedule slot 唯一索引和重启恢复。 +- 增加 scheduler status、blocked/skipped 原因、preview API、run retention。 +- 以安全扫描兜底但不依赖固定 30 秒轮询保证正确性。 + +### Phase 3:只读恢复与 Modem 协作 + +- 增加 `reconcile_outbound` 手动任务。 +- 把 `/api/modem/*` 的 action gate 与 automation resource coordinator 统一,验证 recovery barrier、Modem generation 和人工动作冲突。 +- 仅在 Phase 1/2 的未知状态、锁和恢复测试通过后,评审基带/Modem reset 任务。 + +### Phase 4:危险动作 + +- 在 feature flag 默认关闭的前提下,加入 eSIM/profile switch、基带重启和 device reboot。 +- 实现 durable plan/commit、维护窗口、确认短语、冷却、post-boot marker、恢复后的 run reconcile 和明确的 unknown UI。 +- 评审是否需要独立的 automation notification outbox;不引入 Hub/multi-node,除非产品范围另行批准。 + +## 12. 验收标准 + +### 调度正确性 + +- 同一 task revision 的同一 fixed slot 无论 scheduler 扫描多少次、进程重启多少次都只创建一个 run。 +- interval 以 anchor 和 duration 计算;失败、blocked、停机补发符合声明的 misfire policy,不会因历史 log 缺失而立即风暴执行。 +- fixed 任务在 DST 缺失/重复时间的行为与配置一致,所有 UI 和日志能看到 UTC instant 与 local label。 +- 禁用、删除、修改 revision 后,旧 queued slot 不会以新定义静默执行。 + +### 发送与恢复 + +- 同一 manual key、scheduler slot、服务重启重试和重复点击只产生一个关联 outbound message。 +- `Messaging` 返回 `Pending` 或检测到 `unknown` 时,run 显示 waiting/unknown,不自动创建第二条消息。 +- Modem recovery、进程崩溃、lease 过期和 Modem path 变化后,outbound worker 仍按既有对象 reconcile;automation 不绕过它。 +- 人工发送和自动发送共用 `Messaging` 与 `modem.sms` lane,不能同时对同一 Modem 发出两个未经准入的操作。 + +### Worker 与危险动作隔离 + +- automation 不会创建、claim、complete 或重试 `forward_deliveries`;DeliveryWorker 可继续独立转发 inbound 消息。 +- retention 不会删除有 active forwarding delivery 或 sending outbound 的消息;它也不会持有锁等待网络/Modem。 +- Modem/eSIM recovery 与自动/人工 Modem control 互斥;冲突返回可解释的 blocked/409,不是超时后隐式重试。 +- device reboot 在 command 发出前有 durable plan,在重新启动并确认 post-boot 前不会记录 succeeded;重启中断的 run 可被审计和恢复。 + +### API、前端与审计 + +- 未认证、旧 revision、重复 key、危险动作缺确认、锁冲突都有稳定的 HTTP status/code。 +- SSE 丢事件后前端可重新拉取,不会把实时事件当作唯一日志。 +- 运行日志可按 task/state/source/time 分页;不存在完整 phone/body/token/key 泄露。 +- 任务删除、run retention、服务重启和数据库迁移都保留可解释的最终状态。 + +## 13. 测试计划 + +### 单元与性质测试 + +- fixed slot 去重、时钟回拨、前进、睡眠唤醒、不同 timezone、DST 缺失/重复、misfire。 +- interval anchor、coalesce/skip/queue、任务 revision、enabled/delete 竞态。 +- SQLite unique slot、scheduler lease、过期 owner、并发 manual 请求、ETag/If-Match 冲突。 +- 状态机转移:queued/running/waiting/unknown/failed/cancelled/blocked,禁止非法 terminal 回退。 +- 锁的固定顺序、同资源互斥、队列上限、公平性和取消边界。 + +### 集成与故障注入 + +- fake `Messaging` 验证同一 idempotency key 只创建一次;模拟 Sent、Rejected、NotAttempted、Unknown、timeout、process crash 和恢复。 +- 使用现有 Modem/SMS 测试 double 验证 prepared、send_started、uncertain、unknown 不会盲重发。 +- 并发人工发送、automation send、InboundWorker 和 DeliveryWorker;验证转发 attempt/lease 不被 automation 改写。 +- 模拟 Modem reset、eSIM recovery、path/fingerprint 改变、旧 owner lease 过期,验证 barrier 和 generation 检查。 +- 模拟 SQLite busy、retention 分批、数据库迁移中断、磁盘满和 notification outbox 重试。 +- device reboot 以 fake post-boot marker 测试 plan/commit、非成功前置状态、重启后 reconcile 和 cooldown。 + +### API/前端端到端 + +- 登录/session、同源保护、权限、创建/编辑/启停/删除、手动运行、取消、重复提交、分页和 SSE 重连。 +- 前端按 UTC/local 展示 next run,正确显示 DST warning、waiting/unknown、blocked 原因和危险确认。 +- 对日志、SSE、错误响应做敏感字段扫描,确保号码、正文、password、token、secret 和原始 key 不出现。 + +## 14. 完成定义 + +只有当 Phase 2 的 durable scheduler、Phase 1 的短信幂等集成、worker 冲突隔离、重启恢复、API/前端审计和上述关键测试全部通过,才可把“自动化调度中心”标记为可用。Modem/eSIM 恢复和设备重启必须作为独立的危险能力继续保持默认关闭,不能因为基础 scheduler 已交付而自动开放。 + +## 15. 实现前必须固化的决策 + +以下目前仍是重建建议而非参考实现事实。开始 migration/API 编码前必须写入 schema 常量和测试 fixture: + +- `default_timezone` 最终取值,以及旧任务缺 timezone 时是拒绝、迁移为 UTC 还是显式选择部署时区;不得静默使用主机 locale。 +- fixed 的 DST ambiguous 默认取早一次还是要求用户选择;是否支持 `both_occurrences`。 +- 每种 trigger 的 misfire/overlap 默认值、queue 上限、排队 timeout、全局 max running 和资源公平策略。 +- manual `Idempotency-Key` 的最大长度、保留时间、actor category 组成和 terminal replay HTTP status。 +- run/attempt/log 的保留天数与条数。文中的 90 天/10000 条是推荐初值,不是已确认现状。 +- `send_sms` 对号码规范化、最大正文长度、模板变量白名单和 handler deadline 的具体限制;应直接复用 Messaging 已有校验时只记录引用,不复制两套常量。 +- scheduler lease TTL/heartbeat、进程启动 recovery barrier 和 `running` run 针对每种 handler 的 reconcile 映射。 + +若未做出某项决定,API 必须返回明确的 `unsupported_policy`,不能退回 SimAdmin 的隐式默认或任意字符串处理。 + +## 16. SimAdmin 来源索引 + +以下均为 `/tmp/SimAdmin` 相对路径,行号对应本节开头锁定的提交: + +| 事实 | 来源 | +| --- | --- | +| 30 秒 loop、北京时间、fixed 内存去重、interval 从最后日志计算、并发 spawn | `backend/src/automation/scheduler.rs:12-125`,符号 `spawn_automation_scheduler` | +| handler 参数、`60 + delay` timeout、success/failed log 与通知顺序 | `backend/src/automation/scheduler.rs:141-245`,符号 `execute_task` | +| handler trait 与四类 registry | `backend/src/automation/traits.rs:5-12`;`backend/src/automation/tasks/mod.rs:10-36` | +| SMS 模板、随机等待、`retry_limit+1`、5 秒重试及直发/插库 | `backend/src/automation/tasks/send_sms.rs:56-149`,符号 `SendSmsHandler::execute` | +| reboot 提前返回、baseband 和 backup handler | `backend/src/automation/tasks/device_reboot.rs:14-35`;`baseband_reboot.rs:15-39`;`backup_data.rs:23-43` | +| config 默认值及 tagged trigger/action schema | `backend/src/config.rs:1446-1503`,符号 `AutomationConfig/AutomationTask/AutomationTrigger/AutomationAction` | +| log 表结构、分页/过滤/last log/retention | `backend/src/db.rs:656-673,2192-2384`,符号 `insert_automation_log/get_automation_logs/get_last_log_for_task/cleanup_automation_logs` | +| 真实 automation 路由 | `backend/src/main.rs:879-897` | +| HTTP 200 error envelope、manual test 绕开 scheduler 准入 | `backend/src/handlers.rs:4223-4445`,符号 `*_automation_*_handler` | +| 前端默认值、校验和浏览器生成 task ID | `frontend/src/pages/automation/AutomationTaskDialog.tsx:42-117,124-175,200-307` | +| 整包配置保存、强制 enabled、测试后 1.5 秒猜测刷新、最近 100 日志聚合 | `frontend/src/pages/AutomationCenter.tsx:73-139,173-245` | +| 前端实际 API 方法/查询参数 | `frontend/src/api/current.ts:1145-1179`;类型见 `frontend/src/api/contracts.ts:1066-1115` | diff --git a/docs/specs/simadmin/05-backup-and-restore.md b/docs/specs/simadmin/05-backup-and-restore.md new file mode 100644 index 0000000..7a6ded5 --- /dev/null +++ b/docs/specs/simadmin/05-backup-and-restore.md @@ -0,0 +1,855 @@ +# 功能 5:数据备份与恢复 + +> 本文是设计规格,不包含实现代码。目标产品为 SmsRelayed,SimAdmin 的现有备份能力作为交互和组件化设计的参考基线。 + +## 0. 证据边界与阅读约定 + +本文核对的 SimAdmin 基线是 `/tmp/SimAdmin` 的 clean `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c`。 + +- “SimAdmin 已确认”表示该提交的实际兼容行为;它可能包含安全或原子性缺口,只供识别旧包/旧交互,不能被当作推荐实现。 +- “重建契约”“必须/禁止”表示 SmsRelayed 的目标语义;“建议/推荐”仍需在实现前固化为 schema、默认值和测试 fixture。 +- portable archive、same-device snapshot 和 pre-restore snapshot 是三个不同安全域。正文提到“备份”时如未说明,默认指 portable archive;不得把逻辑 ZIP、SQLite 快照和回滚点互换概念。 +- 本功能不承诺读入 SimAdmin ZIP。若未来要兼容,应作为只读 importer 单独标识 `source_format=simadmin-v1`,先转换为 SmsRelayed staging model,不能直接调用 live import。 + +## 1. 目标与非目标 + +### 1.1 目标 + +本功能需要为 SmsRelayed 提供一套可审计、可预览、可回滚的数据保护流程: + +- 按组件选择备份内容,支持精简备份、完整备份和同机灾备快照。 +- 在任何写入前完成归档格式、版本、组件、大小、哈希、权限和语义校验。 +- 恢复必须经过“上传/选择、校验与冲突策略、执行”三步,用户在真正写入前能看到影响范围。 +- 每一次恢复前自动生成完整的 pre-restore snapshot,并在验证失败或服务重启失败时使用它回滚。 +- 让消息正文、API 凭据、转发渠道 secret、eSIM Matching ID 等数据有明确的默认处理方式,避免凭据泄漏到归档、日志、预览和错误信息。 +- 兼容现有 SQLite WAL、迁移、消息去重、出站发送状态、投递 lease 和至少一次投递语义。 +- 让 API、CLI、前端和自动化任务共享同一个恢复协调器、操作状态和失败语义。 + +### 1.2 非目标 + +- 不备份或回滚 ModemManager、调制解调器硬件、SIM/eSIM 运营商侧状态,也不执行 eSIM profile 下载、安装、启用或删除。 +- 不把自更新下载的二进制、systemd/procd 单元、操作系统包、内核或 /etc 外部文件纳入可移植数据归档;归档只记录构建版本和 commit,不能代替 OTA 二进制回滚。 +- 不恢复活动认证 session;恢复后必须重新认证。 +- 不提供任意 SQL 导入、任意路径写入或将归档中的部署路径直接覆盖到目标机器的能力。 +- 第一阶段不承诺 WebDAV、对象存储、多机实时复制或跨设备自动同步;本地目录和浏览器下载是首选范围。 +- 不承诺短信发送或渠道通知的 exactly-once。现有系统是 at-least-once,恢复期间必须优先避免盲目重发而不是伪造 exactly-once。 + +### 1.3 术语 + +| 术语 | 本规格中的唯一含义 | +| --- | --- | +| archive | 带 manifest 和逻辑组件的可移植 `.srb`;不含原始 SQLite schema | +| snapshot | 同设备、一致性的完整 SQLite + 配置 + journal 回滚 artifact;不作为普通下载文件 | +| pre-restore snapshot | 每次 destructive apply 前自动创建的完整 snapshot,供该 operation 回滚 | +| staging | 校验/解密/迁移的私有临时区;未 commit 的内容绝不能出现在备份库列表 | +| component | 有独立 schema version、分类、摘要和 import adapter 的逻辑数据集合 | +| manifest | 描述格式、来源、组件和摘要的元数据;它不是数据来源签名本身 | +| merge | 按组件业务键幂等合并并报告冲突,不清空整个组件 | +| replace | 只替换明确选择的逻辑组件;不替换当前 sqlite_schema、session 或本机部署路径 | +| quarantine | 数据已导入但 worker 不得 claim/send 的恢复状态 | +| preview token | 绑定 archive hash、目标 revision/schema、选择与确认摘要的一次性/短期授权,不是 session | +| operation journal | 持久记录 restore/rollback 阶段与恢复材料的非敏感日志,供崩溃恢复 | + +## 2. SimAdmin 参考基线 + +### 2.1 组件清单 + +SimAdmin 当前使用 ZIP 容器、manifest.json 和 components/{component}.json。格式版本为 1,应用标识为 simadmin,组件级记录数和 SHA-256 写在 manifest 中。组件清单如下: + +| 组件 key | 内容边界 | 默认精简 | 完整备份 | 敏感性与恢复备注 | +| --- | --- | :---: | :---: | --- | +| config | 设备网络、漫游/数据/APN、工作模式、eSIM 基础配置、版本通知和备份配置 | 是 | 是 | 包含配置类敏感字段;不是整份 AppConfig | +| sms | 短信方向、号码、正文、时间、状态、PDU 等 | 是 | 是 | 含正文和个人数据 | +| notification_config | 通知配置、Webhook 和渠道规则 | 是 | 是 | 前端标注隐私,当前后端敏感标记仍较宽松 | +| notification_logs | 通知事件、规则、渠道和消息记录 | 否 | 是 | 可能包含正文或渠道标识 | +| notification_queue | 队列状态、重试次数、错误、标题、正文和过期时间 | 否 | 是 | 恢复时不能无条件重新发送 | +| automation_config | 自动化任务定义、计划和动作 | 是 | 是 | 恢复前需要校验动作权限 | +| automation_logs | 自动化运行历史 | 否 | 是 | 只恢复审计历史,不重新执行 | +| sim_cache | SMSC、号码、IMSI/ICCID 等缓存 | 是 | 是 | 设备相关,跨设备恢复需谨慎 | +| esim_cache | eSIM profile/eUICC 缓存,包括 Matching ID 等元数据 | 是 | 是 | 只恢复缓存,不改变 eUICC | +| auth | 安全配置和 auth_config;不包含 session | 否 | 否 | 必须显式勾选,导入会清理当前 session | + +默认精简集合是 config、sms、notification_config、automation_config、sim_cache、esim_cache。完整集合包含除 auth 外的其余九个组件;SimAdmin 的“full”并不自动包含 auth。组件可自由选择,full/slim 只是预设,不应绕过敏感数据确认。 + +### 2.2 校验和导入流程 + +SimAdmin 当前已确认的校验包括以下内容: + +- ZIP 使用 `enclosed_name()` 接受相对安全路径;无法 enclosed 的条目会被跳过而不是令整个包失败。manifest 必须存在。重复同名条目会以后读到的内容覆盖内存 map 中先前内容;未在 manifest 声明的额外条目不会被拒绝。 +- 校验 app、format_version、非空组件列表、组件文件存在性。 +- 对每个组件计算 SHA-256,并核对 manifest 中的记录数和文件摘要。 +- 预览返回文件名、备份类型、格式版本、生成时间、版本、敏感标记、组件数量和警告。 +- 上传 preview/apply 的压缩 body 上限为 50 MiB;本地文件 apply 没有同一 body limit,解压后总量、压缩比、单条目大小和组件字段长度没有独立硬上限。当前没有整体签名/整体摘要,也没有归档级加密。 + +前端是三步恢复界面: + +1. 上传或选择本地文件,读取 manifest 并校验。 +2. 选择组件和 merge/replace 冲突策略;auth 需要单独确认。 +3. 执行导入,显示进度和日志;导入 auth 后提示当前 session 将失效。 + +SmsRelayed 应保留这套用户心智模型,但把“模拟进度”改为真实操作状态,把敏感字段和跨文件原子性补齐。 + +### 2.3 pre-restore snapshot 与清理参考 + +SimAdmin 在 apply 前写一个逻辑 ZIP 到 `pre-restore` 目录,但已确认它并不是“当前所选组件”的镜像:它固定包含默认六组件 `config,sms,notification_config,automation_config,sim_cache,esim_cache`,只有本次显式导入 auth 时才额外包含 auth。它不包含 notification logs/queue 或 automation logs。代码在 apply 失败后不会自动导回该文件;前端可以让用户以后选择文件,以 replace 模式再次 apply。正常备份和 pre-restore 使用两个目录但复用同一套清理设置,默认保留期 7 天、最多 10 个文件;文件名使用 `simadmin-backup-` 前缀和 `.zip` 后缀。自动化任务也可生成普通本地备份。 + +该机制有三个需要在 SmsRelayed 中修正的地方: + +- 只保存所选组件不能保证跨配置文件和 SQLite 的整体回滚,因此 SmsRelayed 的 pre-restore 必须是完整当前状态。 +- 直接 fs::write 以及先提交数据库、再写配置会造成半成功状态;SmsRelayed 必须使用临时文件、fsync、操作日志和回滚恢复。 +- pre-restore 是逻辑 JSON 归档而不是 SQLite 物理克隆;SmsRelayed 需要同时支持可移植逻辑归档和停止服务后的同机数据库快照。 + +### 2.4 SimAdmin 已确认的真实 HTTP 契约 + +所有下列路由都位于认证 middleware 后。除 ZIP 下载成功外,大部分业务失败仍返回 HTTP 200,错误位于 `{status:"error",message,...}` envelope;这是旧兼容事实,不是重建目标。 + +| 方法与路径 | 实际请求 | 实际成功结果/重要限制 | +| --- | --- | --- | +| `GET /api/backup/options` | 无 | format version、默认组件、每组件 count、服务端绝对 local/pre-restore 路径 | +| `GET /api/backup/config` | 无 | 规范化后的 `BackupConfig` | +| `POST /api/backup/config` | `{"config": BackupConfig}` | 覆盖配置;无 revision/If-Match | +| `POST /api/backup/export` | `{"components":[...]}` | 同步在内存构建完整 ZIP,`application/zip` 下载 | +| `POST /api/backup/export-local` | 同上 | 同步写入 server local dir,返回 `file` | +| `POST /api/backup/data/clear` | `{"components":[...]}` | 直接清空/重置 live 组件;不是 restore 子步骤 | +| `POST /api/backup/import/preview` | body 是原始 ZIP bytes | 同步解析,返回非 token 化 preview | +| `POST /api/backup/import/apply?mode=merge&components=a,b` | body 是原始 ZIP bytes;`mode` 默认 merge;components 是逗号字符串 | 空 components 自动选 archive 中除 auth 外全部;同步 pre-backup + apply,无 operation ID | +| `GET /api/backup/files` | 无 | `{backups,pre_restore}`,两个目录的文件摘要 | +| `GET /api/backup/files/{filename}/preview` | path filename | 重新读取/解析本地 ZIP | +| `POST /api/backup/files/{filename}/apply?mode=...&components=...` | query 同上传 apply | 同步 apply;没有 body/preview token | +| `GET /api/backup/files/{filename}` | 无 | 普通备份和 pre-restore 都可下载 | +| `DELETE /api/backup/files/{filename}` | 无 | 普通备份和 pre-restore 都可直接删除 | + +本地 filename 必须以 `simadmin-backup-` 开头、`.zip` 结尾,字符限 ASCII 字母数字、`-_.`;查找顺序先普通目录再 pre-restore。配置中的 `storage.local_dir` 只要非空就直接作为路径使用,没有允许根、symlink、owner 或 mode 校验。 + +### 2.5 SimAdmin 已确认的 apply 顺序与恢复缺口 + +1. 解析 ZIP、校验 manifest/app/version、所选组件存在、每组件 SHA-256 与 record count。 +2. 同步生成上述固定默认集合的 pre-restore ZIP。 +3. 先在单个 SQLite transaction 中 import 所选 DB 组件并 commit。 +4. 再通过 `replace_config` 保存合成后的配置。 +5. 只有选择 auth 时才清空 session。 + +没有 worker quiesce、SQLite connection swap、operation lock/journal、post-import `integrity_check/foreign_key_check`、service restart/health check 或自动 rollback。若第 3 步 commit 后第 4 步失败,数据库已改变而配置未改变。merge/replace 只影响行表的删除策略;config、notification config、automation config 都直接用归档值覆盖相应分区,不因 mode 不同而做字段级 merge。notification queue 的 status/retry 时间会原样导入,因此可能被现有 worker 后续消费。 + +### 2.6 SimAdmin 已确认的默认值与组件细节 + +| 项 | 实际默认/规范化 | +| --- | --- | +| backup enabled | `false` | +| components | `config,sms,notification_config,automation_config,sim_cache,esim_cache` | +| schedule | mode `manual`;weekdays 1..7;time `04:00`;interval `7 days` | +| cleanup | retention 启用、7 天;max-files 启用、10 个;规范化范围分别 1..3650、1..1000 | +| storage | `/opt/simadmin/backups` | +| upload compressed limit | 50 MiB | +| format/app | `format_version=1`,`app="simadmin"` | +| filename | `simadmin-backup-{full|slim}-YYYYMMDD-HHMMSS.zip`,秒粒度,已存在时会覆盖 | + +`BackupConfig.schedule,last_run_at,last_run_key` 在该提交中只有配置/前端存储,没有独立 backup scheduler 消费;真正的定时备份来自 automation action `backup_data`。因此不能仅凭保存 backup schedule 就宣称会自动运行。 + +`full` 的判定是选择了除 auth 外的九个组件;额外选择 auth 不改变 full。`sensitive()` 只把 config、sms、sim_cache、esim_cache、auth 标为敏感,notification config/log/queue 和 automation config/log 即使实际字段含 webhook、body、detail,也会被标为非敏感。这是参考实现的分类缺口。 + +归档由整个组件 JSON 的 SHA-256 和 record count 保护;manifest 本身、组件顺序/整体 ZIP、额外文件不在签名边界。导出逐组件分别锁数据库连接读取,不保证多个组件来自同一 SQLite snapshot;ZIP 完整构建在内存中。local 写入使用 `create_dir_all + fs::write`,没有 `.partial`、`create_new`、fsync、原子 rename 或权限固化,写完即按 mtime 清理。 + +已确认 merge 去重键包括:SMS 的 direction/phone/content/timestamp/PDU;notification/automation log 的全部选定字段;notification queue 的 status/event/summary/channel/rule/title/body/next_attempt/created_at。SIM/eSIM cache 使用 identity/ICCID/cache key upsert。replace 会先 DELETE 组件对应表,再逐行插入;这与 SmsRelayed 推荐的 quarantine、状态合法性和跨组件外键处理不是同一语义。 + +## 3. SmsRelayed 当前结构 + +### 3.1 配置文件 + +当前配置由 AppConfig 以 TOML 保存,默认路径为 /etc/sms-relayed/config.toml。主要分区为: + +| 分区 | 当前内容 | 备份边界 | +| --- | --- | --- | +| app | device_name、modem_path | device_name 可移植;modem_path 是部署路径,不直接覆盖 | +| sms | storage 忽略策略、验证码关键词等 | 可移植 | +| forward | enabled、profile 列表 | 可移植,但 profile 的 secret 分离 | +| delivery | 并发数、渠道超时等 | 可移植;运行中的 lease 不属于配置 | +| channels | Bark、Telegram、WeCom、DingTalk、Lark、Webhook profile | profile 名称和非 secret 参数可备份,secret 单独保护 | +| api | enabled、bind、port、IPv6、trusted proxy、password、database_path | password 不进入普通归档;bind/port/path 视为本机部署策略 | +| http | 请求超时等 | 可移植 | +| retention | 消息保留期和批量大小 | 可移植 | +| monitoring | 监控设置 | 可移植,但不得泄漏 secret | + +渠道 secret 包括 Bark key、Telegram bot_token/chat_id/API base 中的敏感部分、企业微信 secret、钉钉 secret、Lark webhook/secret,以及 Webhook URL、body、headers 中可能出现的 token、Bearer 值或个人信息。当前配置摘要会对这些字段做 redaction,但配置文件中的 api.password 仍是可读 TOML 值,因此备份不能把“当前配置文件可读”误当成“可公开归档”。 + +配置保存已有较好的原子写入基础:创建 0600 临时文件,写入并 sync_all,通过 rename 替换目标,再同步父目录;临时文件失败时由 Drop 清理。备份实现应复用这一语义,并对用户配置的备份目录同样强制 0700/0600、拒绝 symlink,而不能只依赖默认 /etc/sms-relayed 目录。 + +### 3.2 SQLite、迁移和派生数据 + +默认数据库路径为 /etc/sms-relayed/sms-relayed.sqlite。打开数据库时启用 foreign_keys、WAL、busy timeout;主库、-wal、-shm 文件均通过 no-follow 方式打开并限制为 0600。任何物理复制都必须把 WAL 一并纳入一致性边界,不能只复制主库文件。 + +主要表和数据性质如下: + +| 数据 | 当前结构或用途 | 备份策略 | +| --- | --- | --- | +| messages | inbound/outbound 消息、phone_number、body、timestamp、source、read_at、favorite_at、error、dedupe key、outbound phase、lease owner/deadline | 主要业务数据;portable 归档按 typed record 导出 | +| outbound_idempotency | key、request_hash、message_id、created_at | 同机恢复可选;跨设备导入需防止 key 冲突 | +| forward_deliveries | 每个 message/profile 的 pending、in_flight、retry_wait、succeeded、permanent_failed、attempt、next_attempt、lease 和错误 | 状态与消息一致性重要;活动 lease 不能原样恢复 | +| forward_attempt_samples | 最近的渠道尝试、耗时、调度延迟、结果和错误码 | 审计历史;不重新执行 | +| conversation_pins | 会话置顶 | 用户状态,可选恢复 | +| conversation_summaries | 会话计数、未读数、最后消息 | 派生数据,导入后由消息和触发器重建/校验 | +| meta | modem_fingerprint、modem_dedupe_namespace、runtime_modem_fingerprint、mismatch 标记等 | 设备绑定;portable 默认不导出 | +| auth_sessions | token_hash、credential_proof、expires_at | 永不导出或导入 | +| auth_credential_state | salt、verifier | 与本机 .auth-key 绑定;portable 不导出 | +| SQLite indexes/triggers | 去重、会话汇总、状态约束和外键 | 不作为数据组件导入,由当前程序和 migrations 重建 | + +src/storage/migrations.rs 会逐步增加 favorite_at、inbound_dedupe_key、outbound phase、dispatch_delay_ms、唯一约束、meta/outbox 初始化以及 conversation summary 回填。备份不能直接替换 sqlite_schema、user_version 或表文件;必须在当前程序打开并运行现有迁移后,以组件适配器逐行导入。 + +### 3.3 认证与 session + +认证入口为 /api/auth/login、logout、me,session 通过 HttpOnly cookie sms-relayed-session 传递,默认有效期 7 天,且有失败限流和最多 256 个 session 的约束。SessionStore 使用数据库路径同目录的 .auth-key(32 字节、0600),数据库只保存 token_hash、credential_proof 和过期时间;auth_credential_state 保存 PBKDF2-HMAC-SHA256 派生的 verifier 和 salt。 + +密码变化会删除 auth_sessions。由于 credential proof 依赖本机 .auth-key,单独导入数据库里的 auth_credential_state 不能可靠迁移认证能力。设计原则是: + +- 不备份 token 明文、token_hash、credential_proof、auth_sessions、.auth-key。 +- 不在预览、操作日志、错误响应、CLI 输出中显示密码或 token。 +- 任意恢复完成后清理本机所有 session;如果配置中的 API 密码发生变化,要求使用新密码重新登录。 +- .auth-key 保留在本机,不从 portable 归档导入;如果执行凭据重置,可主动轮换它并重新生成 credential state。 + +### 3.4 消息、出站发送和转发投递 + +messages 的状态包括 received、sending、sent、failed;出站阶段还包括 created、prepared、send_started、uncertain、unknown、complete。uncertain/unknown 表示 modem 可能已经发送,不能因为恢复而自动再发一条短信。 + +forward_deliveries 采用 pending、in_flight、retry_wait、succeeded、permanent_failed,使用 lease_at/lease_token 做 compare-and-swap;DeliveryWorker 会恢复过期 lease、按 30 秒安全扫描和下次重试时间调度。forward_attempt_samples 只记录尝试历史。持久化层还提供 outbound idempotency、inbound dedupe、消息保留和 active delivery 保护。 + +因此: + +- portable 归档应保存消息和投递历史,但不能把某个 worker 的 lease token 当成可迁移凭据。 +- in_flight 的转发必须在恢复时转为“待人工确认”或安全的 retry_wait,不能在没有明确选项时立即重发。 +- sending、uncertain、unknown 的出站短信默认进入 quarantine/review,不自动调用 modem。 +- 恢复完成后是否恢复投递队列必须是显式策略;默认 history-only,用户确认后才允许 resume。 + +### 3.5 自更新结构 + +src/update.rs 会下载校验文件和平台二进制,在目标文件同目录生成临时文件,流式计算 SHA-256,校验版本/commit,fsync、chmod 0755 后以 rename 替换旧二进制,再请求 systemd 或 OpenWrt procd 重启。失败时保留旧二进制,重启失败时会明确告知“二进制已更新但服务未重启”。 + +备份只在 manifest 记录 app_version、build_commit、数据库/配置 schema 版本和架构;不打包当前或旧二进制,不覆盖 update 临时文件,也不把“恢复数据”描述成“回滚版本”。如果旧版本无法读取新组件,必须在预览阶段拒绝或明确提示需要先安装兼容版本。 + +## 4. 推荐归档格式 + +### 4.1 容器与编码 + +推荐使用带 .srb 后缀的 ZIP 容器,格式标识为 sms-relayed-backup。选择 ZIP 是为了沿用 SimAdmin 的本地库和下载体验;ZIP 只负责容器,不使用传统弱 ZIP 密码。条目使用 UTF-8、固定路径、固定排序和固定换行,便于重现摘要和离线校验。 + +建议布局如下: + + manifest.json + components/config.public.toml + components/messages.ndjson + components/forwarding.state.ndjson + components/forwarding.attempts.ndjson + components/ui.state.json + components/idempotency.ndjson + components/secrets.enc # 仅显式导出并加密时存在 + components/personal.enc # 需要保护短信正文时存在 + +NDJSON 适用于大规模消息和投递记录:一行一个版本化记录,允许流式导入并设置行数、字段长度和总大小限制。小型配置和 manifest 使用 UTF-8 TOML/JSON。portable 归档不包含原始 SQLite 文件。 + +同机灾备快照是另一种 artifact,可以使用相同 manifest,但增加: + + snapshot/database.sqlite + snapshot/config.toml.enc + snapshot/operation.json + +database.sqlite 必须由 SQLite online backup 或等价的一致性快照生成,而不是在 WAL 工作期间直接复制主库。快照只用于同机回滚,默认不提供下载为 portable 归档。 + +### 4.2 manifest、版本和 schema + +manifest 至少包含以下字段: + + { + "format": "sms-relayed-backup", + "format_version": 1, + "manifest_schema": 1, + "app_version": "...", + "build_commit": "...", + "created_at": "...", + "kind": "slim|full|snapshot", + "scope": "portable|same_device", + "source": { + "config_schema": 1, + "sqlite_schema": 7, + "config_revision": "..." + }, + "components": [ + { + "id": "messages", + "schema_version": 1, + "path": "components/messages.ndjson", + "encoding": "ndjson", + "records": 123, + "bytes": 45678, + "sha256": "...", + "classification": "personal", + "required": false + } + ], + "encryption": { + "mode": "none|envelope", + "algorithm": "XChaCha20-Poly1305", + "kdf": "Argon2id", + "salt": "...", + "payload": "..." + } + } + +规则如下: + +- format_version 是归档协议的大版本,manifest_schema 是 manifest 本身版本,component schema_version 独立演进,不能混用 SQLite user_version。 +- app_version/build_commit 仅用于兼容性提示,不作为可信安全边界。 +- manifest 必须列出每个条目的路径、编码、记录数、字节数、SHA-256、组件 schema 和数据分类。 +- manifest 只包含计数、摘要和非敏感版本信息;不包含密码、token、短信正文、完整电话号码、Webhook header 或 Matching ID。 +- 加密归档的 manifest 可以明文保留组件 id、分类、密文大小和摘要,但不能泄漏正文或 secret;需要密码解密后才能解析受保护组件。 +- 可选地增加 Ed25519 签名或外部签名文件,签名覆盖规范化 manifest、条目摘要和格式版本。加密的 AEAD tag 是机密性和完整性保护,但不替代来源签名。 + +### 4.3 SmsRelayed 组件边界 + +建议把可移植内容拆成以下组件,组件 id 一旦发布后只追加字段,不复用已有 id 表示不同语义: + +| 组件 id | 包含 | 默认策略 | +| --- | --- | --- | +| config.public | 非 secret 的 app、sms、forward、delivery、channels、http、retention、monitoring;profile 名和运行开关保留 | 精简和完整均可选 | +| messages | 消息正文、号码、方向、状态、时间、来源、阅读/收藏状态;portable 时清理 modem_sms_path | 精简默认选中,正文视为 personal | +| forwarding.state | message/profile 关系、成功/永久失败/待处理状态、次数、错误摘要和下一次时间 | 精简默认选中;活动 lease 不原样导入 | +| forwarding.attempts | provider attempt 历史和调度延迟 | 完整默认选中 | +| ui.state | 会话置顶等非消息派生 UI 状态 | 完整默认选中 | +| idempotency | outbound idempotency key、request_hash、message 关联 | 仅同设备或显式选中;跨设备默认跳过 | +| secrets.enc | API password、APN password、渠道 key/token/secret、Webhook 敏感 URL/body/header 等 | 永不默认;必须加密并二次确认 | +| personal.enc | 被用户要求保护的短信正文和号码等个人数据 | 当归档离开本机时推荐/可强制加密 | +| device.meta | modem fingerprint、dedupe namespace、runtime mismatch 等设备绑定信息 | 仅 same_device snapshot | + +conversation_summaries、索引和触发器不作为独立导入数据;它们由当前迁移和重建流程生成。auth_sessions、.auth-key、auth_credential_state 不属于任何 portable 组件。 + +配置中的 database_path、modem_path、API bind/port 和 trusted proxy 等部署策略不能由 portable 归档覆盖。若需要同机完整回滚,snapshot 可以保存原值,但恢复前仍需确认目标路径与当前安装一致。 + +### 4.4 精简、完整和快照 + +- 精简备份:config.public、messages、forwarding.state;默认不含尝试样本、idempotency、device.meta、secrets.enc。适合日常本地备份和消息迁移。 +- 完整备份:全部安全 portable 组件,包括 config.public、messages、forwarding.state、forwarding.attempts、ui.state,以及用户明确允许的 idempotency。完整不等于包含 secrets 或 session。 +- 凭据包:secrets.enc 的显式附加选项,不受 full 预设自动启用;必须输入归档密码并在执行前再次确认。 +- 同机灾备快照:保存完整当前 SQLite 一致性快照、完整配置和操作元数据,包含回滚所需的本机 secret,但权限为 0600、不可通过普通下载接口导出;可使用安装级本地密钥加密。 + +## 5. 敏感数据策略 + +### 5.1 凭据和转发渠道 secret + +普通 portable 归档中必须删除或替换以下值: + +- api.password、APN username/password 中的 password; +- Bark key、Telegram bot_token 和相关 token、企业微信 secret、钉钉 secret、Lark secret; +- Webhook URL 中的 token、Authorization/Bearer header、body 中的固定 secret,以及任何等效的自定义 header; +- .auth-key、auth_credential_state 和 session 数据。 + +非 secret 字段可以保存 profile 名称、启用状态、超时、HTTP method、content type、非敏感规则和已 redacted 的 endpoint。恢复后保留 profile 但将缺失 secret 的 profile 标为 needs-credentials,并禁止 worker 发送,直到用户补齐。 + +如果用户选择导出 secrets.enc: + +- 使用应用层 envelope encryption;推荐 Argon2id 派生密钥、随机 salt、随机 nonce 和 XChaCha20-Poly1305 AEAD。不能使用传统 ZIP 密码。 +- 密码只能来自交互式输入、受限权限的文件或 stdin,不能放入 URL、命令行参数、操作日志或前端 localStorage。 +- 预览只显示“包含凭据、需要密码”,不显示字段值;错误信息只报告解密/认证失败,不回显密文内容。 +- 导入 secret 前要求显式授权,并记录组件 id、操作结果和操作者,不记录 secret 本身。 + +### 5.2 session 与认证状态 + +归档导入不接受 session。每次恢复,包括只恢复 messages 的恢复,都应在 commit 后清理本机 auth_sessions,并使当前浏览器 cookie 失效;API 需要返回 session_invalidated=true,前端转到登录页。 + +同机 snapshot 回滚也不恢复旧 session。回滚 auth_credential_state 时必须用当前本机 .auth-key 重新同步;如果不匹配,删除旧 credential state 并按当前配置密码重新生成。凭据恢复后的唯一可接受结果是“旧 session 全部失效、用户重新登录”,不能尝试让旧 cookie 继续有效。 + +### 5.3 短信正文、号码和投递日志 + +短信正文、完整号码、Webhook body 和错误文本均按 personal 数据处理: + +- 预览只显示组件名称、记录数、时间范围、大小和冲突数,不显示正文、完整号码或 provider response。 +- 本地文件权限为 0600;归档要离开本机、通过浏览器下载或被标记为 personal 时,默认启用 personal.enc 或要求用户确认未加密导出。 +- 诊断日志只记录 operation_id、组件 id、计数和错误码;不记录正文、完整号码、归档密码或渠道响应。 +- metadata-only 是可选的合规导出模式,保留方向、时间、状态和稳定内部 id,但不包含 body/phone_number;metadata-only 归档不能声称可以完整恢复短信。 + +### 5.4 eSIM Matching ID + +SmsRelayed 当前没有 SimAdmin 的 eSIM profile 管理表,但 SimAdmin 的 esim_cache 包含 matching_id、SMDP、ISDP AID、ICCID 等高敏感缓存。若未来导入这类组件: + +- Matching ID、SMDP 地址、ISDP AID、ICCID/IMSI 以及 raw eUICC 缓存都视为 credential/activation data,默认不导出。 +- 可移植归档只保留“存在缓存”和非敏感统计;完整缓存只能进入 secrets.enc 或同机加密 snapshot。 +- 恢复缓存永远不触发 eSIM 操作;跨设备恢复默认丢弃 device.meta 和 eUICC 关联,必须由用户在目标设备重新确认。 +- 前端预览只显示“eSIM 敏感数据已省略/已加密”,不显示 Matching ID 的部分掩码也不应写入普通日志。 + +## 6. 导出、校验和原子写入 + +### 6.1 导出一致性 + +导出需要通过 BackupCoordinator 获取只读一致性视图: + +1. 锁定备份/恢复操作,暂停会改变归档边界的清理和自动备份。 +2. 配置通过安全打开和稳定 revision 读取;如果在读取期间发生保存,重试或失败,不拼接两份配置。 +3. SQLite 使用只读独立连接或 SQLite online backup 获取一致视图,不长期占用业务 writer。必要时在同一快照上读取 messages、deliveries、attempts、UI 状态和 idempotency。 +4. 按固定排序流式生成组件,写入临时归档,计算组件摘要和 manifest。 +5. 关闭并 fsync 临时文件,重新验证后以原子 rename 放入备份目录,再 fsync 目录。任何 .partial 文件都不能出现在文件列表或下载接口中。 + +### 6.2 归档校验 + +导入前和导入过程中都执行校验: + +- 归档总大小、解压后总大小、组件数、记录数、单行长度、字段长度和压缩比都有硬上限;manifest 不能让程序预先分配不受限内存。 +- 条目路径只能匹配 manifest 中声明的固定前缀和文件名;拒绝绝对路径、..、重复条目、符号链接、目录伪装、未知必需条目和超出备份根目录的路径。 +- 校验 format、manifest schema、组件 schema、app id、条目编码、记录数、字节数、SHA-256 和可选签名。 +- 每条记录按当前 schema 解码并进行类型、枚举、时间范围、长度、外键引用和业务状态校验。错误要指出组件、记录序号和错误码,但不泄漏字段值。 +- SQLite 变更完成后执行 integrity_check、foreign_key_check、关键计数检查和派生 summary 校验;配置导入前执行 AppConfig validate。 +- 解密时先验证 AEAD tag,再解析明文;密码错误和篡改统一为不可恢复的校验错误,不尝试部分导入。 + +校验结果应区分 fatal、warning 和 informational。fatal 不允许进入第三步;warning 必须在最终确认页再次显示。 + +### 6.3 原子写入和操作日志 + +恢复涉及配置文件、SQLite 和服务进程,不能假设文件 rename 与数据库 commit 是一个操作。推荐使用恢复 journal: + +- 每个操作生成不可预测 operation_id,在私有 staging 目录创建 operation.json,记录阶段、源归档摘要、目标组件、snapshot_id 和清理状态;不写入 secret/body。 +- 先完成 pre-restore snapshot,再对暂存数据库和配置执行导入、迁移、完整性检查。 +- 数据库变更使用单个事务;配置写入使用现有 prepare_secure_write 的 temp + sync_all + rename + parent fsync。 +- replace、snapshot rollback 或涉及 schema/配置变化的操作优先在停止服务的 maintenance window 内进行。关闭 SQLite 连接并处理 WAL 后,再以可恢复的 rename/交换替换目标文件。 +- merge 也必须在单一数据库事务内完成;如果配置随后提交失败,用 journal 和 snapshot 恢复数据库,不能返回成功。 +- 只有数据库、配置、校验和服务启动后的健康检查全部通过,journal 才标记 committed;否则进入 rollback-required,保留 snapshot 和诊断信息。 + +## 7. 恢复语义 + +### 7.1 三步恢复 + +#### 第一步:上传/选择与预览 + +用户可上传归档、从本地备份库选择文件,或选择 snapshot。服务端先执行大小、路径、manifest、摘要、解密和记录语义校验,返回: + +- 归档类型、scope、生成时间、来源版本、schema; +- 可用组件、分类、记录数、大小、时间范围和是否需要密码; +- 目标当前 schema/版本的兼容结果; +- 预估磁盘空间、是否需要停机/重启、是否会清理 session; +- fatal/warning 列表和 preview_token。 + +此阶段禁止写业务数据库、配置、session 或 worker 状态,只允许创建受限 staging 文件。 + +#### 第二步:组件、冲突和安全策略 + +用户选择: + +- 要恢复的组件; +- merge 或 replace; +- metadata-only 或包含正文; +- 是否解密并导入 secrets; +- forward delivery 采用 history-only、quarantine 或显式 resume; +- 设备绑定数据是否仅限 same_device; +- 是否接受 service restart 和 session invalidation。 + +preview_token 绑定归档 SHA-256、当前 config revision、当前数据库 schema、组件选择和确认摘要;归档或目标发生变化时 token 失效,必须重新预览。 + +merge 的定义是按业务键去重并保留当前数据;replace 的定义是对所选组件的源数据做整体替换,但仍不删除未选择的组件,也不能删除当前运行所需的 schema、索引、触发器和认证保护。replace 前必须成功创建完整 snapshot。 + +#### 第三步:执行 + +用户确认后服务端重新校验 preview_token、权限、磁盘和当前版本,创建 pre-restore snapshot,然后进入 maintenance。返回 operation_id,前端通过轮询或 SSE 获取真实阶段: + + validating -> snapshotting -> quiescing -> applying -> verifying + -> restarting -> healthy -> committed + +失败时状态为 failed 或 rolled_back,并返回 snapshot_id、恢复建议和是否需要重新登录。不会用前端计时器伪造百分比。 + +### 7.2 pre-restore snapshot + +每次 apply 都自动创建一个完整的当前状态 snapshot,即使用户只选择恢复 messages。snapshot 至少包含: + +- 一致性的 SQLite 数据库; +- 当前完整配置的受保护副本; +- snapshot manifest、数据库 schema、config revision、服务版本、权限和创建时间; +- rollback 所需的 operation journal。 + +snapshot 写入私有目录的 .partial 目录,文件 0600、目录 0700,全部 fsync 后原子 rename。snapshot 成功后才能对 live 状态做 destructive replace。snapshot 不是普通可下载文件;API 只返回 id、状态、大小和时间。 + +回滚必须按 snapshot 的整套配置和数据库恢复,不能只把原来选择的组件再次 import。回滚成功后清理 session,重新启动服务并进行健康检查;回滚本身失败时保留 snapshot、当前文件和 journal,禁止自动删除任一份可用于人工恢复的副本。 + +### 7.3 merge/replace 的组件语义 + +- config.public:merge 只更新归档明确包含且用户确认的字段,保留本机部署路径、API bind/port、database_path 和当前缺失的 secret;replace 也不能覆盖这些部署策略。 +- messages:以稳定消息 id、inbound dedupe key 或组合业务键做幂等去重;同 id 内容冲突不静默覆盖,记录冲突并要求 replace 或人工处理。portable 归档中的 modem_sms_path 清空。 +- forwarding.state:以 message_id/profile_key 为键合并;导入前清空 lease owner/token/lease_at。in_flight、retry_wait、pending 默认 quarantine,不自动发出。 +- forwarding.attempts:只追加或按稳定 attempt id 去重,永不触发发送。 +- ui.state:按用户状态的业务键合并;conversation_summaries 始终从消息重建。 +- idempotency:只有 same_device 或明确选择时恢复;跨设备冲突保留当前 key 并报告冲突,不能把不同 request_hash 合并。 +- secrets.enc:逐字段显式替换,导入后将受影响 profile 标为待验证,并通过 test/health 检查确认,不在恢复阶段发送真实通知。 +- device.meta:只允许同机 snapshot;portable 归档不恢复 modem fingerprint、dedupe namespace 和 mismatch 标记。 + +## 8. 恢复期间的服务与 worker 协调 + +当前 runtime 会同时启动 modem inbound worker、outbound worker、DeliveryWorker、retention worker、HTTP API,并使用 Store、EventBus 和 DeliveryWakeup。恢复必须增加一个共享的 Backup/Restore Coordinator,状态至少为 idle、preparing、quiescing、applying、verifying、restarting、failed。 + +协调规则: + +- 同一时间只允许一个 backup/restore/rollback;清理任务和自动化备份在 maintenance 期间暂停。 +- 第二步完成后禁止新的短信发送、消息删除、配置保存、渠道配置变更和 delivery claim;API 对这些操作返回明确的 maintenance 错误。 +- 停止接收新的 modem 事件,等待当前事务结束;inbound worker、outbound worker、DeliveryWorker 停止 claim 新任务并在有界时间内 drain。无法 drain 的调用必须按现有 uncertain/lease 语义记录,不假装成功。 +- 在数据库交换前关闭所有 SQLite 连接、checkpoint/保存 WAL,并等待 worker 退出;不能让旧 Store 持有被 rename 的文件句柄。 +- 配置变更当前需要重启才能进入运行态,因此恢复 config、channel、delivery、API 或数据库路径相关设置后必须安排受控重启。若重启失败,操作不算 committed,按 snapshot 回滚或标记人工恢复。 +- 重启后重新运行 migrations、完整性检查、SessionStore password synchronization 和过期 delivery lease recovery。默认不唤醒 quarantine 的投递;用户选择 resume 后才调用 DeliveryWakeup。 +- restore 完成后恢复 inbound、outbound、delivery、retention 和自动备份,并发布一个不含敏感数据的 Config/Backup 状态事件。 + +对现有 at-least-once 投递语义的说明必须出现在 API 和前端:恢复可能导致已成功但归档未记录的渠道通知重试,也可能导致不确定的出站短信保持人工确认;系统不能用“恢复成功”承诺外部 provider 没有重复。 + +## 9. 清理策略 + +备份和快照使用独立策略、目录和锁: + +| 类型 | 推荐默认值 | 清理原则 | +| --- | --- | --- | +| 正常 portable 备份 | 30 天、最多 20 个、可选总容量上限 | 只清理已校验且非活动文件;至少保留最近一个有效 full | +| pre-restore snapshot | 至少保留最近 3 个,最短 14 天 | 当前 operation、最近一次失败操作和最后一个 healthy snapshot 不得删除 | +| .partial/staging | 24 小时后 | 只清理没有 active journal 的孤儿目录;操作进行中不清理 | +| 无法校验的归档 | 不自动纳入保留计数 | 移到 quarantine 或标记 invalid,保留足够时间供诊断 | + +清理流程只有在新备份完整写入、fsync、重新校验且登记成功后才能删除旧文件。按时间、数量、容量排序时不能删除最后一个有效恢复点;删除失败不应删除数据库或配置。清理和 restore 使用同一把 operation lock。 + +## 10. API 设计 + +SmsRelayed 当前没有 backup API;建议新增受现有认证 middleware 保护的接口: + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| GET | /api/backup/options | 返回组件、版本、大小上限、存储能力、加密能力和当前空间 | +| GET | /api/backup/config | 返回备份目录、计划、保留期、数量/容量上限;secret 永不返回 | +| PUT | /api/backup/config | 使用 revision/If-Match 保存计划和清理配置 | +| POST | /api/backup/export | 创建 portable export operation;完成后 result 给出一次性下载资源或 local file_id | +| POST | /api/backup/export-local | `/export` 的兼容便捷入口;在受限本地库生成归档,返回同一种 operation | +| POST | /api/backup/import/preview | 上传归档,返回 preview_token 和非敏感摘要 | +| POST | /api/backup/import/apply | 携带 preview_token、mode、components 和安全策略执行恢复 | +| GET | /api/backup/operations/{id} | 查询真实阶段、进度、warning、snapshot_id 和最终状态 | +| GET | /api/backup/files | 列出已校验的 portable 归档和 snapshot 摘要 | +| GET | /api/backup/files/{id}/preview | 重做本地文件预览 | +| GET | /api/backup/files/{id} | 下载 portable 归档;snapshot 默认禁止下载 | +| DELETE | /api/backup/files/{id} | 仅删除用户明确指定的非保护归档 | +| GET | /api/backup/snapshots | 列出可回滚的 snapshot | +| POST | /api/backup/snapshots/{id}/rollback | 需要二次确认,执行完整 snapshot 回滚 | +| GET | /api/backup/health | 返回目录权限、空间、锁和最近一次操作状态 | + +具体约束: + +- 所有接口都返回 operation_id 或 request_id;错误响应使用稳定错误码,正文不进入错误消息。 +- export/apply 支持 request idempotency key,重复请求不能生成两个并发 restore。 +- apply 必须要求 preview_token、显式 components、mode 和确认摘要;replace、secret、resume、rollback 需要更强的确认。 +- 认证 session 在 commit 后失效;正在执行 restore 的请求可以完成,但后续 polling 应允许使用短期 operation token 或重新登录后的只读查询。 +- 上传上限和解压后上限均在 HTTP 层和归档层执行,不能只依赖 body size。 +- 不复用 SimAdmin 当前“HTTP 200 + error envelope”造成的歧义;失败应使用合适的 4xx/5xx,同时返回 operation 状态。 + +### 10.1 重建请求/响应 schema + +export 请求必须明确安全域,`full` 不隐式加入 secret: + +```json +{ + "kind": "full", + "scope": "portable", + "components": ["config.public", "messages", "forwarding.state"], + "destination": "download", + "personal_data": "encrypted", + "include_secrets": false, + "expected_config_revision": "..." +} +``` + +接受后返回 `202`: + +```json +{ + "operation": { + "id": "...", + "kind": "export", + "state": "queued", + "phase": "validating", + "created_at": "...Z" + } +} +``` + +operation 成功后 `result` 至少包含 `artifact_id,manifest_sha256,size,expires_at,download_path?`,客户端再从受保护、同源且短期有效的下载资源取文件。这样大归档、客户端断线和本地导出使用同一事实源;不得在内存中一次性构建整个 archive。若实现确需同步 stream,必须另写兼容 endpoint,响应带 request ID,不能与异步 operation 的语义混用。 + +上传 preview 使用 `Content-Type: application/octet-stream`、受限 body 和经过清理的 `X-Backup-Filename`;服务端流式写 staging,成功返回: + +```json +{ + "preview_token": "...", + "archive": { + "sha256": "...", + "format_version": 1, + "scope": "portable", + "created_at": "...Z", + "source_version": "..." + }, + "components": [ + {"id":"messages","schema_version":1,"records":123,"bytes":4567,"classification":"personal","compatible":true} + ], + "impact": { + "restart_required": true, + "session_invalidation": true, + "required_free_bytes": 123456, + "quarantined_deliveries": 2 + }, + "fatal": [], + "warnings": [] +} +``` + +apply 不再重新上传 ZIP;它用 JSON 引用 preview: + +```json +{ + "preview_token": "...", + "mode": "merge", + "components": ["messages", "forwarding.state"], + "delivery_policy": "quarantine", + "import_secrets": false, + "accept_restart": true, + "confirmation": {"archive_sha256_prefix":"12ab34cd","replace":false} +} +``` + +成功接受返回 `202` operation。相同 `Idempotency-Key` 和 canonical body 返回同一 operation;不同 body 返回 `409 idempotency_conflict`。preview token 必须单次绑定 archive hash、目标 config revision/database generation、actor 和已确认选项;过期或目标变化返回 `409 preview_stale`,不自动重新解释用户选择。 + +operation 查询的固定状态集合为: + +```text +queued -> validating -> snapshotting -> quiescing -> applying + -> verifying -> restarting -> healthy -> committed +任意非 terminal -> cancelling -> cancelled(仅尚未 destructive commit 时) +任意阶段 -> rolling_back -> rolled_back +rolling_back 失败 -> recovery_required +校验/准备失败 -> failed +``` + +每次响应返回 `state,phase,phase_started_at,completed_units,total_units,warnings,snapshot_id,result,error,session_invalidated`。百分比只能由真实单位推导;不知道 total 时返回 null。`committed,rolled_back,failed,cancelled,recovery_required` 是 terminal;`failed` 仅表示 live 状态未变或已安全处理,若 live 状态不确定必须使用 `recovery_required`。 + +### 10.2 重建错误契约 + +```json +{ + "error": { + "code": "archive_checksum_mismatch", + "message": "backup validation failed", + "request_id": "...", + "retryable": false, + "details": {"component":"messages","record_index":null} + } +} +``` + +| HTTP | code 示例 | 语义 | +| --- | --- | --- | +| 400 | `invalid_json`,`invalid_archive`,`missing_preview_token` | 请求/容器不可解析 | +| 401/403 | `authentication_required`,`confirmation_required`,`snapshot_download_forbidden` | 身份、安全域或确认不足 | +| 404 | `artifact_not_found`,`snapshot_not_found`,`operation_not_found` | opaque ID 不存在 | +| 409 | `operation_in_progress`,`preview_stale`,`idempotency_conflict`,`restore_conflict`,`maintenance_active` | 目标或并发状态冲突 | +| 413 | `archive_too_large`,`expanded_size_limit`,`compression_ratio_limit` | 压缩/解压限制 | +| 422 | `unsupported_format`,`unsupported_component_schema`,`semantic_validation_failed`,`incompatible_target` | 可解析但不可应用 | +| 507 | `insufficient_storage` | 预检或写入空间不足 | +| 503 | `worker_quiesce_failed`,`restart_failed`,`dependency_unavailable` | 服务协调失败;是否已回滚从 operation 查询 | + +错误 `details` 只允许组件 ID、记录序号、限制和稳定冲突计数;禁止包含原始行、正文、号码、secret、解密材料或 provider response。 + +## 11. CLI 流程 + +在现有 setup、run、send、update、config check/show 基础上增加: + + sms-relayed backup create --output PATH --kind slim|full + sms-relayed backup preview PATH + sms-relayed backup verify PATH + sms-relayed backup list + sms-relayed backup cleanup + sms-relayed restore preview PATH + sms-relayed restore apply PATH --mode merge|replace --components LIST + sms-relayed restore snapshots list + sms-relayed restore snapshots rollback SNAPSHOT_ID + sms-relayed restore status OPERATION_ID + +规则如下: + +- restore 默认要求交互式确认;非交互执行必须同时传 --yes,并且 replace、secret、resume、rollback 还要有对应确认选项。 +- 归档密码从 TTY、stdin 或 0600 的 passphrase file 读取,禁止使用命令行明文参数;进程列表和 shell history 不能看到密码。 +- 在服务运行时,CLI 通过 coordinator 请求 maintenance;离线模式要求明确说明服务已停止并检查数据库路径,不能静默覆盖正在使用的 SQLite。 +- backup create 可在运行时生成一致性 portable 归档;物理 snapshot 和 restore 必须具备关闭 worker/重启服务的能力,否则只做 preview,不执行 destructive apply。 +- CLI 输出只显示组件、计数、版本、摘要、operation/snapshot id 和错误码,不显示短信正文、号码、secret 或 provider response。 + +## 12. 前端流程 + +前端可沿用 SimAdmin BackupRestore.tsx 的三个区域,但按 SmsRelayed 结构调整: + +### 12.1 备份页 + +- 展示 config.public、messages、forwarding.state、forwarding.attempts、ui.state、idempotency、secrets 和 snapshot 的组件卡片。 +- 每张卡片显示分类、记录数、预计大小、是否包含正文、是否设备绑定;secret/personal 使用显眼的隐私提示。 +- local、download、未来 WebDAV 分开显示;local path 由后端提供且只读,前端不能让用户构造任意路径。 +- 自定义文件名必须由后端校验并真正采用;文件名只允许安全字符,禁止覆盖已有文件和目录穿越。 + +### 12.2 恢复页 + +1. “上传/校验”:拖拽或选择文件,显示 manifest、摘要、版本、兼容性、空间和 warning;不显示正文。 +2. “冲突策略”:选择组件、merge/replace、正文模式、secret 解密、投递 history-only/quarantine/resume;replace 和 credentials 有额外确认。 +3. “执行”:显示 snapshot 创建、worker quiesce、应用、校验、重启和健康检查的真实状态;成功显示 rollback snapshot id,失败显示自动回滚结果。 + +页面必须在执行前明确: + +- 将创建完整 pre-restore snapshot; +- 服务会进入 maintenance 并可能重启; +- 所有 session 会失效; +- active/unknown delivery 和 modem outbound 不会被无条件重发; +- 未包含在归档中的渠道 secret/eSIM 数据需要重新配置。 + +本地库页区分 portable archive、pre-restore snapshot 和 invalid/quarantine 文件。snapshot 只提供预览、回滚和受控删除,不提供普通下载。成功恢复后前端清除旧缓存并跳转登录页;进度不能用固定延时模拟。 + +## 13. 失败、回滚、磁盘和权限 + +### 13.1 失败矩阵 + +| 失败点 | 预期结果 | +| --- | --- | +| 上传、路径、大小、摘要、解密或语义校验失败 | 不接触 live 配置/数据库;删除 staging 或进入 quarantine | +| snapshot 创建失败 | 不开始 restore,保留原 live 状态,报告空间/权限/SQLite 错误 | +| worker 无法 quiesce | 超时并取消操作;不替换数据库,不删除旧 snapshot | +| 数据库事务失败 | 事务回滚,配置临时文件不 commit,live 状态不变 | +| 配置 commit 失败 | 使用 journal/snapshot 恢复数据库,保留失败诊断,不能返回部分成功 | +| 重建派生数据或 integrity_check 失败 | 停止重启流程,使用 pre-restore snapshot 回滚 | +| 服务重启/健康检查失败 | 标记 failed/rollback,保留 old database、new staging、snapshot 和 journal,提供人工恢复命令 | +| 自动回滚失败 | 不继续尝试覆盖;进入 recovery-required,保留所有副本并提示管理员停止服务后离线处理 | +| 清理失败 | 只报告清理失败,不影响已 committed 的数据;至少保留当前恢复点 | + +任何失败日志都必须可由 operation_id 关联,但不得包含正文、号码、secret、密码或完整 provider 错误响应。恢复前后都要保留 config revision、数据库 schema 和归档摘要,便于审计。 + +### 13.2 磁盘空间预检 + +在上传写入、snapshot、解压和最终归档四个阶段分别检查 statvfs。所需可用空间至少按以下保守值估算: + + required_free = + max(2 * archive_uncompressed_limit, + current_db + current_wal + current_shm + current_config) + + 20% headroom + + 64 MiB + +实际值应取 manifest 声明值、HTTP 上限和当前文件大小的最大值;压缩比异常时立即拒绝。空间不足必须在第三步之前报告,不能先删除旧备份来“腾空间”后再尝试;只有用户明确执行清理且仍保留最后一个有效恢复点时才允许清理。 + +### 13.3 权限和路径 + +- 默认 backup root 建议为 /var/lib/sms-relayed/backups,目录 0700、服务用户拥有;config、database、snapshot 和归档文件 0600。 +- 创建目录、临时文件和目标文件时使用 create_new、O_NOFOLLOW、CLOEXEC,拒绝 symlink、硬链接替换和跨目录 rename。 +- 用户提供的 local_dir 必须 canonicalize 到允许根目录内,不能等于 config、database、运行时 socket 或系统二进制目录。 +- 文件写完后 fsync 文件和父目录;崩溃遗留的 .partial 只能在没有 active journal 且超过 TTL 后清理。 +- CLI 若无权限必须报告需要的用户/目录权限;不能自动 chmod 用户已有的任意目录,也不能以 root 将归档写到宽泛路径。 + +## 14. 分阶段交付 + +### Phase 0:协议和协调基础 + +- 固化 manifest、组件 schema、分类、错误码、preview_token 和 operation 状态机。 +- 实现 BackupCoordinator、operation lock、maintenance 状态和敏感日志约束。 +- 为 config 与 SQLite 建立一致性读取、空间预检、权限检查和 staging 目录规范。 + +### Phase 1:可移植备份与只读预览 + +- 交付 slim/full portable ZIP、config.public、messages、forwarding.state、manifest、SHA-256、verify 和本地库。 +- 交付 API export/preview、CLI create/preview/verify、前端第一、二步。 +- 完成正文 metadata-only、redaction、WAL 一致性读取和自动清理。 + +### Phase 2:逻辑恢复与 pre-restore snapshot + +- 交付 merge/replace、typed import、SQLite 事务、配置安全写入、派生数据重建和完整 pre-restore snapshot。 +- 交付 quiesce/restart/health-check、失败自动回滚、snapshot 列表和 rollback API/CLI。 +- 前端交付第三步真实进度、session 失效和登录跳转。 + +### Phase 3:投递、凭据和设备绑定 + +- 交付 forwarding.attempts、idempotency、quarantine/resume 策略。 +- 交付 secrets.enc、personal.enc、passphrase 流程、凭据重置和 profile needs-credentials。 +- 明确 same_device snapshot 与 portable restore 的 device.meta/eSIM 行为。 + +### Phase 4:运营和增强 + +- 交付计划备份、容量保留策略、自动化任务、可选签名、审计查询和监控指标。 +- 评估 WebDAV/对象存储;在远端存储前增加传输加密、端点认证和远端原子提交。 +- 评估跨版本离线恢复工具,但不把版本回滚与数据回滚混为一体。 + +## 15. 验收标准 + +功能完成至少满足: + +1. 可以生成 slim、full 和 same-device snapshot;manifest 的格式、组件、schema、记录数、字节数和 SHA-256 可独立验证。 +2. 任意不完整、篡改、越界、重复、超限或版本不兼容的归档都在写入 live 状态前失败。 +3. 三步恢复中,第一步无副作用,第二步能看到组件和警告,第三步创建完整 pre-restore snapshot 并报告真实 operation 状态。 +4. merge/replace 对消息、投递状态、attempt、UI 状态和 idempotency 的语义符合本规格;派生 summary、索引和触发器由当前 migrations/重建生成。 +5. 恢复期间没有新的 worker claim、modem send、消息删除或配置并发写入;恢复后服务能重启、健康检查通过并恢复正常 worker。 +6. 数据库事务失败、配置写入失败、重启失败和 post-check 失败均能保持 live 状态不变或自动完整回滚,并保留可用 snapshot。 +7. portable 归档不含 session、.auth-key、auth credential state、普通 secret、Matching ID 或设备 namespace;正文和号码遵守明确的加密/确认策略。 +8. 任何 API、CLI、前端和日志输出都不泄漏密码、token、secret、完整电话号码、短信正文或 provider response。 +9. 默认权限、symlink 防护、fsync、空间预检和清理保护满足本规格,崩溃不会产生可被误识别为完整备份的文件。 +10. 现有旧数据库经 migrations 后仍能备份、预览和恢复;自更新成功后,备份/恢复不改变二进制替换和重启语义。 + +## 16. 测试要求 + +### 16.1 单元和属性测试 + +- manifest canonical serialization、组件排序、记录数、字节数、SHA-256、签名和 schema 版本兼容。 +- ZIP 路径穿越、绝对路径、重复条目、symlink、目录伪装、截断文件、超大字段、压缩炸弹和未知组件。 +- config/public redaction、secret envelope 加解密、AEAD 篡改、密码错误、passphrase 不进入日志。 +- typed record 的枚举、时间、长度、外键、状态迁移和冲突检测。 +- 不同 schema fixture 的兼容迁移、未知 optional component warning 和未知 required component fatal。 + +### 16.2 SQLite 与一致性集成测试 + +- WAL 写入期间导出,验证主库、WAL、SHM 的一致性和独立只读连接不阻塞业务 writer。 +- 并发 inbound、outbound、delivery claim、retention 与 export/restore 的锁竞争。 +- merge 去重、replace、外键、触发器、conversation summary 重建、favorite/pin/read 状态和 idempotency 冲突。 +- pending、retry_wait、in_flight、succeeded、permanent_failed、expired lease 以及 sending/uncertain/unknown 的恢复策略。 +- 现有 migrations 从旧 schema 到当前 schema 的备份和恢复;database path、modem path 和设备 meta 不被 portable 归档误改。 + +### 16.3 崩溃、回滚和服务协调测试 + +- 在 validating、snapshotting、quiescing、数据库 commit、配置 rename、verify、restart 各阶段注入进程崩溃或断电。 +- 验证重启后 journal 能识别未完成操作,并选择提交、回滚或 recovery-required;不能把 partial 文件当作有效备份。 +- 验证 worker 不会在 maintenance 中 claim/send,lease 和 quarantine 状态符合策略,恢复后 resume 必须显式开启。 +- 验证 pre-restore snapshot 能完整恢复配置、数据库、派生数据和服务健康状态;回滚后 session 仍全部失效。 +- 验证 systemd/procd 重启成功、失败和二进制已更新但服务未重启的组合不会导致错误的“恢复成功”。 + +### 16.4 安全、CLI、API 和前端验收 + +- 文件/目录权限、父目录 fsync、nofollow、硬链接替换、非服务用户访问和自定义目录越界测试。 +- API preview/apply token 过期、并发操作、权限不足、断点上传、大小上限、正确 HTTP 错误码和敏感字段不回显。 +- CLI 非交互确认、stdin/passphrase file、--yes、服务运行/停止、磁盘不足和命令行进程列表检查。 +- 前端三步流程、组件选择、warning 阻断、真实进度、replace/secret/resume 二次确认、snapshot rollback、session 失效和登录跳转。 +- 端到端验证:生成归档、复制到另一临时实例、预览、恢复消息/历史、重新填写渠道 secret、启动 worker,并检查不发送不应发送的短信或通知。 + +## 17. 实现前必须固化的决策 + +以下仍是 SmsRelayed 重建建议,不是 SimAdmin 已确认行为: + +- `.srb` 是否就是 ZIP v1、canonical JSON/TOML/NDJSON 的字节规则、未知 optional component 的保留/忽略规则,以及 signature 是否在 v1 强制。 +- portable personal 数据是“下载时强制加密”还是允许二次确认后的明文;Argon2id 参数、passphrase 最短策略和 key rotation 流程。 +- archive/upload/expanded/单组件/单记录/字段/压缩比的具体硬上限。不能只沿用参考实现的 50 MiB 压缩 body。 +- snapshot 本地加密密钥来源、backup root、总容量上限、最小健康回滚点数量和管理员恢复手册。 +- message 的跨实例稳定业务键。当前 SmsRelayed 内部 ID、inbound dedupe key 和归档 source ID 的冲突优先级必须用 fixture 证明。 +- `forwarding.state` 的 quarantine 表达方式:独立 restore quarantine 表、delivery 新状态还是 generation barrier;不得复用一个现有可 claim 状态后仅靠内存阻止。 +- config.public 的字段 allowlist、哪些部署字段只可 same-device 恢复、secret 缺失时 profile 的 `needs_credentials` 持久化位置。 +- service quiesce deadline、不可 drain outbound 的 unknown 映射、重启/健康检查预算及 `recovery_required` 的离线修复命令。 +- preview/operation token TTL、幂等 key 保留时间、归档下载 TTL,以及 restore 后 polling 如何跨 session invalidation 延续。 + +未决项必须返回明确的 unsupported/validation error,不能以默认 merge、默认恢复 queue 或明文导出代替产品决策。 + +## 18. SimAdmin 来源索引 + +以下均为 `/tmp/SimAdmin` 相对路径,行号对应本文锁定提交: + +| 事实 | 来源 | +| --- | --- | +| format/app/path/50 MiB/默认组件、组件路径、敏感标记 | `backend/src/backup.rs:31-151`,符号 `BackupComponent` | +| kind/import mode、manifest/options/preview/apply/local-file response schema | `backend/src/backup.rs:155-301` | +| config/auth 实际导出边界 | `backend/src/backup.rs:303-347,976-1127`,符号 `BackupBaseConfig/AuthBackup/export_component` | +| 真实 backup routes 与 upload body limit | `backend/src/main.rs:898-950` | +| HTTP handlers、raw body + query apply、固定 pre-restore 集合 | `backend/src/backup.rs:360-475,552-714` | +| config 规范化、full 判定、filename、内存 ZIP 构建 | `backend/src/backup.rs:771-970` | +| ZIP parse、checksum/count 校验、preview warning | `backend/src/backup.rs:1202-1334` | +| DB transaction 后配置保存、仅 auth 清 session | `backend/src/backup.rs:1336-1502`,符号 `apply_backup` | +| merge/replace 去重与 queue/cache 导入 | `backend/src/backup.rs:1514-1850` | +| local 任意路径、`fs::write`、mtime cleanup、普通/pre 文件解析与下载/删除解析 | `backend/src/backup.rs:2017-2258` | +| backup 配置默认值和未被独立 scheduler 使用的 schedule 字段 | `backend/src/config.rs:1505-1647`;全库消费者见 `backend/src/automation/tasks/backup_data.rs:23-43` | +| automation backup action 直接写 local archive | `backend/src/backup.rs:2039-2057`;`backend/src/automation/tasks/backup_data.rs:23-43` | +| 前端 restore/backup 模拟日志与定时进度、auth 确认、三块文件库 | `frontend/src/pages/BackupRestore.tsx:477-1058,1523-1710,1770-1925` | +| 前端实际 API body/query | `frontend/src/api/current.ts:1054-1142`;类型见 `frontend/src/api/contracts.ts:1118-1224` | diff --git a/docs/specs/simadmin/06-cellular-network-management.md b/docs/specs/simadmin/06-cellular-network-management.md new file mode 100644 index 0000000..6d9a516 --- /dev/null +++ b/docs/specs/simadmin/06-cellular-network-management.md @@ -0,0 +1,659 @@ +# 功能 6:蜂窝网络管理 + +状态:仅规格,不包含实现。 + +本文定义 SmsRelayed 对蜂窝网络管理的产品边界、适配边界和分阶段交付方案。结论先行:SmsRelayed 是短信 relay,不是 CPE/热点控制器;应先补齐可验证的只读诊断和用户意图控制,再谨慎增加驻网、APN 与射频高级操作。SimAdmin 的全部网络/CPE 功能不应无条件迁移。 + +## 0. 证据边界与阅读约定 + +本文核对的 SimAdmin 基线是 `/tmp/SimAdmin` 的 clean `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c`。 + +- “SimAdmin 已确认”是该提交的真实兼容行为,包含其同步长请求、HTTP 200 error envelope、进程内状态和潜在 secret/path 暴露;它不是 SmsRelayed 的推荐安全边界。 +- “重建契约”“必须/禁止”是 SmsRelayed 的目标语义;“建议/推荐”是实现前仍需固化的选择。 +- 本文明确不提供 SimAdmin endpoint 的原路径兼容。若确需兼容旧 UI,应在受保护 adapter 中翻译为 `/api/cellular` operation,不允许旧 handler 直接控制 modem。 + +## 1. 目标 + +### 1.1 产品目标 + +- 在不破坏收发短信的前提下,提供当前 modem、SIM、驻网、运营商、RAT、信号和可用小区信息。 +- 让用户明确控制“是否允许蜂窝数据”和“是否允许漫游数据”,并保证后台恢复逻辑不能违背这些明确意图。 +- 在硬件和 ModemManager 能力已确认时,提供可观察、可超时、可回滚的运营商扫描/注册与 APN 应用。 +- 为射频模式、频段选择和真实小区锁定定义严格的 capability gate;能力不存在时返回不支持或未知,不伪造成功。 +- 将 ModemManager、D-Bus、NetworkManager、QMI 和 AT 的职责分层,使 vendor-specific 能力不会污染核心短信路径。 + +### 1.2 非目标 + +- 不把 SmsRelayed 变成完整 CPE:不迁移 WLAN 配置/扫描、DDNS、路由、防火墙、热点、eSIM/LPA、通话、OTA、设备自动化、系统重启等 SimAdmin 功能。 +- 不因“蜂窝数据关闭”而关闭整个 modem 或影响 SMS/IMS 注册;数据面和短信/控制面必须分离。 +- 不提供任意 `mmcli`、`qmicli`、AT 命令执行接口,不把 modem path、设备节点或 shell 参数直接暴露给 HTTP 请求。 +- 不仅凭 UI 或进程内状态声称“已锁定小区”;没有真实底层写入和重启后验证时,cell lock 必须标记为不支持。 +- 不在本功能中修改、清空或接管宿主机 iptables/ip6tables、默认路由或 NetworkManager 之外的主机网络策略。 +- 不默认启用自动驻网、自动数据连接、自动漫游、频段锁定、小区锁定或 watchdog 射频循环。 + +### 1.3 术语 + +| 术语 | 本规格中的唯一含义 | +| --- | --- | +| modem | ModemManager 枚举且通过现有 fingerprint/path 校验的设备实例 | +| radio | modem 的无线电启用状态;不等同于蜂窝数据 bearer | +| registration | 3GPP home/roaming/searching 等驻网观测值;不等同于数据连通 | +| bearer/data plane | 提供 IP 数据连接的 MM/NM 对象和接口;SMS 可在数据关闭时继续工作 | +| desired | 用户/配置要求系统维持的策略值 | +| observed | 从 MM/NM/QMI/系统读回、带采样时间的事实 | +| enforcement | desired 是否已应用:`satisfied|applying|blocked|failed|unknown` | +| capability | `supported|unsupported|unknown|in_memory_only`,并带 adapter/source;不能由 UI 推断 | +| operation | 长写操作的持久资源,拥有 ID、generation、状态、deadline 和审计 | +| generation | desired/operation 的单调版本,防止晚到结果覆盖更新意图 | +| read-back | 写操作后从独立的 observed source 再读取;调用返回 `Ok` 本身不算 read-back | +| airplane intent | 用户要求关闭 radio 的持久负向意图;比 watchdog 恢复优先 | +| APN configured/applied/verified | 分别表示保存的目标、live profile/bearer 值、实际数据连接验证,不可合并为一个 bool | + +## 2. SimAdmin 的实际 API 与能力基线 + +以下是对 `/tmp/SimAdmin` 实际代码、Bruno 请求和前端的归纳,不代表 SmsRelayed 的承诺 API。 + +### 2.1 实际 HTTP API + +| API | 实际能力 | 重要行为/限制 | +| --- | --- | --- | +| `GET /api/network` | 当前注册状态、运营商、技术、信号 | 主要是 ModemManager 标量属性映射;包含注册状态和 RAT | +| `GET /api/cells` | serving/neighbour 小区 | 优先 QMI NAS,再尝试 ModemManager `GetCellInfo`,最后用 `mmcli` location/signal;不同 modem 输出差异很大 | +| `POST /api/cell-monitor/start`, `/stop` | 开关小区/信号监测 | 通过 `mmcli --location-enable-3gpp` 和 `--signal-setup=5`;用进程内 atomic 防重复,不能保证硬件长期提供数据 | +| `GET /api/network/signal-strength` | 0–100 信号质量 | 不是 RSRP/RSRQ/SINR;数值可能来自 ModemManager `SignalQuality` | +| `GET /api/location/cell-info` | serving/neighbour 的位置/小区摘要 | 从小区信息和 MCC/MNC 组合,字段随 modem 能力而变 | +| `GET /api/network/interfaces`、`/connection-addresses` | 主机接口、地址和流量信息 | 偏 CPE/主机网络观察,不等同于 modem 注册状态 | +| `GET /api/data`、`POST /api/data` | 通过 NetworkManager 开关数据连接 | 维护 `data_user_disabled` 和持久化 `data_enabled`;会同步 NM autoconnect;不会清理宿主机 iptables/ip6tables | +| `GET /api/roaming`、`POST /api/roaming` | 漫游数据策略 | 持久化 `roaming_allowed`;活动数据连接会按 APN 断开/重连;不等同于强制取消网络注册 | +| `GET /api/airplane-mode`、`POST /api/airplane-mode` | modem enable/disable 形式的飞行模式 | `enabled` 实际映射为 modem disabled;有过渡态重试和最长约 45 秒等待 | +| `GET /api/network/operators` | 当前/已缓存运营商 | 读取 `AvailableNetworks` 等 ModemManager 数据 | +| `GET /api/network/operators/scan` | 运营商扫描 | ModemManager `Scan` 请求约 45 秒,再约每 2 秒轮询 `AvailableNetworks`,总计约 20 秒缓存轮询;前端提示可能需要约 2 分钟 | +| `POST /api/network/register-manual` | 按 MCCMNC 手动注册 | 请求如 `{"mccmnc":"46001"}`;`Register` 约 45 秒,错误时可能执行 radio recovery | +| `POST /api/network/register-auto` | 自动注册 | 使用空 MCCMNC 调 `Register`;不会给调用方一个独立的长任务资源 | +| `GET /api/apn`、`POST /api/apn` | APN 配置和 bearer/APN context | 配置保存与活动 bearer 更新混在同一 handler;保存成功但 bearer 更新失败时仍可能返回 success 并附带 `bearer_update_error` | +| `GET /api/radio-mode`、`POST /api/radio-mode` | `auto`、`lte`、`nr` | 读取 `SupportedModes`/`CurrentModes`,映射后调用 `SetCurrentModes`;handler 不显式重新注册、read-back 或回滚,底层 modem 可能自行掉线/重注册 | +| `GET /api/band-lock`、`POST /api/band-lock` | LTE/NR 频段选择 | 读取 `SupportedBands`/`CurrentBands`,调用 `SetCurrentBands`;空数组表示使用全部支持频段;不能保证每个 modem 的物理 band 映射一致 | +| `GET /api/cell-lock`、`POST /api/cell-lock`、`POST /api/cell-lock/unlock-all` | 显示/修改 LTE 或 NR 的 ARFCN/PCI | **仅修改 `CellLockStore` 的内存 tuple,不调用 ModemManager、QMI 或 AT;重启进程即丢失,也不改变 modem** | + +SimAdmin 的统一 response 大多是 HTTP 200 加 `{status,message,data}` 或 `{status:error}`。这适合其现有 CPE UI,但不适合直接作为 SmsRelayed 新控制 API 的错误契约:SmsRelayed 应使用语义化 HTTP 状态码,并区分“不支持”“正在执行”“执行失败”和“结果不确定”。 + +#### 2.1.1 SimAdmin 已确认的请求/响应字段 + +下表补充旧客户端真正依赖的 JSON。所有路由位于认证 middleware 后;表中的业务错误通常仍是 HTTP 200,调用方必须检查 envelope `status`。 + +| 方法与路径 | request | success `data` | +| --- | --- | --- | +| `GET /api/network` | 无 | `{operator_name,registration_status,technology_preference,signal_strength,mcc?,mnc?}` | +| `GET /api/cells` | 无 | `{serving_cell:{tech,cell_id,tac},cells:[...]}`;字段缺失多以空串/0 表达 | +| `POST /api/cell-monitor/start|stop` | 空 JSON 可选 | `{}`;重复 start/stop 也 success | +| `GET /api/radio-mode` | 无 | `{mode,technology_preference,supported_modes}` | +| `POST /api/radio-mode` | `{"mode":"auto|lte|nr"}` | `{}`;只表示 `SetCurrentModes` 返回成功 | +| `GET /api/band-lock` | 无 | `{locked,supported_lte_fdd_bands,supported_lte_tdd_bands,supported_nr_fdd_bands,supported_nr_tdd_bands,lte_fdd_bands,lte_tdd_bands,nr_fdd_bands,nr_tdd_bands}` | +| `POST /api/band-lock` | 上述四个 current 数组 | `{}`;四数组全空会把 current 设置为 supported 全集 | +| `GET /api/network/operators/scan` | 无;注意是 GET,不是 POST | `{operators:[{path,name,status,mcc,mnc,technologies}]}` | +| `POST /api/network/register-manual` | `{"mccmnc":"46001"}` | `{}`,message 为 Registration started | +| `POST /api/network/register-auto` | `{}` | `{}` | +| `GET /api/apn` | 无 | `{contexts:[{path,name,active,apn,protocol,username,password,auth_method,context_type}]}`;**password 会原样返回** | +| `POST /api/apn` | `{context_path,apn?,protocol?,username?,password?,auth_method?}` | `{}` 或 `{bearer_update_error}`;后者仍是 success envelope | +| `GET/POST /api/data` | POST `{"active":bool}` | `{active}` | +| `GET/POST /api/roaming` | POST `{"allowed":bool}` | `{roaming_allowed,is_roaming}` | +| `GET/POST /api/airplane-mode` | POST `{"enabled":bool}` | `{enabled,powered,online}` | +| `GET /api/cell-lock` | 无 | `{any_locked,rat_status:[{rat,rat_name,enabled,lock_type,pci?,arfcn?}]}` | +| `POST /api/cell-lock` | `{rat:12|16,enable,lock_type?,pci?,arfcn?}` | `{success:true}`,仅改内存 | +| `POST /api/cell-lock/unlock-all` | `{}` | `{success:true}`,仅清内存 | + +参考实现没有 operation ID、generation、If-Match、确认字段或统一 mutation conflict 响应。scan 在普通 GET 中最长可等待约 65 秒;register 在普通 POST 中等待最多 45 秒或更久的 recovery。SmsRelayed 推荐的 `POST + 202 operation` 是有意不兼容的新契约。 + +### 2.2 实际后端适配 + +- ModemManager D-Bus 是主要控制面:`Modem3gpp.Scan/Register`、`Modem.Enable/Disable`、`Modem.SetCurrentModes/SetCurrentBands` 以及相关属性读取。 +- 数据连接由 NetworkManager profile 和 `nmcli` 管理。APN 会写入 GSM profile;漫游禁止时使用 home-only 等策略。 +- 小区诊断会优先尝试 QMI NAS,再回退到 ModemManager/`mmcli`;这只是“读到什么就展示什么”,不保证所有 RAT 或邻区字段都有值。 +- `mmcli` 同时被用作发现、监测启停和 fallback/recovery。长时间 Scan/Register、modem 重建和设备重枚举均可能超时或改变 object path。 +- watchdog 周期约 15 秒,会检查 modem、注册、数据和 host firewall 规则;在允许恢复时可能断开/重连、循环 radio、扫描 modem 或重启 ModemManager。 + +#### 2.2.1 SimAdmin 已确认的默认值、写入顺序和并发 + +| 项 | 已确认行为 | +| --- | --- | +| config defaults | `data_enabled=false`、`roaming_allowed=true`;APN 空、protocol=`dual`、username/password 空、auth=`chap` | +| startup | 从 `data_enabled` 构造进程内 `data_user_disabled`;airplane intent 总从 false 开始;无条件调用 `ensure_nm_modem_profile`;2 秒后 init data;5 秒后启动 15 秒 watchdog | +| mutation serialization | radio/band/data/register/airplane/APN bearer 等多条路径使用全局 `with_serial` mutex;不是 per-modem;operator scan 和部分 monitor/read 路径不使用同一锁 | +| data off/on | 先执行 NM live activate/deactivate,成功后保存 `data_enabled` 和内存 flag;再 detached spawn 更新 NM autoconnect,不等待结果 | +| roaming | 先保存 `roaming_allowed`;若 observed data active,则断开再重连。重连失败不会回滚刚保存的 policy | +| airplane | `enabled=true` 时在调用前先把进程内 intent 置 true,成功后 read current state;intent 不持久化。启用失败后该 true 仍可被 watchdog继续执行 | +| APN | 先合并并保存配置,再按客户端提供的 `context_path` 写 live bearer;live 失败仍返回“config saved” success 和原始 `bearer_update_error` | +| radio/band | method 返回即 success;没有 handler 级 read-back、rollback、generation 或 registration verification | + +`GET /api/data` 在内存 user-disabled 为 true 时直接返回 `active=false`,不读实际 NM/MM;否则以 modem state `>= connected` 推断 active。保存 data policy 失败发生在 live 操作之后,可造成 live 已改变、配置未改变。APN GET 会从 bearer/config 回填并返回 password;APN POST 的 object path 来自客户端,后端直接构造 D-Bus proxy,未绑定到已验证 modem。这些都是重建时必须消除的安全/一致性缺口。 + +operator scan 会吞掉 `Scan` 的 D-Bus error/45 秒 timeout,随后轮询 `AvailableNetworks` 约 20 秒,最后可能把旧 cached/current 列表作为 success 返回。manual/auto register 未在 handler 校验 MCCMNC;特定 QMI internal error 会自动 disable/enable radio 并等待 searching/ready,recovery 成功可使 HTTP 返回 success,但不证明已注册到请求的 manual network。 + +cell monitor 使用 `mmcli -m any --location-enable-3gpp`、`--signal-setup=5` 和对应 stop 命令;进程内 atomic 只防同进程重复按钮。start 的第一步成功、第二步失败时 location 可能仍启用;进程重启会丢失 active 标记。 + +### 2.3 硬件和 ModemManager 限制 + +ModemManager 只暴露 modem 固件和插件能够表达的能力。以下情况均属正常失败/未知,不应通过猜测补齐: + +- modem 未暴露 `Modem3gpp`、`SupportedModes`、`SupportedBands`、`CurrentBands`、`GetCellInfo` 或对应的 writable method。 +- 同一型号在不同固件、USB composition、MBIM/QMI/串口模式下的字段和 writable capability 不同;5G NR band、NSA/SA 和 LTE fallback 也不能从 RAT 名称推断。 +- `SetCurrentModes`/`SetCurrentBands` 可能接受调用但需要重新注册,甚至导致 modem 长时间无服务;当前网络支持不代表该 band 组合可用。 +- 运营商扫描、手动注册和启停 radio 都可能耗时几十秒,期间短信 D-Bus 对象、bearer、QMI port 或 modem object path 可能暂时消失。 +- 小区锁定通常是 vendor/QMI/AT 专有能力,ModemManager 通用接口并不保证存在;即便 modem 支持,锁定可能是一次性固件状态、重启丢失或与 band/radio 配置相互覆盖。 +- APN 的真实生效点可能是 NetworkManager profile、ModemManager bearer 或厂商拨号器;只改其中一层不能声称数据连接已经采用新 APN。 + +### 2.4 已知的“仅内存态” cell lock 限制 + +SimAdmin 的 `CellLockStore` 只有 `lte: Option<(arfcn,pci)>` 和 `nr: Option<(arfcn,pci)>`,由 `Arc>` 持有。`/api/cell-lock` handler 只调用 store 的 `apply/status/unlock_all`,完全没有底层锁网调用;Network 页面仍然把它展示为 cell lock 按钮。这是 UI/状态语义错误,而不是可移植的实现参考。 + +SmsRelayed 必须遵守以下边界: + +1. 不复制这个 store 作为“锁定功能”。 +2. 如果尚无真实 adapter,API 只能返回 `capability: "unsupported"` 或 `501 Not Implemented`,明确说明“未向 modem 写入”。 +3. 若未来增加真实锁定,必须记录 adapter、RAT、ARFCN、PCI、持久性和重启后 read-back 结果;仅内存的 desired state 只能叫“待应用配置”,不能叫“已锁定”。 + +### 2.5 已确认的参考前端行为与误导风险 + +以下只是兼容性事实,不是目标 UX: + +- Network 页进入小区页签时调用 monitor start,按用户选择的刷新间隔轮询;切换页签/卸载时 fire-and-forget stop。两个页面实例或快速切换会互相停止共享 monitor,后端也没有订阅者引用计数。 +- operator scan 由浏览器发起一个同步 `GET`,按钮一直等待该请求;manual/auto register 成功后延迟 3 秒刷新,而不是轮询注册 operation。 +- radio mode POST 返回后,前端立即把选择值写成本地 current mode,并在 3 秒后刷新 band;没有以服务器 read-back 覆盖成功文案。 +- band 选择在成功后写入浏览器 `localStorage`,空数组被当作“取消限制”;本地选择不是设备 desired/observed truth,也不会在另一浏览器同步。 +- cell lock handler 只改内存,但前端显示“已锁定/已解除”并在 2 秒后刷新,造成硬件已生效的错觉。 +- APN GET 返回的 username/password 被直接填入表单;保存时又把 password 作为普通 JSON 字段发回。返回成功后页面统一显示“已保存”,不会把 `bearer_update_error` 解释为 saved-not-applied。 + +重建可以复用“页签按需加载、后台暂态错误不打扰用户”的交互意图,但不得复用上述 secret 回显、乐观成功、localStorage truth 或同步长请求模式。 + +## 3. SmsRelayed 当前状态与缺口 + +### 3.1 当前已有能力 + +- `src/modem.rs` 的 `ModemService` 通过 `mmcli` 查询 modem JSON,旧版不支持 JSON 时回退到文本;现有 `ModemStatus` 包含 enabled、state、SIM state、号码、运营商名称、信号质量和 access technologies。 +- 现有动作只有 enable、disable、reset。动作使用 try-lock 串行化;reset 按 session token 限流;执行前要求已验证的 modem path,并检测 path drift/设备 fingerprint,避免对错误的重枚举 modem 执行危险动作。 +- `src/dbus/*` 是短信收发路径:使用 ModemManager SMS/Messaging D-Bus、信号订阅、连接缓存和 5–30 秒级超时;没有 Modem/Modem3gpp/NetworkManager 的通用网络控制抽象。 +- `src/modem/ims/*` 的 native QMI 只读探测 IMS/NAS 能力,选择唯一或 primary QMI port,探测总期限约 5 秒;探针会拒绝歧义端口、权限失败和 proxy 不可用,不执行通用 NAS 写操作。 +- API 路由只有受 session 保护的 `/api/modem/status`、`/enable`、`/disable`、`/reset` 等;受保护路由还包括短信、配置、转发和 service 控制。 +- TOML 配置目前有 `[app]` 的 `modem_path`、API session/password、SMS/转发等配置,没有蜂窝数据、漫游、APN、注册、radio、band 或 cell lock 配置。 +- 前端 Status 页的 Modem panel 展示健康、SIM、运营商、信号、access technology、短信能力,并提供启停/确认 reset;没有 SimAdmin 那种 Network/CPE 页面。现有测试覆盖 mmcli JSON/text、disabled/SIM missing、path drift、QMI port selection、IMS probe、D-Bus SMS owner change/超时等。 + +### 3.2 缺口与风险 + +| 能力 | 当前状态 | 不能直接假设 | +| --- | --- | --- | +| 注册 | 只有 modem state/access technology 的粗粒度信息 | `state=registered` 不是 MCC/MNC、home/roaming 或可用数据的完整证明 | +| 运营商 | 只有 operator name 的诊断字段 | 没有 scan、manual register、auto register job | +| 信号/小区 | 只有 `signal_quality` 百分比;IMS QMI 不是 cell API | 没有 RSRP/RSRQ/SINR、PCI、ARFCN、邻区或 serving-cell 模型 | +| 数据 | 无 NetworkManager/ModemManager bearer 控制 | 不能提供 data switch 或 watchdog reconnect | +| 漫游 | 无策略模型 | 不能从 operator name 或注册状态推导允许漫游 | +| APN | 无 APN 配置/验证 | 不应把 APN credential 塞进现有 modem status | +| 飞行模式/radio | modem enable/disable 与 radio mode 尚未抽象 | 不能把 disable 直接当作可恢复的 airplane policy | +| band lock | 无 `SupportedBands`/`SetCurrentBands` 适配 | 不应按 band number 猜测 ModemManager ID | +| cell lock | 无底层接口 | 不能复制 SimAdmin 的内存态假实现 | +| watchdog | 没有蜂窝恢复循环 | 新 watchdog 若越权,会破坏用户数据/漫游意图和短信稳定性 | + +## 4. 推荐的能力分层 + +能力必须按“可观察性”和“破坏半径”分层;每一层都要先完成 capability discovery 和 read-back,再允许下一层。 + +### L0:只读诊断(默认、优先交付) + +建议提供统一 snapshot,至少包括: + +- modem identity 的非敏感摘要、已验证 object path、ModemManager/plugin/tool capability; +- SIM ready/locked/missing、modem state、radio enabled/disabled; +- registration state、MCC/MNC、operator name/code、home/roaming、RAT/access technology; +- `SignalQuality` 以及可用时的 RSRP/RSRQ/SINR、serving cell、neighbour cells、PCI、ARFCN、band、TAC/CID;缺失字段返回 `null` 与 reason; +- data bearer/profile 的状态、接口和 IP 摘要,但不返回 APN 密码或完整 IMSI/ICCID/号码; +- APN 的有效来源和脱敏值;`configured`、`applied`、`verified` 必须分开; +- capability flags:`supported`、`unsupported`、`unknown`、`in_memory_only`,以及 adapter/source 和采样时间。 + +小区监测应是按需、只读、有限频率的操作;默认不常驻启用 QMI/AT polling,避免与 SMS/IMS QMI port 争用。 + +### L1:低风险用户策略开关 + +低风险层只包含与短信 relay 直接相关的策略: + +- **蜂窝数据允许/禁止**:禁止数据必须断开当前数据连接、关闭相关 NM autoconnect,并持久化为最高优先级的用户意图;不能关闭 radio,也不能阻止短信接收/发送尝试。 +- **允许/禁止漫游数据**:禁止漫游时阻止/断开 roaming bearer,但保持 modem 注册能力;不能用“当前不是 roaming”替代策略值。 +- **飞行模式**是用户可操作但高影响的显式开关,不应称为低风险自动控制。它可以关闭 modem radio,但必须确认、展示长任务状态,并禁止 watchdog 自动重新启用。 + +L1 不包括后台自动切换 radio、band、cell 或自动替用户打开数据/漫游。 + +### L2:网络注册 + +- 先提供当前/缓存运营商列表的只读查询,再提供显式触发的 scan。 +- 手动注册要求合法 MCCMNC、二次确认、长任务 operation id 和完成后的 registration read-back;自动注册是显式用户操作,不是默认后台策略。 +- scan/register 期间标记网络控制面为 busy,不能并发执行 airplane、radio、band、APN apply 或另一注册操作。 +- 如果 operator scan/register 可能影响短信可达性,UI 必须先说明;失败不应自动扩大为 baseband reset,除非另有用户确认和独立 recovery policy。 + +### L3:APN + +- APN 是持久化 desired config、NetworkManager profile/bearer 的 live config 和实际已连接验证结果三者的组合。 +- 先支持读取/校验/保存和显式 apply;对活动 bearer 使用新 APN 前应捕获旧 profile,应用后验证 bearer 的 APN、接口和连接状态。 +- 用户名、密码、认证方式、IP protocol 必须有 schema 校验;密码只接受写入/保留语义,不在 GET、错误、事件、日志或前端回显。 +- APN 应用失败不能返回“已成功应用”;最多返回 `saved_not_applied`,并提供 operation status 与旧配置/恢复结果。 +- 不为短信 relay 默认创建数据连接;是否管理数据连接必须由显式 feature gate 和用户策略决定。 + +### L4:射频、频段和小区锁定 + +- **radio mode**(auto/LTE/NR 等)属于高影响操作,只能使用 modem 报告的 supported/current modes;切换后必须等待并验证注册,失败时尽力恢复此前 mode。 +- **band lock** 只有在 `SupportedBands`、`CurrentBands` 和 `SetCurrentBands` 全部可用且物理 band 映射经过 adapter 验证时才开放。空集合的“解锁全部”必须被明确定义并 read-back,不能把“用户没有选择”误当作安全默认。 +- 选择频段时至少保留用户明确允许的 fallback 或要求用户确认“可能完全失去服务”;核心产品不提供自动 band optimizer。 +- **cell lock** 默认不提供写操作。只有完成真实 QMI/vendor/AT adapter、读回、重启持久性和解锁路径验证后才可进入实验性/高级功能;否则只展示发现到的 serving cell。 + +## 5. 适配边界:ModemManager、D-Bus、NetworkManager、QMI、mmcli、AT + +### 5.1 ModemManager D-Bus:首选抽象 + +生产控制应优先经 D-Bus adapter,按 object path 和接口 capability 调用,而不是拼接命令行: + +- `org.freedesktop.ModemManager1.Modem`:状态、启用/禁用、signal/access technology、`SupportedModes`/`CurrentModes`; +- `Modem3gpp`:registration、operator、`AvailableNetworks`、scan/register; +- `Modem.Simple`/Bearer:只在明确确认 ModemManager bearer 是本项目数据路径时使用; +- `SupportedBands`/`CurrentBands`/`SetCurrentBands`:仅在三者和 modem plugin 语义一致时使用; +- `GetCellInfo` 等只读接口:作为 cell source 之一,不承诺所有字段。 + +每个 adapter 应在启动和 modem 重枚举后重新发现 capability,缓存的 unsupported 不能永久阻止后续 modem;对象路径必须由现有 path verification/fingerprint 机制保护。 + +### 5.2 NetworkManager:数据面边界 + +NetworkManager 负责主机连接 profile、autoconnect、bearer activation、接口和地址。蜂窝控制层只通过受控 adapter 改变目标 GSM profile,不直接编辑任意连接、不修改路由/防火墙。没有 NetworkManager 或无可验证 GSM profile 时,数据/APN 写操作返回 unsupported/unknown;不能因为 modem 已注册就声称“数据已连接”。 + +### 5.3 `mmcli`:诊断和兼容 fallback + +`mmcli` 可以用于 modem discovery、健康诊断、版本兼容 fallback 或明确隔离的 recovery,但不是新控制 API 的默认实现层。所有调用都必须: + +- 使用已验证的 modem id/path; +- 固定参数 allowlist、独立超时和输出解析; +- 不记录完整命令、手机号、IMSI、APN 密码、SMS body 或原始 QMI/AT payload; +- 对长操作返回 operation 状态,而不是占住普通 HTTP 请求; +- 与 D-Bus adapter 共享同一 per-modem mutation lock。 + +### 5.4 QMI/qmicli:能力专用 adapter + +当前 SmsRelayed 的 native QMI 是 IMS/NAS 只读探针,使用 QMI proxy、5 秒 deadline、primary/唯一 QMI port 选择,并明确拒绝歧义 port。它不能直接升级为通用网络写入层。未来若需要 QMI cell/band/lock: + +- 必须另建 capability-specific adapter 和 modem/firmware compatibility matrix; +- 与 IMS probe 隔离 client、锁和错误码,避免 QMI CID/port 争用影响短信; +- qmicli 仅作为可选外部 fallback,不能成为所有发行版的硬依赖; +- 不能因为某个 QMI NAS 查询成功就推断 band/cell lock 写能力存在。 + +### 5.5 AT:最后的 vendor adapter + +AT 只允许在明确型号/固件/串口映射的 adapter 内部使用。命令必须静态 allowlist、参数范围校验、串口互斥、响应 parser 和超时;禁止 HTTP 传入原始 AT。AT 失败或响应不确定时,状态标为 uncertain,不继续发送破坏性命令。没有可维护的兼容矩阵就不纳入核心阶段。 + +## 6. 用户意图、watchdog 与自动恢复优先级 + +### 6.0 SimAdmin 已确认的 watchdog 基线(不作为目标) + +参考 watchdog 每 15 秒一轮:找到 modem 并成功读取 state 后,先检查进程内 airplane intent,再检查 `data_user_disabled`;二者会阻止该分支的 reconnect/register/radio-cycle。若 modem discovery 本身失败,则代码不会先检查这两个 intent,连续 3 次会执行 `mmcli --scan-modems`,连续 5 次可 `systemctl restart ModemManager`,recovery cooldown 300 秒。它只检查并记录 iptables 是否有规则,不再自动 flush。 + +在未阻断时,searching 连续 4 轮会 auto register,8 轮会 radio cycle;enabled-idle 8 轮或 transition 6 轮会 radio cycle;data activation retry cooldown 120 秒。`roaming_allowed=false` 会传给 NM connection policy,但本身不阻止 auto register/radio recovery。计数、cooldown、airplane intent 全在内存中,进程重启即丢失。 + +因此下述优先级是 SmsRelayed 的**重建契约**,不是对参考实现已经满足的声明。特别是 modem missing recovery 也必须受持久 airplane/maintenance intent 和 operation generation 约束;不能因为 object 消失就绕开用户负向意图。 + +用户明确的负向意图必须比可用性自动化更强。建议把“观测状态”和“desired policy”分开持有,优先级如下: + +1. **飞行模式意图**:用户要求关闭 radio 时,任何 watchdog、数据连接、驻网、scan/register、band/radio recovery 都不得自动重新启用 modem。 +2. **用户禁止数据**:断开数据并关闭 autoconnect;watchdog 不得 reconnect、Simple.Connect 或修改为启用。radio/注册可继续为短信服务工作。 +3. **用户禁止漫游数据**:不允许 roaming bearer;若当前处于 roaming 且数据已连,断开数据;不得通过强制打开 roaming 或切换注册来绕过策略。 +4. **正在执行的显式用户操作**:在不违反上述负向意图的前提下,scan/register/APN/radio/band 操作独占控制锁;完成后必须 read-back。 +5. **watchdog 只读观察与有限恢复**:只在无阻断意图、无显式操作、超过去抖阈值时运行;第一阶段只记录诊断、刷新状态或提示用户。 +6. **自动数据连接/自动注册**:默认关闭;只有配置和用户都明确开启时才可执行。 + +用户重新打开数据或允许漫游,只表示恢复许可,不表示立即 radio cycle、强制注册或立刻消费漫游流量;恢复动作应按正常冷却和显式策略执行。 + +任何控制 API 的返回都应同时报告 `desired`、`observed`、`enforcement` 和 `last_operation`。例如数据关闭但 NM 断开尚未完成时,状态应是 `desired=disabled, observed=disconnecting, enforcement=blocked`,而不是简单 `false`。 + +## 7. 状态机、超时、并发与回滚 + +### 7.1 分离的状态机 + +不要用一个字符串覆盖所有状态。建议至少分成四个正交维度: + +```text +modem: unavailable -> discovered -> disabled + -> enabling -> enabled/searching + -> registered_home | registered_roaming + -> disabling -> disabled + +data: unknown -> disconnected -> connecting -> connected + \-> disconnecting -> disconnected + +policy: airplane_requested, data_user_disabled, roaming_allowed + +operation: queued -> running -> verifying -> succeeded + \-> failed | timed_out | uncertain + \-> rolled_back | cancelled +``` + +`registered_home`/`registered_roaming` 是网络观测值,不能替代 `roaming_allowed` 策略;`connected` 是已验证数据面,不等于 modem state 高于某个数字。modem object removal、ModemManager owner change、设备重枚举统一进入 `unavailable/unknown`,并使当前 operation 进入 `uncertain`,不能猜测成功。 + +### 7.2 建议超时和轮询 + +这些是 API/测试契约的初始预算,实际 adapter 可按 capability 调整,但不得无限等待: + +| 操作 | 单步/总预算建议 | 超时后的行为 | +| --- | --- | --- | +| D-Bus 属性读取 | 2–5 秒 | 返回 stale/unknown,保留采样时间和 reason | +| modem enable/disable、airplane | 单次 5 秒、总计 45 秒 | 重新读取状态;不确定时保留安全的用户意图并提示 | +| operator scan | `Scan` 45 秒,随后最多 20 秒缓存轮询 | operation `timed_out`/`uncertain`,不自动重试多次 | +| manual/auto register | `Register` 45 秒,验证注册最多再 60 秒 | 不自动 reset;用户另行确认 recovery | +| NM APN/data apply | 每个步骤 15–30 秒,总计 60 秒 | 读取旧 profile/live 状态,执行回滚或返回 uncertain | +| cell/signal snapshot | 5–10 秒 | 缺失字段为 null;不阻塞状态页 | +| operation 总期限 | 建议 120 秒硬上限 | 取消后禁止晚到的结果覆盖新 desired generation | + +### 7.3 并发与取消 + +- 每个 modem 一个 mutation lock;airplane、data policy apply、scan/register、APN、radio、band、cell lock 互相声明冲突关系。 +- 只读 snapshot 可并发,但要有短缓存/请求合并,避免前端轮询风暴;cell monitor 最多一个实例。 +- 冲突请求返回 `409 operation_in_progress` 和当前 operation id,不排队执行未知数量的破坏性操作。 +- 每个 desired policy 和 operation 带单调递增 generation;晚到的 D-Bus/QMI/NetworkManager结果只能更新同 generation,不能覆盖用户新选择。 +- 网络控制锁不得包住短信发送的整个生命周期;SMS D-Bus owner change、对象消失和发送结果 unknown 必须沿用现有短信恢复语义。 +- 取消只取消等待/后续步骤,不能假定 modem 已回到旧状态;取消后仍必须 read-back。 + +### 7.4 回滚与不确定结果 + +- APN、radio mode、band selection:先读取旧 live state 和旧持久化配置,应用后 read-back;失败时尽力恢复旧值,并分别报告 `rolled_back` 或 `rollback_failed`。 +- 配置写入采用现有安全的临时文件、权限和 revision/precondition 思路;不能出现 live 已切换而旧凭据丢失的情况。 +- 数据禁止和飞行模式是安全负向意图:底层断开/禁用失败时也保留阻断 intent,禁止 watchdog 趁失败间隙恢复连接;状态显示 `uncertain` 并要求用户明确重试或解除意图。 +- 如果 modem 在应用中消失,不能立即写入“成功”;重连后必须用 fingerprint/object path 重新发现并验证,无法验证时提供人工恢复提示。 +- band/cell 操作只要没有硬件 read-back,就不得从命令返回值推断已生效;cell lock 不支持时不产生“回滚成功”的假事件。 + +## 8. 权限、安全与隐私 + +- 所有新蜂窝 API 默认加入现有 protected router,要求有效 session;写操作要求 `application/json`、same-origin/CSRF 防护和明确确认字段。 +- 继续使用已验证 modem path、path drift 检查和 identity fingerprint;绝不接受客户端任意 object path、`/dev/*`、NM connection name 或命令片段。 +- scan/register、airplane、radio mode、band lock、APN apply、cell lock 等操作应有速率限制;reset、baseband recovery 等高影响动作必须独立确认且不能被 watchdog隐式触发。 +- 日志、事件、错误和前端响应不得泄露 APN password、用户名(除非必要且脱敏)、IMSI、ICCID、完整号码、QMI/AT 原始 payload、完整 cell location 或 SMS body。现有 public health 的最小披露原则继续适用。 +- API 错误只返回稳定 code 和可行动的摘要;底层 stderr、命令行、D-Bus object path 和 vendor 错误写入受控 debug 日志时也必须脱敏。 +- 不提供任意 shell/AT 代理,不清空 host firewall,不修改与短信 relay 无关的接口/路由。 +- 单用户产品当前不必引入复杂 RBAC,但应保留 operation audit:谁、何时、请求哪种 capability、结果是 succeeded/failed/uncertain;不记录 secret 内容。 + +## 9. API、配置与前端方案 + +### 9.1 推荐 API 契约 + +建议新增独立命名空间 `/api/cellular`,不要把 CPE 端点原样复制到 `/api/modem`。推荐形态如下: + +| 资源 | 读/写 | 说明 | +| --- | --- | --- | +| `/api/cellular/status` | GET | 单次完整 snapshot:modem/registration/data/policy/capability/source/采样时间 | +| `/api/cellular/cells` | GET | serving/neighbour;按 capability 返回字段,不能保证有 cell lock | +| `/api/cellular/operators` | GET | 当前/缓存列表 | +| `/api/cellular/operators/scan` | POST | 返回 `202 {operation_id}`,不在普通请求中阻塞 45–120 秒 | +| `/api/cellular/registration` | POST | `{mode:"auto"}` 或 `{mode:"manual",mccmnc:"..."}`;显式确认、返回 operation | +| `/api/cellular/operations/{id}` | GET | `queued/running/verifying/succeeded/failed/uncertain/rolled_back` 及安全错误摘要 | +| `/api/cellular/data` | GET/PUT | desired policy 与 observed data 状态分开;PUT 要求 `{enabled,confirm?}` | +| `/api/cellular/roaming` | GET/PUT | policy 与 live registration 分开;禁止漫游不必 deregister | +| `/api/cellular/airplane` | GET/PUT | 明确高影响确认;不可由 watchdog 覆盖 | +| `/api/cellular/apn` | GET/PUT | GET 脱敏;PUT 支持保留密码和显式 apply/test operation | +| `/api/cellular/radio-mode` | GET/PUT | 只呈现 ModemManager 实际 supported modes | +| `/api/cellular/bands` | GET/PUT | 只有 capability gate 通过才允许写;提供旧值、fallback 和 read-back | +| `/api/cellular/cell-lock` | GET/PUT | capability 为 `unsupported` 时返回 501/明确状态;不提供 SimAdmin 内存态写成功 | + +新 API 建议使用 `200`(读/同步完成)、`202`(已接受的长 operation)、`400/422`(输入或 capability 参数错误)、`401/403`(权限/确认)、`409`(并发冲突)、`501`(未实现/不支持)、`503`(依赖不可用)、`504`(adapter 超时)。所有长操作都必须有可轮询的 operation resource。 + +#### 9.1.1 重建 snapshot schema + +`GET /api/cellular/status` 返回一个自洽采样;空值和不支持必须显式,不能沿用 SimAdmin 的空串/0: + +```json +{ + "sampled_at": "...Z", + "generation": 12, + "modem": {"state":"registered_home","radio_enabled":true,"fingerprint":"short-redacted"}, + "registration": { + "state":"home","operator":{"name":"...","mccmnc":"46001"}, + "rat":["lte"],"reason":null + }, + "signal": {"quality_percent":72,"rsrp_dbm":null,"rsrq_db":null,"sinr_db":null,"source":"modemmanager"}, + "data": { + "desired":"disabled","observed":"disconnected","enforcement":"satisfied", + "profile_id":"redacted-stable-id","interface":null + }, + "policy": {"airplane_requested":false,"roaming_allowed":false,"auto_register":false,"auto_reconnect":false}, + "capabilities": { + "operator_scan":{"state":"supported","adapter":"modemmanager"}, + "band_write":{"state":"unknown","adapter":null}, + "cell_lock":{"state":"unsupported","adapter":null} + }, + "last_operation_id": null +} +``` + +敏感身份只返回产品确实需要的脱敏/稳定摘要;完整 IMSI、ICCID、号码、APN secret、D-Bus path 和 `/dev` 节点不属于此响应。若某 source 读取失败,保留该字段 `null`、`sampled_at` 和稳定 `reason`;不得用上次值冒充新采样,缓存值必须带 `stale=true` 和原采样时间。 + +#### 9.1.2 重建写请求与 operation + +所有 mutation 要求 `Idempotency-Key`、JSON、expected generation;已处于目标且 read-back 可证明时可同步返回 `200`,否则返回 `202`: + +```json +{ + "operation": { + "id": "...", + "kind": "registration.manual", + "generation": 13, + "state": "queued", + "phase": "admission", + "requested_at": "...Z", + "deadline_at": "...Z" + } +} +``` + +推荐 request body: + +| API | body | +| --- | --- | +| `PUT /data` | `{"enabled":false,"expected_generation":12,"confirm":true}` | +| `PUT /roaming` | `{"allowed":false,"expected_generation":12}` | +| `PUT /airplane` | `{"enabled":true,"expected_generation":12,"confirmation":"ENABLE AIRPLANE MODE"}` | +| `POST /operators/scan` | `{"expected_generation":12}` | +| `POST /registration` | `{"mode":"manual","mccmnc":"46001","expected_generation":12,"confirm":true}` 或 mode auto | +| `PUT /radio-mode` | `{"mode":"lte","expected_generation":12,"fallback":"previous","confirm":true}` | +| `PUT /bands` | `{"bands":[{"rat":"lte","number":3}],"unlock_all":false,"expected_generation":12,"fallback":"previous","confirm":true}` | + +MCCMNC 必须是 5 或 6 位 ASCII 数字并在 server-side schema 校验;band/mode 只能取本次 capability snapshot 暴露的 canonical ID。客户端不能提交 MM numeric band ID、object path、profile name 或 adapter 名称。 + +APN 使用 secret 三态,GET 永不回传 secret: + +```json +{ + "apn":"internet", + "protocol":"ipv4v6", + "username_update":{"mode":"keep"}, + "password_update":{"mode":"replace","value":"..."}, + "auth_method":"chap", + "apply":true, + "test_connectivity":false, + "expected_generation":12, + "confirm":true +} +``` + +`mode` 只允许 `keep|replace|clear`,只有 replace 接受 value。APN GET 返回 `username_present/password_present` 和 configured/applied/verified 三层状态,不返回原值。operation 保存 params redaction 后的 fingerprint;同 idempotency key/同 body 返回同一 operation,不同 body 返回 409。 + +operation 状态固定为: + +```text +queued -> running -> verifying -> succeeded + \-> rolling_back -> rolled_back + \-> failed | timed_out | uncertain | rollback_failed +queued/running -> cancelling -> cancelled(仍需 read-back) +``` + +详情返回 `state,phase,generation,capability,adapter,started_at,deadline_at,finished_at,previous_safe_summary,observed_after,error,rollback`。`uncertain/rollback_failed` 禁止 watchdog 自动采取扩大的恢复动作;必须等待同 generation reconcile 或人工选择。 + +#### 9.1.3 重建错误 envelope + +```json +{ + "error": { + "code":"operation_in_progress", + "message":"cellular mutation is busy", + "request_id":"...", + "retryable":true, + "details":{"operation_id":"...","resource":"modem.control"} + } +} +``` + +| HTTP | code 示例 | 场景 | +| --- | --- | --- | +| 400 | `invalid_json`,`missing_idempotency_key` | transport/request 格式 | +| 401/403 | `authentication_required`,`confirmation_required`,`policy_forbidden` | 身份、确认、负向 intent 阻断 | +| 404 | `operation_not_found` | opaque operation ID | +| 409 | `operation_in_progress`,`generation_mismatch`,`idempotency_conflict`,`modem_identity_changed` | 并发/晚到/重枚举 | +| 422 | `invalid_mccmnc`,`invalid_apn`,`unsupported_selection`,`no_safe_fallback` | 输入可解析但不合法 | +| 429 | `rate_limited`,`cooldown_active` | scan/register/high-impact 限流 | +| 501 | `capability_unsupported` | 确认不支持;cell lock 默认使用此语义 | +| 503 | `modem_unavailable`,`networkmanager_unavailable`,`dependency_unavailable`,`maintenance_active` | 外部依赖或维护 barrier | +| 504 | `adapter_timeout` | 请求尚未创建外部副作用;若副作用可能已发生,应创建 operation 并标 `uncertain`,不能只返回 504 | + +`UnknownMethod/Unsupported` 映射 capability unsupported;`AccessDenied` 映射依赖权限错误;`NoReply/owner change/object removed` 通常映射 operation uncertain。底层 stderr、D-Bus path、APN 值、raw QMI/AT 不进入 error response。 + +### 9.2 推荐配置 + +建议增加独立且默认关闭的配置段;名称可在实现前按现有 TOML 风格定稿: + +```toml +[cellular] +enabled = false # 未显式开启时只保留现有 modem/SMS 功能 +manage_data = false # 不默认接管 NetworkManager 数据连接 +data_enabled = false # 用户意图;实现时要区分“未设置”和“明确关闭” +roaming_allowed = false # 默认不替用户产生漫游数据;按部署需求显式开启 +auto_register = false +auto_reconnect = false +watchdog_recovery = false +``` + +- `enabled=false` 时不得自动创建 NM profile、开启数据、扫描运营商或写 radio/band/cell。 +- APN 放在 `[cellular.apn]` 或同等子段,密码沿用现有 0600 配置文件保护;配置 summary、API GET 和事件全部脱敏。 +- airplane、radio、band、cell lock 的持久化必须分别定义“desired”和“last applied”;在 capability 未确认前不持久化伪造的 cell lock。 +- 用户负向意图(数据关闭、漫游关闭、飞行模式)应持久化并在服务重启后先恢复为阻断策略,再开始任何自动恢复。 +- 配置 revision/precondition 与 live apply 必须有明确顺序和回滚规则,不能采用“先返回成功、后台随便尝试”的语义。 + +### 9.2.1 推荐持久化与重启恢复 + +这是目标设计,不是 SimAdmin 已有行为: + +- `cellular policy` 使用原子替换的持久化配置保存 `enabled/manage_data/data_enabled/roaming_allowed/airplane desired`、APN secret 引用和单调 `revision`;`observed` 状态不写成 policy。 +- 长操作写入 `cellular_operations`(或等价持久层),至少包含 `id/type/state/target_fingerprint/redacted_params_hash/idempotency_key/config_revision/created_at/started_at/deadline_at/finished_at/error/rollback_state`。不得持久化 APN secret、原始命令或完整身份标识。 +- 服务启动时把遗留 `queued/running/cancelling` 标为 `reconciling`,重新发现 modem 身份并 read-back;能证明目标已达成则 `succeeded`,能证明未执行则 `failed`/按策略重试,无法证明则 `uncertain`。不得盲目重放 radio/band/APN/register。 +- 操作保留期、事件保留期和幂等键 TTL 必须成为显式配置/常量并有测试;在实现前需冻结具体默认值。过期清理不能删除仍在运行、uncertain 或等待人工处理的记录。 +- capability/cache 可留在内存,但必须绑定 modem fingerprint、D-Bus owner generation 和采样时间;重枚举或 owner change 立即失效。前端 localStorage 只能保存展示偏好,不能保存硬件 truth。 + +### 9.3 推荐前端 + +- 第一阶段把蜂窝诊断放在现有 Status/Modem 区域或一个显式的 Advanced Cellular 页面,不复制 SimAdmin 的 CPE Dashboard、WLAN 和系统控制入口。 +- Dashboard/Status 只显示紧凑摘要:注册、运营商、RAT、信号、home/roaming、数据 policy/observed、SMS 可用性;cell 详情按需展开。 +- 数据/漫游开关显示 pending、enforcement、最后操作和失败原因;不可用/未知/不支持必须有文字说明,不使用乐观 UI 永久覆盖服务器状态。 +- 飞行模式、手动注册、APN apply、radio/band 操作都先确认影响,显示预计可能断网/短信延迟,提交后轮询 operation;页面离开后操作仍可观察。 +- APN 密码输入支持“保持现有密码”,保存后不回显;错误中不展示 bearer secret。 +- band UI 只列出服务器 capability 返回的实际支持集合;不使用 localStorage 作为 desired/hardware truth,不把空选择隐式当作危险锁定。 +- cell lock 控件在真实 adapter 未交付前应隐藏或明确禁用,并显示“当前产品未向 modem 写入小区锁定”;不保留 SimAdmin 那种误导性成功按钮。 + +## 10. 测试矩阵 + +| 层级 | 必测场景 | 验收重点 | +| --- | --- | --- | +| 只读解析 | ModemManager JSON/text、缺少接口、SIM missing、disabled、无信号、旧版 mmcli | 字段为 null/unknown 且 reason 清楚;不把 fallback 当完整能力 | +| object path/身份 | modem 重枚举、owner change、path drift、多个 modem、fingerprint 不匹配 | 不能对未验证路径执行写操作;晚到结果不覆盖新状态 | +| D-Bus adapter | 属性读取、Enable/Disable、Scan/Register、Supported/Current Modes/Bands、UnknownMethod/AccessDenied/NoReply | 错误分类、deadline、read-back、断线重连;无 SMS deadlock | +| NetworkManager | profile 缺失、APN 应用、autoconnect、连接/断开超时、漫游 home-only、旧 profile 恢复 | data off 永不 reconnect;APN 的 configured/applied/verified 不混淆;iptables 不变 | +| QMI | 唯一/primary/歧义 port、proxy 不可用、权限、CID/timeout、IMS probe 与 cell adapter 并发 | IMS 只读探针不被新控制污染;不因 NAS 成功宣称 lock capability | +| AT/vendor | 允许型号、非法参数、错误响应、设备消失、超时、未识别固件 | 无任意命令注入;uncertain 时停止并提示,不能继续破坏性操作 | +| 用户策略 | data off、roaming off、airplane on 与 watchdog、自动 reconnect、显式 register/APN 并发 | 优先级严格满足第 6 节;安全负向 intent 不被恢复逻辑覆盖 | +| 状态机 | searching、registered、roaming、connecting、disconnecting、owner change、cancel、late result | desired/observed/enforcement/operation 四维一致,超时不假成功 | +| 回滚 | band/radio/APN 应用失败、配置 commit 失败、live apply 成功但 read-back 失败 | 可恢复则恢复旧值;不可恢复返回 uncertain 和操作建议 | +| API/安全 | 未登录、跨源、缺 content type、缺确认、重复 operation、速率限制、501 unsupported | HTTP status/错误 code 正确;secret、号码、IMSI/ICCID、raw command 不泄露 | +| 配置 | 缺少新段的旧 TOML、默认关闭、0600、revision 冲突、重启恢复负向 intent | 旧配置兼容;默认不接管 CPE 数据;写入具备原子性 | +| 前端 | loading/pending、操作超时、服务端状态覆盖 optimistic state、unsupported cell lock、APN 密码保持 | 不误导、不丢状态、不展示 secret;长操作可轮询 | +| 硬件实验室 | 至少一款 LTE、带 NR 的 modem、不同 MM/plugin/firmware、无 NM、无 QMI、AT-only | 每个 capability 有矩阵与 known limitation;破坏性用例需人工确认和恢复步骤 | + +核心单元测试应使用 fake D-Bus/NetworkManager/adapter,不依赖真实 modem;真实设备测试只作为 opt-in integration/lab suite,不能在普通 `cargo test` 中切换 radio、band、APN 或数据。 + +## 11. 分阶段交付 + +### Phase 0:契约与能力发现 + +- 定义 `CellularSnapshot`、capability、policy、operation、error code 和敏感字段脱敏规则。 +- 把 modem path verification、D-Bus owner change、MM/QMI/NetworkManager adapter interface 设计好;不提供新的写操作。 +- 增加 fake adapter 和状态机/策略测试。 + +### Phase 1:只读诊断 + +- 实现 `/api/cellular/status`、信号/注册/运营商/数据观测和按需 cell snapshot。 +- 在现有 Modem 页面展示摘要;缺少 capability 时显示 unknown/unsupported。 +- 保证 SMS 收发与 IMS probe 测试不回归。 + +### Phase 2:用户策略开关 + +- 先交付 data enabled/disabled 和 roaming allowed/disallowed,明确 NM 数据面与 SMS/注册面分离。 +- 加入持久化负向 intent、enforcement/read-back、并发锁和 watchdog 禁止越权测试。 +- 飞行模式作为显式高影响操作单独验收,不启用自动恢复。 + +### Phase 3:运营商扫描与注册 + +- 长任务 operation API、扫描缓存、manual/auto register、超时/取消/owner change 处理。 +- 默认只做显式用户操作;不在后台自动注册或 radio cycle。 + +### Phase 4:APN + +- 配置校验、secret 保护、NM/bearer apply、连接验证和回滚。 +- 明确无 NetworkManager、无 bearer 或应用结果不确定时的 API 状态;不影响 SMS relay 默认行为。 + +### Phase 5:radio mode 与 band lock + +- 只在 capability matrix 覆盖的 modem 上开启;默认 advanced/feature flag off。 +- 先实现 read-only supported/current,再实现显式写入、fallback、超时、read-back 和解锁全部;不做自动优化。 + +### Phase 6:真实 cell lock 评估 + +- 只有在选定硬件上完成 QMI/vendor/AT adapter、持久性、重启验证、解锁和恢复实验后才决定是否交付。 +- 若没有跨硬件可维护的真实能力,产品结论应是“不支持 cell lock”,而不是增加一个仅内存态 API。 + +## 12. 完成标准 + +本功能只有在以下条件同时满足时才可宣称完成: + +- SmsRelayed 默认仍是短信 relay,不默认接管蜂窝数据或 CPE 网络; +- 用户明确关闭的数据/漫游/飞行模式意图在服务重启、modem 重枚举和 watchdog 运行时仍具有最高优先级; +- 所有写操作都有 capability gate、认证、确认、operation 状态、超时和 read-back;失败不会伪装成 success; +- APN、radio、band 的失败路径有可验证回滚或明确 uncertain; +- cell lock 没有真实硬件实现时明确返回 unsupported/in-memory-only,且 UI 不误导; +- 没有修改 host firewall/路由、没有暴露任意命令、没有日志或 API secret 泄露; +- 普通单元/集成测试覆盖上述状态、并发、安全和 fallback 矩阵,真实设备破坏性操作仅在受控实验室执行。 + +## 13. 实现前必须冻结的决策 + +下列事项在参考实现中没有可靠答案,不能由实现者暗自选择: + +1. 首批支持的 modem/plugin/firmware、NetworkManager 版本和多 modem 选择规则;不在矩阵内的能力是 `unsupported` 还是 feature flag off。 +2. `airplane` 在本产品中精确定义为 modem `Enable(false)`、低功耗 radio state,还是平台级飞行模式;它与 SMS relay 可用性的冲突提示。 +3. data/APN 的唯一所有者:只管理指定 NM connection、只管理 MM bearer,或两者协调;现有用户 profile 的所有权和回滚边界。 +4. 操作 timeout、保留期、幂等 TTL、scan cache TTL、watchdog 阈值和 recovery budget 的生产默认值。 +5. cancel 的底层能力:哪些 adapter 可中止,哪些只能停止后续步骤并等待 read-back;`uncertain` 的人工恢复入口。 +6. radio/band/cell lock 的产品范围。特别是 cell lock 在没有选定真实 adapter 前必须保持 unsupported。 +7. APN secret 使用当前 0600 配置、独立 secret store 还是外部凭据提供者,以及备份/恢复是否包含它。 +8. 向已有兼容客户端保留第 2.1.1 节 legacy 路由的期限;目标客户端只使用第 9.1 节 operation API。 + +## 14. SimAdmin 参考来源索引 + +以下行号以已审阅基线 `/tmp/SimAdmin` 的 `main@eb6f497ad332e59f1f3899acc3b2b9427661fe5c` 为准;正文已经给出完整契约,索引仅用于复核。 + +| 主题 | 参考位置 | +| --- | --- | +| 真实受保护路由与 HTTP method(scan 为 GET) | `backend/src/main.rs:510-688`,`device_routes` | +| handler 请求/响应及业务错误 envelope | `backend/src/handlers.rs:1525-1818`、`:2309-2577`,`start_cell_monitor_handler`、radio/band/operator/APN/data/roaming/airplane handlers | +| 全局串行锁 | `backend/src/serial.rs:1-29`,`SERIAL_LOCK`/`with_serial` | +| radio、band、cell monitor、scan/register、APN、airplane 底层行为 | `backend/src/modem_manager.rs:3057-3524`、`:4096-4420`、`:4833-5017` | +| 数据连接与 NetworkManager/profile 行为 | `backend/src/modem_manager.rs:3659-4094`,`set_data_connection_with_apn`/`set_data_connection_inner` | +| watchdog 阈值与恢复顺序 | `backend/src/modem_manager.rs:6246-6590`,`data_connection_watchdog` | +| 内存 data-off/airplane intent 与启动任务 | `backend/src/main.rs:328-470`、`backend/src/state.rs:42-91` | +| cell lock 仅内存 store | `backend/src/cell_lock_store.rs:1-69`,`CellLockStore` | +| 配置字段和默认值 | `backend/src/config.rs:1811-1867`、`:1886-1935`、`:2182-2268`,`ApnConfig`、`AppConfig` 与 network/APN getters/setters | +| 前端请求 method 与类型 | `frontend/src/api/current.ts:536-718`、`frontend/src/api/types.ts` 中 network/cellular request/response types | +| Network 页 scan/register、乐观 radio、band localStorage、cell lock、APN secret 回填、monitor 生命周期 | `frontend/src/pages/Network.tsx:132-149`、`:347-429`、`:499-692`、`:768-821` | diff --git a/frontend/src/components/messages/message-console.tsx b/frontend/src/components/messages/message-console.tsx index 743dd5d..9312551 100644 --- a/frontend/src/components/messages/message-console.tsx +++ b/frontend/src/components/messages/message-console.tsx @@ -206,6 +206,8 @@ export function MessageConsole({ ? `${initialPhone}:${targetMessageId}` : null; const markingReadPhonesRef = useRef>(new Set()); + const markingReadPromisesRef = useRef>>(new Map()); + const manualUnreadMessageIdsRef = useRef>>(new Map()); const hasLoadedConversationsRef = useRef(false); const loadedMessageCountRef = useRef(0); const pendingSendRef = useRef<{ @@ -562,20 +564,33 @@ export function MessageConsole({ useEffect(() => { if (!selectedPhone || isComposingNew) return; if (markingReadPhonesRef.current.has(selectedPhone)) return; - const hasUnread = messages.some( + const unreadMessages = messages.filter( (message) => message.phone_number === selectedPhone && message.direction === "inbound" && message.read_at === null, ); - if (!hasUnread) return; + if (unreadMessages.length === 0) return; const phone = selectedPhone; + const manualUnreadIds = manualUnreadMessageIdsRef.current.get(phone); + const autoReadMessages = manualUnreadIds + ? unreadMessages.filter((message) => !manualUnreadIds.has(message.id)) + : unreadMessages; + if (autoReadMessages.length === 0) return; markingReadPhonesRef.current.add(phone); - apiFetch(`/api/conversations/${encodeURIComponent(phone)}/read`, { - method: "POST", - }) + const markRead = manualUnreadIds + ? Promise.all( + autoReadMessages.map((message) => + apiFetch(`/api/messages/${message.id}/read`, { method: "POST" }), + ), + ) + : apiFetch(`/api/conversations/${encodeURIComponent(phone)}/read`, { + method: "POST", + }); + + const operation = markRead .then( () => reloadActiveViews().catch((err) => { @@ -589,7 +604,11 @@ export function MessageConsole({ ) .finally(() => { markingReadPhonesRef.current.delete(phone); + if (markingReadPromisesRef.current.get(phone) === operation) { + markingReadPromisesRef.current.delete(phone); + } }); + markingReadPromisesRef.current.set(phone, operation); }, [isComposingNew, messages, reloadActiveViews, selectedPhone]); const filteredConversations = useMemo(() => { @@ -628,6 +647,7 @@ export function MessageConsole({ function selectConversation(phone: string) { if (!isComposingNew && phone === selectedPhone) return; + manualUnreadMessageIdsRef.current.delete(phone); resetMessageWindow(); setSelectedPhone(phone); setPhoneNumber(phone); @@ -637,6 +657,9 @@ export function MessageConsole({ } function startNewMessage() { + if (selectedPhone) { + manualUnreadMessageIdsRef.current.delete(selectedPhone); + } setSelectedPhone(null); setPhoneNumber(""); resetMessageWindow(); @@ -646,6 +669,9 @@ export function MessageConsole({ } function closeMobileThread() { + if (selectedPhone) { + manualUnreadMessageIdsRef.current.delete(selectedPhone); + } resetMessageWindow(); setIsComposingNew(false); setSelectedPhone(null); @@ -725,6 +751,7 @@ export function MessageConsole({ console.error(err); return; } + manualUnreadMessageIdsRef.current.delete(selectedPhone); try { await reloadActiveViews(); } catch (err) { @@ -735,8 +762,23 @@ export function MessageConsole({ async function handleMarkSelected(read: boolean) { const ids = Array.from(selectedIds); + const phone = selectedPhone; if (ids.length === 0) return; setOperationError(null); + if (phone) { + const manualUnreadIds = + manualUnreadMessageIdsRef.current.get(phone) ?? new Set(); + for (const id of ids) { + if (read) manualUnreadIds.delete(id); + else manualUnreadIds.add(id); + } + if (manualUnreadIds.size > 0) { + manualUnreadMessageIdsRef.current.set(phone, manualUnreadIds); + } else { + manualUnreadMessageIdsRef.current.delete(phone); + } + await markingReadPromisesRef.current.get(phone); + } try { await Promise.all( ids.map((id) => @@ -852,6 +894,7 @@ export function MessageConsole({ }); setDeleteConversationTarget(null); if (selectedPhone === phone) { + manualUnreadMessageIdsRef.current.delete(phone); resetMessageWindow(); setSelectedPhone(null); setIsComposingNew(false); diff --git a/frontend/src/message-console.test.tsx b/frontend/src/message-console.test.tsx index 0722ba7..541c91a 100644 --- a/frontend/src/message-console.test.tsx +++ b/frontend/src/message-console.test.tsx @@ -1300,6 +1300,205 @@ describe("MessageConsole bulk read", () => { resolveRead?.(); await waitFor(() => expect(markedRead).toBe(true)); }); + + test("preserves a manually unread message across refreshes while reading new arrivals", async () => { + const readAt = "2026-07-11T00:01:00Z"; + let serverMessages = [ + { ...unreadMessage, read_at: readAt as string | null }, + ]; + const mutationRequests: string[] = []; + let resolveUnreadResponse: (() => void) | undefined; + mocks.apiFetch.mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/conversations") { + return Promise.resolve([ + { + phone_number: unreadMessage.phone_number, + last_message: serverMessages[0], + unread_count: serverMessages.filter( + (message) => message.read_at === null, + ).length, + total_count: serverMessages.length, + }, + ]); + } + if (input.startsWith("/api/messages?") && init?.method !== "POST") { + return Promise.resolve( + serverMessages.map((message) => ({ ...message })), + ); + } + if (init?.method === "POST") { + mutationRequests.push(input); + if (input === `/api/messages/${unreadMessage.id}/unread`) { + serverMessages = serverMessages.map((message) => + message.id === unreadMessage.id + ? { ...message, read_at: null } + : message, + ); + mocks.handlers["message.read_state_changed"]?.(); + return new Promise((resolve) => { + resolveUnreadResponse = () => resolve({}); + }); + } + const readMatch = input.match(/^\/api\/messages\/(\d+)\/read$/); + if (readMatch) { + const id = Number(readMatch[1]); + serverMessages = serverMessages.map((message) => + message.id === id ? { ...message, read_at: readAt } : message, + ); + } + if (input.includes("/api/conversations/")) { + serverMessages = serverMessages.map((message) => ({ + ...message, + read_at: readAt, + })); + } + return Promise.resolve({}); + } + return Promise.resolve({}); + }); + + render(); + fireEvent.click( + await screen.findByRole("button", { name: /\+15550000001/ }), + ); + await screen.findByText("test body"); + fireEvent.click( + screen.getByRole("button", { name: "Conversation actions" }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Select messages" }), + ); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click( + screen.getByRole("button", { name: "Conversation actions" }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Mark unread (1)" }), + ); + + await waitFor(() => + expect(mutationRequests).toContain( + `/api/messages/${unreadMessage.id}/unread`, + ), + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect( + serverMessages.find((message) => message.id === 1)?.read_at, + ).toBeNull(); + expect( + mutationRequests.some((request) => + request.includes("/api/conversations/"), + ), + ).toBe(false); + resolveUnreadResponse?.(); + await waitFor(() => expect(screen.queryByRole("checkbox")).toBeNull()); + mocks.handlers["message.read_state_changed"]?.(); + const newMessage = { + ...unreadMessage, + id: 2, + body: "new arrival", + read_at: null, + }; + serverMessages = [newMessage, ...serverMessages]; + mocks.handlers["message.created"]?.({ payload: newMessage }); + + await waitFor(() => + expect(mutationRequests).toContain(`/api/messages/${newMessage.id}/read`), + ); + expect( + serverMessages.find((message) => message.id === 1)?.read_at, + ).toBeNull(); + expect(serverMessages.find((message) => message.id === 2)?.read_at).toBe( + readAt, + ); + expect( + mutationRequests.some((request) => + request.includes("/api/conversations/"), + ), + ).toBe(false); + }); + + test("applies manual unread after an in-flight automatic read", async () => { + const readAt = "2026-07-11T00:01:00Z"; + let serverMessage = { + ...unreadMessage, + read_at: null as string | null, + }; + const mutationRequests: string[] = []; + let resolveAutomaticRead: (() => void) | undefined; + mocks.apiFetch.mockImplementation((input: string, init?: RequestInit) => { + if (input === "/api/conversations") { + return Promise.resolve([ + { + phone_number: unreadMessage.phone_number, + last_message: serverMessage, + unread_count: serverMessage.read_at === null ? 1 : 0, + total_count: 1, + }, + ]); + } + if (input.startsWith("/api/messages?") && init?.method !== "POST") { + return Promise.resolve([{ ...serverMessage }]); + } + if (init?.method === "POST") { + mutationRequests.push(input); + if (input.includes("/api/conversations/")) { + return new Promise((resolve) => { + resolveAutomaticRead = () => { + serverMessage = { ...serverMessage, read_at: readAt }; + resolve({}); + }; + }); + } + if (input === `/api/messages/${unreadMessage.id}/unread`) { + serverMessage = { ...serverMessage, read_at: null }; + } + return Promise.resolve({}); + } + return Promise.resolve({}); + }); + + render(); + fireEvent.click( + await screen.findByRole("button", { name: /\+15550000001/ }), + ); + await waitFor(() => + expect( + mutationRequests.some((request) => + request.includes("/api/conversations/"), + ), + ).toBe(true), + ); + fireEvent.click( + screen.getByRole("button", { name: "Conversation actions" }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Select messages" }), + ); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click( + screen.getByRole("button", { name: "Conversation actions" }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Mark unread (1)" }), + ); + + await Promise.resolve(); + expect(mutationRequests).not.toContain( + `/api/messages/${unreadMessage.id}/unread`, + ); + resolveAutomaticRead?.(); + await waitFor(() => + expect(mutationRequests).toContain( + `/api/messages/${unreadMessage.id}/unread`, + ), + ); + expect(serverMessage.read_at).toBeNull(); + expect(mutationRequests).toEqual([ + expect.stringContaining("/api/conversations/"), + `/api/messages/${unreadMessage.id}/unread`, + ]); + }); }); describe("MessageConsole timeline pagination", () => { diff --git a/src/api/auth.rs b/src/api/auth.rs index 5a1139f..24e5b71 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -28,6 +28,9 @@ const MAX_LOGIN_FAILURES: u32 = 5; const LOGIN_FAILURE_WINDOW: StdDuration = StdDuration::from_secs(5 * 60); const CREDENTIAL_SECRET_BYTES: usize = 32; +#[derive(Clone)] +pub(super) struct AuthenticatedSession(pub String); + #[derive(Clone, Copy)] struct LoginFailures { window_started: Instant, diff --git a/src/api/messages.rs b/src/api/messages.rs index e9f75d4..8f98f0c 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -1,11 +1,12 @@ use axum::body::Body; -use axum::extract::{Path, Query, State}; +use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::sse::{Event, Sse}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; use axum::{Json, Router}; use serde::Deserialize; +use std::time::Duration; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; @@ -22,6 +23,11 @@ use crate::storage::NewMessage; use super::{ApiError, ApiResult, ApiState}; +#[cfg(not(test))] +const EVENT_SESSION_REVALIDATE_INTERVAL: Duration = Duration::from_secs(15); +#[cfg(test)] +const EVENT_SESSION_REVALIDATE_INTERVAL: Duration = Duration::from_millis(10); + #[derive(Deserialize)] pub struct SendRequest { phone_number: String, @@ -401,17 +407,58 @@ async fn export_messages( Ok((headers, body).into_response()) } -async fn events(State(state): State) -> impl IntoResponse { +async fn events( + State(state): State, + Extension(session): Extension, +) -> impl IntoResponse { let rx = state.events.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|result| match result { - Ok(event) => match serde_json::to_string(&event) { - Ok(data) => Some(Ok::<_, std::convert::Infallible>( - Event::default().event(event.name()).data(data), - )), - Err(_) => None, + let sessions = state.sessions.clone(); + let token = session.0; + let mut revalidate = tokio::time::interval(EVENT_SESSION_REVALIDATE_INTERVAL); + revalidate.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let stream = futures_util::stream::unfold( + (BroadcastStream::new(rx), revalidate, sessions, token), + |(mut events, mut revalidate, sessions, token)| async move { + loop { + let event = tokio::select! { + _ = revalidate.tick() => { + match sessions.is_valid(&token).await { + Ok(true) => continue, + Ok(false) => return None, + Err(error) => { + log::error!("event stream session validation failed: {error:#}"); + return None; + } + } + } + result = events.next() => { + match result { + Some(Ok(event)) => event, + Some(Err(_)) => continue, + None => return None, + } + } + }; + + match sessions.is_valid(&token).await { + Ok(true) => {} + Ok(false) => return None, + Err(error) => { + log::error!("event stream session validation failed: {error:#}"); + return None; + } + } + let Ok(data) = serde_json::to_string(&event) else { + continue; + }; + let event = Event::default().event(event.name()).data(data); + return Some(( + Ok::<_, std::convert::Infallible>(event), + (events, revalidate, sessions, token), + )); + } }, - Err(_) => None, - }); + ); Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) } @@ -420,9 +467,114 @@ mod tests { use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; + use std::time::Duration; use super::*; + fn sse_test_state() -> super::super::ApiState { + let store = crate::storage::MessageStore::open_in_memory().unwrap(); + super::super::ApiState { + config: std::sync::Arc::new(crate::config::AppConfig::default()), + config_path: std::path::PathBuf::from("/tmp/not-used.toml"), + config_save_lock: Arc::new(tokio::sync::Mutex::new(())), + store: store.into(), + events: crate::events::EventBus::new(), + delivery_wakeup: crate::delivery::DeliveryWakeup::new(), + started_at: std::time::Instant::now(), + sessions: super::super::auth::SessionStore::default(), + modem: crate::modem::ModemService::new(), + sms_sender: super::super::test_sms_sender(), + service_control: super::super::service::ServiceControl::default(), + } + } + + #[tokio::test] + async fn revoked_session_closes_event_stream_before_sensitive_message_is_sent() { + let state = sse_test_state(); + let token = state.sessions.create_session().await.unwrap(); + let response = events( + State(state.clone()), + Extension(super::super::auth::AuthenticatedSession(token.clone())), + ) + .await + .into_response(); + let mut body = response.into_body().into_data_stream(); + state.sessions.remove(&token).await.unwrap(); + state.events.send(AppEvent::MessageCreated(Message { + id: 1, + direction: MessageDirection::Inbound, + phone_number: "+15551234567".to_string(), + body: "private body".to_string(), + timestamp: "2026-09-08T00:00:00Z".to_string(), + status: MessageStatus::Received, + source: MessageSource::Modem, + modem_sms_path: Some("/org/freedesktop/ModemManager1/SMS/1".to_string()), + read_at: None, + error: None, + created_at: "2026-09-08T00:00:00Z".to_string(), + updated_at: "2026-09-08T00:00:00Z".to_string(), + favorite_at: None, + delete_blocked: false, + })); + + let chunk = tokio::time::timeout(Duration::from_secs(1), body.next()) + .await + .expect("revoked event stream closes promptly"); + assert!(chunk.is_none(), "revoked stream leaked an SSE event"); + } + + #[tokio::test] + async fn expired_session_closes_idle_event_stream() { + let state = sse_test_state(); + let token = state.sessions.create_session().await.unwrap(); + let response = events( + State(state.clone()), + Extension(super::super::auth::AuthenticatedSession(token.clone())), + ) + .await + .into_response(); + let mut body = response.into_body().into_data_stream(); + state.sessions.expire_for_test(&token).await.unwrap(); + + let chunk = tokio::time::timeout(Duration::from_secs(1), body.next()) + .await + .expect("expired idle event stream closes promptly"); + assert!(chunk.is_none()); + } + + #[tokio::test] + async fn authenticated_router_event_stream_receives_events() { + use tower::ServiceExt; + + let state = sse_test_state(); + let token = state.sessions.create_session().await.unwrap(); + let events = state.events.clone(); + let response = super::super::router(state) + .oneshot( + axum::http::Request::builder() + .uri("/api/events") + .header( + header::COOKIE, + format!("{}={token}", super::super::auth::SESSION_COOKIE), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body().into_data_stream(); + events.send(AppEvent::ConfigSaved); + + let chunk = tokio::time::timeout(Duration::from_secs(1), body.next()) + .await + .expect("authenticated event stream receives an event") + .expect("authenticated event stream remains open") + .unwrap(); + let chunk = String::from_utf8_lossy(&chunk); + assert!(chunk.contains("event: config.saved"), "{chunk}"); + } + #[derive(Clone, Default)] struct RecordingSmsSender { calls: Arc>>, diff --git a/src/api/mod.rs b/src/api/mod.rs index 0e2cf8f..f8c5fc5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -175,7 +175,7 @@ pub fn router(state: ApiState) -> Router { .merge(modem::routes()) .merge(forwarding::routes()) .layer(middleware::from_fn( - move |req: axum::extract::Request, next: middleware::Next| { + move |mut req: axum::extract::Request, next: middleware::Next| { let sessions = sessions.clone(); async move { let token = auth::session_token(req.headers()); @@ -189,6 +189,8 @@ pub fn router(state: ApiState) -> Router { return auth::session_storage_error(error).into_response(); } } + req.extensions_mut() + .insert(auth::AuthenticatedSession(token)); next.run(req).await } }, diff --git a/src/inbound.rs b/src/inbound.rs index 73f087d..eef6c30 100644 --- a/src/inbound.rs +++ b/src/inbound.rs @@ -244,7 +244,7 @@ impl InboundWorker { continue; } joined = children.join_next(), if !children.is_empty() => { - report_child_result(joined); + report_child_result(joined)?; continue; } sms = subscription.next_sms() => sms?, @@ -349,16 +349,18 @@ impl InboundWorker { } } -fn report_child_result(joined: Option, tokio::task::JoinError>>) { +fn report_child_result(joined: Option, tokio::task::JoinError>>) -> Result<()> { match joined { - Some(Ok(Ok(()))) | None => {} + Some(Ok(Ok(()))) | None => Ok(()), Some(Ok(Err(error))) => { error!("incoming SMS processing task failed: {error:#}"); report_child_failure(); + Err(error).context("incoming SMS processing failed; rebuilding subscription") } Some(Err(error)) => { error!("incoming SMS processing task failed to join: {error}"); report_child_failure(); + Err(error).context("incoming SMS processing task failed; rebuilding subscription") } } } @@ -374,7 +376,7 @@ async fn wait_for_reconnect(delay: Duration, children: &mut JoinSet>) tokio::select! { _ = &mut sleep => return, joined = children.join_next(), if !children.is_empty() => { - report_child_result(joined); + let _ = report_child_result(joined); } } } @@ -883,15 +885,19 @@ mod tests { } struct ScriptedSource { - subscription: Mutex>>, + subscriptions: Mutex>>, subscribed: Arc, subscribed_paths: Arc>>, } impl ScriptedSource { fn new(subscription: impl InboundSubscriptionAdapter + 'static) -> Self { + Self::with_subscriptions(vec![Box::new(subscription)]) + } + + fn with_subscriptions(subscriptions: Vec>) -> Self { Self { - subscription: Mutex::new(Some(Box::new(subscription))), + subscriptions: Mutex::new(subscriptions.into()), subscribed: Arc::new(Notify::new()), subscribed_paths: Arc::new(Mutex::new(Vec::new())), } @@ -907,7 +913,7 @@ mod tests { .lock() .unwrap() .push(modem_path.to_string()); - let subscription = self.subscription.lock().unwrap().take(); + let subscription = self.subscriptions.lock().unwrap().pop_front(); self.subscribed.notify_one(); Box::pin(async move { subscription.ok_or_else(|| anyhow::anyhow!("scripted subscription exhausted")) @@ -1259,6 +1265,63 @@ mod tests { let _ = run.await; } + #[tokio::test] + async fn terminal_property_failure_rebuilds_subscription_and_recovers_from_snapshot() { + let terminal_actions = (0..MAX_PROPERTY_READ_ATTEMPTS) + .map(|attempt| { + PropertyAction::Return(Err(anyhow::anyhow!("temporary GetAll failure {attempt}"))) + }) + .collect(); + let source = Arc::new(ScriptedSource::with_subscriptions(vec![ + Box::new(SnapshotSubscription { + initial_messages: vec![Box::new(ScriptedSms::new(SMS_PATH, terminal_actions))], + live_messages: VecDeque::new(), + live_reads: Arc::new(AtomicUsize::new(0)), + }), + Box::new(SnapshotSubscription { + initial_messages: vec![Box::new(ScriptedSms::new( + SMS_PATH, + vec![PropertyAction::Return(Ok(properties( + "recovered after GetAll failure", + StorageType::Me as u32, + )))], + ))], + live_messages: VecDeque::new(), + live_reads: Arc::new(AtomicUsize::new(0)), + }), + ])); + let subscribed_paths = source.subscribed_paths.clone(); + let store = Store::open_in_memory().unwrap(); + store + .set_modem_fingerprint("property-recovery-fingerprint".to_string()) + .await + .unwrap(); + let query = messaging(store.clone()); + let worker = worker(store, source, settings(Vec::new(), Vec::new())); + + let run = tokio::spawn(async move { + let mut children = JoinSet::new(); + loop { + worker + .run_subscription(MODEM_PATH, &mut children) + .await + .unwrap_err(); + tokio::time::sleep(Duration::from_millis(1)).await; + } + }); + + let messages = wait_for_messages(&query).await; + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].body, "recovered after GetAll failure"); + assert_eq!( + subscribed_paths.lock().unwrap().as_slice(), + [MODEM_PATH, MODEM_PATH] + ); + run.abort(); + let _ = run.await; + } + #[tokio::test] async fn startup_snapshot_overlapping_a_live_signal_is_persisted_once() { let live_reads = Arc::new(AtomicUsize::new(0));