Compare commits

...
46 Commits
Author SHA1 Message Date
chick ef280b53c9 fix(web): 修复无正文请求被 JSON 解析器拒绝导致"删除节点"失效
控制台所有请求共用的 request() 无条件带上 Content-Type: application/json,
包括无正文的 DELETE /api/v1/instances/:id 与 POST test-connection。Fastify
的 JSON 解析器见到"声明了 JSON 却是空正文"会直接回 400 HTTP_400,请求在
进入路由之前就被拒——所以"删除节点"看起来点了没反应,日志里三次真实尝试
全部是 400,而路由本身完好。

复现(对 8788 实测):
- DELETE + Content-Type: json + 空正文 → 400 HTTP_400(旧行为,坏)
- DELETE 不带 Content-Type          → 401 CONSOLE_AUTH_REQUIRED(到达路由)
- DELETE + Content-Type: json + {}  → 401(能解析,故非路由问题)
- GET    + Content-Type: json + 空   → 401(Fastify 对 GET 不解析正文)

修复:Content-Type 只在确实有正文时才发送。删除的意图本来就在头部
(If-Match / X-Preparation-Id / X-Confirmation-Token)里,无正文属正常形状。

同源的 POST test-connection 也一并修好。iOS 客户端本就用 route.body != nil
判断,与修复后的契约一致——Web 侧是唯一的例外。

回归测试:
- web:断言 DELETE 只发 Accept、不发 Content-Type
- api:断言真实形状到达路由并完成删除(202 且节点转 404),
  同时断言"声称 JSON 却空正文"仍须显式 400、节点必须留存

验证:147 文件 / 1371 测试全绿;lint 干净;typecheck 干净;dist 已重建且
8788 正在服务的 bundle 与磁盘一致(md5 相同)。
2026-09-29 18:49:17 +08:00
chick 3e0bcb1462 docs(ios): 记录端到端测试抓出的三个接缝缺陷 2026-09-16 20:19:30 +08:00
chick d81605ebff docs(ios): 记录 P6 自查结论与修复 2026-09-16 19:20:59 +08:00
chick 3f6090081f feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct,
  写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>)
- RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器,
  因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写)
- NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter;
  DirectTransport 是薄适配,直通路径行为不变
- RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃,
  四类以上失败各有独立 UpstreamError 分支与用户文案
- DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet
- 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成
- scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫
- 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00
chick a6867bc9cb docs(ios): §7 配对实现修订——--pair 窗口、agent 生成 bearer、握手后全量上报 2026-09-16 11:43:52 +08:00
chick 99752bd355 docs(ios): 中继实现修订——长轮询替 WSS、响应方向临时×临时、agent 本机登录 2026-09-16 09:15:26 +08:00
chick 95c653b3d3 docs(ios): 双通道(内网直通 + 公网中继)设计稿
- 拓扑:每节点一个 agent,只出站连中继,出口硬编码回环,把 SSRF 爆炸半径从整个内网收缩到单节点
- 信封:Ed25519 签名覆盖 nodeID/reqID/ek 防改投与错配,临时-静态 DH 加密,并写准前向安全的实际边界
- 授权:中继不得授权任何事,危险写确认改由手机产生、agent 校验,说明服务端签发令牌为何不可搬用
- iOS:引入 InstanceEndpoint 与 NodeTransport,origin 同时承担目标/去重键/Cookie 绑定三职的迁移路径
- 复用既有 ProblemDetails、FRESHNESS_STATUSES 与 server/proxy 策略,并要求策略抽为共享 JSON 防漂移
2026-09-15 14:55:45 +08:00
chick 802ddd1102 release(ios): 0.2.2 构建 10 修消息卡空洞、标签胶囊假宽与表单标题层级
- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高
- 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现
- 设置页应用信息补「构建」一行,版本号全程不带括号
- 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带
- 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾
- 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
2026-09-15 13:03:23 +08:00
chick c3d17b3daf release(ios): 0.2.2 构建 9 修复消息发送遮挡并中性化石墨灰阶
- 消息中心发送入口从被标签栏盖住的底部操作条移到导航栏右侧,发信恢复可用
- 停用态主按钮保留主色淡底与同色描边,与次级按钮拉开层级
- 消息行删除改安静线性图标,去掉一屏红块
- 缺数据读数统一为短横,卡片延迟补空格单位
- 中性灰阶改为严格 R=G=B,浅色主题不再读出冷调;图标石墨底同步去蓝
- 详情页延迟量表改用与卡片同一算法,身份信息值改为右对齐
- 标签加号按钮与输入框同高同圆角
2026-09-15 08:19:18 +08:00
chick 9ad0c46088 release(ios): 0.2.2 构建 8 中性化批量标签禁用态并去蓝化图标底色
- FleetTagBatchSheet:无勾选时标签胶囊不再走 Button 禁用态(系统会压成灰块,像加载
  失败),改为中性标签原样渲染;覆盖度文案留空,图标改 tag;新建标签输入与按钮置灰
  并补一句「勾选节点后即可新建标签并批量贴上」。
- 应用图标:底色与插槽从偏青绿的石墨(R>G=B,亮壁纸上读作蓝)改为严格等值中性灰
  20/19/10,金色跨度拉大(#FADD98 -> #C78D2F)去掉芥末黄塑料感;AppIcon.svg 同步。
- 版本号:marketing 0.2.2 不带括号,构建号 7 -> 8;文档里 0.2.x (N) 一律改写为
  「0.2.x 构建 N」,README 与现行说明同步。
2026-09-15 05:07:03 +08:00
chick 36ce791215 polish(ios): 中性化标签与方向胶囊并收紧详情头部间距
- AITag 拆为两类:不传状态色时是中性归类胶囊(tile 底、发丝描边、墨字,可带 10pt 图标),
  传状态色时才用 14% 色雾与 30% 描边。颜色只服务状态语义。
- 详情页标签改用 AITag(tag, systemImage: "tag"),与节点卡片同一枚形状;头部 VStack 间距
  14 -> 12,读数格 minHeight 62 -> 56,加载完成后不再明显小于下方卡片。
- 消息中心「收件 / 发件」由绿/墨配色改回中性胶囊配 arrow.down.left / arrow.up.right,
  绿色重新只表示健康状态。
- 批量标签表在无勾选时给出「先在节点列表勾选要归类的节点」,不再留空白行。
- 文档:0.2.2 增量记录与校验值更新为 0e4ffb74…;版本号后不再带括号,
  0.2.0/0.2.1/0.2.2 与 README、现行说明统一改写为「0.2.x 构建 N」。
2026-09-15 03:56:31 +08:00
chick eb880e0d14 release(ios): 0.2.2 移除分组改用标签归类并完成石墨配色重构
- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理
  instance-groups.json 与 fleet.groupMode
- 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签
- 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义;
  浅色改中性冷灰,深色改低明度石墨三层
- 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊)
- 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿
- 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位
- 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹;
  补齐设备名称/存储总量/可用存储/使用率四个字段名
- 版本号显示去掉括号,设置页只显示 0.2.2
- 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
2026-09-15 02:23:24 +08:00
chick ec3ffb9db3 docs(ios): 同步 0.2.x 实况文档与规划记录 2026-09-14 18:22:23 +08:00
chick 3607eddc9f release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
2026-09-14 16:30:39 +08:00
chick 27687868ef fix(ios): unify toolbar controls and release build 3 2026-09-13 01:15:41 +08:00
chick 2ff23d1fb0 fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid
- Add link-chain state to fleet cards and parse hardware/control/SIM/cellular
- Replace the wrapped icon with a full-bleed app icon
- Bump the IPA build number
2026-09-13 00:46:26 +08:00
chick 409345cba3 docs(ios): document the 0.2.0 release 2026-09-12 17:18:54 +08:00
chick e01a105645 feat(ios): simplify navigation and ship 0.2.0
Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
2026-09-12 17:10:02 +08:00
chick 20632be86a feat(ios): unify action hierarchy and surfaces
Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
2026-09-12 16:15:57 +08:00
chick 0f2866a853 feat(ios): refine native interface and app icon
Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
2026-09-12 14:09:02 +08:00
chick 31b2f33d15 feat(ios): refine adaptive theme and unified controls 2026-09-10 03:19:03 +08:00
chick c841748971 feat(ios): unify list cards and loading states 2026-09-10 01:35:12 +08:00
chick 995206ac23 feat(ios): refine native ui controls 2026-09-09 11:14:39 +08:00
chick c568e496b2 feat(ios): add native settings and storage controls 2026-09-09 02:06:34 +08:00
chick 9c3186252b feat(ios): add cross-instance log center 2026-09-09 01:53:40 +08:00
chick cf187e42fa feat(ios): add cross-instance notification center 2026-09-09 01:13:45 +08:00
chick 84ec3678c0 feat(ios): add device details and reliable ipa build 2026-09-09 00:46:28 +08:00
chick 7778693807 feat(ios): add cross-instance message center 2026-09-09 00:18:56 +08:00
chick 54eb8dcb86 feat(ios): add native app foundation and unsigned ipa build 2026-09-09 00:05:51 +08:00
chick fda3e11f1e docs: add iOS native IPA foundation plan 2026-09-08 21:22:38 +08:00
chick 1caf6985ff docs: add iOS native IPA design 2026-09-08 20:57:54 +08:00
chick 001659ed91 style(web): unify panel, group and state controls
Standardize panel actions, group filters, segmented modes, workbench tabs, message filters, identity controls and privacy toggles on the shared button scale. Add cascade-aware tests so stale compact sizes and low-contrast states cannot return.
2026-09-08 19:58:10 +08:00
chick bfe5025943 style(web): close control-system gaps found in the deep sweep 2026-09-08 08:02:01 +08:00
chick c0adbcbc8a style(web): unify accessible control system 2026-09-08 01:00:22 +08:00
chick 61a5aee48d fix(events): close review gaps found in the follow-up sweep
- deletion flow emitted job/instance envelopes before the transaction that
  closed the job rows; subscribers refetching on the notification saw
  status 'running' with no further event. Emits now happen after commit,
  including the failure path
- interrupted-deletion sweep now emits job envelopes with the job's real
  request_id (it previously stayed silent while the secure-execution sweep
  already emitted)
- verified during the sweep: backup restore validation re-derives the
  expected schema via migrateDatabase, so migration 18 is automatically
  covered; no tables reference audit_events or operation_preparations, so
  the retention deletes cannot trip FK constraints
2026-09-07 05:57:35 +08:00
chick 70e598208a perf,feat(events),ops: third optimization pass
Overview cold start:
- InstanceResourceService persists every live fetch into status_snapshots
  (category 'resources') and serves a snapshot younger than 2 minutes
  before calling upstream, so the first fleet overview after a restart
  costs zero device requests; instance deletion cleans up via FK cascade

Bounded retention for the four fastest-growing tables (15-minute sweep):
- connection_logs: one row per probe per beat had no automatic cleanup
- audit_events: new pruneBefore (90d)
- operation_preparations: every prepare/retry attempt inserted a row,
  terminal rows now expire after 7 days
- notification_queue: terminal rows pruned after 30 days

SSE events now cover instance lifecycle:
- create/update emit instance envelopes from the routes; the two-phase
  delete emits a job envelope on every terminal transition plus an
  instance envelope when the node actually disappears, so fleet and
  instance views invalidate in real time

Linux ops:
- 'install.sh unit' writes systemd user units for API and gateway with
  Restart=on-failure, 0600 secret injection, and
  MULTI_SIMADMIN_SYSTEMD_UNIT wired so console self-update restarts via
  systemctl -- closing the self-update loop on Linux
2026-09-07 02:43:11 +08:00
chick c1be714ba4 perf,feat(events),hardening: second review pass
Fleet overview N+1:
- InstanceResourceService gains a 30s TTL cache with single-flight
  coalescing; the overview no longer fires six live upstream requests per
  device on every render (plus the re-login storm), while the per-device
  detail route probes live via force:true

Event journal becomes live:
- job terminal transitions (manual executions and the interrupted sweep)
  now append to the journal, so /api/v1/events SSE feeds the frontend's
  invalidation controller that was built but never received events
- journal pruning moves off the append hot path (was an unindexable
  full-table json_extract scan per insert) onto the retention timer

Console auth hardening:
- scrypt upgraded from N=16384 to N=2^16 (OWASP interactive guidance);
  a new password_kdf column records the derivation per row and legacy
  hashes rehash transparently on the next successful login without
  invalidating sessions (migration 18)

Legacy stack:
- instance URL validation blocks IPv4-compatible IPv6 after WHATWG
  canonicalization (::a9fe:a9fe metadata, ::7f00:1 loopback slipped past)
- status polls cool down auto-login for 60s after a failed attempt so a
  stale saved password cannot hammer the device into an account lockout

Build hygiene:
- web bundle splits app (410kB) from vendor (212kB) so framework code
  stays cacheable across releases; stale root package-lock.json removed
  (pnpm is the only lockfile)
2026-09-07 02:20:41 +08:00
chick 29a0eee854 style: apply prettier to the repo's declared format surface 2026-09-07 01:59:24 +08:00
chick 2977f75129 fix: apply security and correctness review findings across both stacks
Legacy panel:
- upstream body reads now carry their own deadline and a 10 MB byte budget;
  a stalled modem can no longer hang /api/status fan-out forever nor OOM
  the proxy (request headers alone had the timeout, bodies had none)
- add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel
  (delete-instance and confirmed-write dialogs) is no longer clickjackable
- only send a JSON content-type when the API console request has a body, so
  payload-less dangerous writes stop failing with 400 and burning the
  one-use confirmation token
- register a form-urlencoded parser (the proxy branch was unreachable) and
  drop the multipart parser that buffered up to 60 MB before rejecting;
  bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body
- remove /api/sms/send from readable paths: GET bypassed the write
  confirmation for a send endpoint
- /api/instances/:id/login maps upstream failures to a stable 502 instead
  of leaking raw error text
- guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request);
  prune dead code (buildClients, cookie expando no-op)

Control plane:
- deleting a notification channel detaches it from rules instead of leaving
  dangling ids that made every referencing rule unreadable and silently
  dropped future notifications; rule reads tolerate unknown ids
- startup sweep resets notification_queue rows stranded in 'sending' by a
  crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on
  the retention timer
- /api/v1/metrics no longer emits operator-assigned node names on the
  session-free scrape; login limiter map is bounded and pruned; Secure
  cookie honors the gateway-declared x-forwarded-proto
- webhook delivery sets redirect: manual (signed payloads are not replayed)
- SMTP envelope sender is validated against CR/LF smuggling
- scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with
  a clear reason instead of burning every retry; contract narrowed to the
  pinned baseline
2026-09-07 01:57:47 +08:00
chick 236e78327c feat(console-update): systemd restart launcher for Linux supervision
Linux deployments under a systemd unit can now use the online update
panel: MULTI_SIMADMIN_SYSTEMD_UNIT (+ optional _SCOPE=user|system) hands
the restart to systemctl; createPlatformRestartLauncher picks launchd on
macOS and systemd on Linux, degrading to 'unsupported' otherwise.
2026-09-07 01:38:23 +08:00
chick 3c5971e167 feat(installer): support Linux hosts with XDG data root and ss port probe
- platform gate accepts Darwin and Linux; other platforms are refused
- default install root follows each platform's convention
  (~/Library/Application Support vs ~/.local/share)
- LAN IP discovery uses ip -4/hostname -I on Linux, ipconfig on macOS
- port occupancy checks fall back from lsof to ss
- API process gets MULTI_SIMADMIN_SECRET_BACKEND pinned per platform
- README documents the per-platform secret storage
2026-09-07 01:36:04 +08:00
chick e4c4bce75b feat(secrets): add a 0600 file secret backend so production runs on Linux
The production composition hardwired the macOS Keychain (/usr/bin/security),
which made Linux deployment impossible. Now:

- SecretStore gains a stable provider identity persisted in
  secret_references.provider; services stop hardcoding 'macos-keychain'
- shared reference codec (secret-reference.ts) understands both
  keychain:// and secret-file:// references
- FileSecretStore: single 0600 JSON map under the data root, atomic
  temp+rename writes, serialized in-process, same validation envelope
- production composition picks the backend via
  MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store
  elsewhere) and readiness probes the matching backend
2026-09-07 01:32:07 +08:00
chick c57dc81e42 fix: finish cross-platform test parity while keeping POSIX deployment semantics
- cutover readState: enforce 0600 mode bits only on POSIX (Windows ACLs
  govern access; chmod is a no-op there)
- backup activation: skip the read-only-handle fsync on win32; the staged
  rename-over-open-WAL tests keep running on the POSIX deployment targets
- test-fixtures: normalize fixture paths to POSIX separators before
  comparing with manifest entries
2026-09-07 01:18:44 +08:00
chick 96dd9e2ba6 chore: pin LF line endings via .gitattributes
Windows checkouts previously materialized CRLF, breaking every pinned
sha256 snapshot check in operation-registry and test-fixtures.
2026-09-07 01:10:19 +08:00
chick 4f42a6f835 fix: make cutover restart argv upgrade-safe and tests platform-honest
- cutover-orchestrator: restart via 'node --import tsx' (matching install.sh)
  instead of a hardcoded .pnpm/tsx@4.22.4 loader path that breaks on tsx bumps
- production-gateway: use process.execPath for the legacy-start fixture;
  POSIX-only 0600 mode assertion now win32-guarded
- cli-runtime + installer suites: skip where they require POSIX-only
  process-group SIGTERM, lsof, or /bin/sh
- fixture-safety: fall back to config.example.json when config.json is absent
  on a fresh clone
2026-09-07 01:09:49 +08:00
chick eed134a25c fix: honor underscore-unused convention and make durability fsyncs cross-platform
- eslint: respect the repo's existing _-prefix convention for unused vars
- phase-one-blockers: derive projectRoot via import.meta.dirname so Windows
  checkouts stop producing C:\C:\... paths
- backup/release-evidence/cutover-orchestrator: skip POSIX-only directory
  fsync on win32 and fsync read-only handles through a writable handle
2026-09-07 01:07:16 +08:00
173 changed files with 19578 additions and 4380 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+8 -1
View File
@@ -8,5 +8,12 @@ coverage/
apps/web/dist/
.hermes/
.local-run/
artifacts/
apps/ios/.build/
build/
data/
# 误在工作副本里跑构建时留下的缓存目录
apps/ios/apps/
# 图标由 Design/generate_app_icon.swift 重绘,产物不入库(正式资源在 Assets.xcassets)
apps/ios/Design/AppIcon.png
+41 -5
View File
@@ -2,6 +2,30 @@
一个用于聚合管理多个 [SimAdmin](https://github.com/3899/SimAdmin) 实例的轻量本地运维面板。
## iOS Native 应用
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**,构建号 10,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。
底部为节点、消息、设置三个入口:
- **节点**:运行概览、标签多选筛选、标签批量归类、搜索、批量选择、实例增删改,卡片直连实例详情。
- **实例详情**:设备、SIM、蜂窝网络、系统资源、CPU、连接状态六个模块,敏感值默认脱敏,显示后离开页面即隐藏。
- **消息**:跨实例短信读取、按实例与方向筛选、搜索、发送、删除。
- **设置**:主题外观(跟随系统 / 浅色 / 深色)、应用信息、本地数据与日志中心、隐私与数据、数据清理。
实例信息与密码保存在应用沙盒本地文件,会话 Cookie 只存在于进程内存,不使用 Keychain,不上传任何数据。应用只访问用户添加的节点地址,上游路径为固定白名单。
按产品决策移除的能力:全局访问密码、定时与后台调度、iOS 远程推送、自动备份与自动清理、控制台自更新,以及依赖常驻进程的应用内通知中心(日志中心因此收进设置,不再单独占一个底部入口)。
构建与自检:
```bash
./scripts/build-ios-ipa.sh # 产出 build/ipa/MultiSimAdmin.ipa 与 .sha256
cd apps/ios && swift test # 48 个用例
```
文档:现行说明 [docs/ios-native.md](docs/ios-native.md),发布记录 [0.2.2](docs/ios-native-0.2.2.md)、[0.2.1](docs/ios-native-0.2.1.md) 与 [0.2.0](docs/ios-native-0.2.0.md)。
## 功能
- 只需要在 `config.json` 配置多个 SimAdmin 地址。
@@ -14,7 +38,7 @@
- 融合 [SimAdminHub](https://github.com/3899/SimAdminHub) 的管理思路:跨节点短信、通知、自动化与跨域健康自然融入 Fleet 节点总览体系,无需部署或接入外部 Hub。
- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。
## 一键安装(macOS)
## 一键安装(macOS / Linux)
```bash
curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scripts/install.sh | sh
@@ -28,7 +52,7 @@ curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scri
安装完成后按终端输出访问 `http://<本机局域网 IP>:8788/fleet`。首次密码可在当前管理台直接设置。Gateway 默认监听所有网络接口,因此必须确保主机仅接入可信内网或已通过防火墙限制 8788 的来源;HTTP 部署不得直接暴露到公网,公网开放必须由前置代理提供 HTTPS 和访问控制。
> 当前生产秘密存储使用 macOS Keychain,因此一键安装脚本暂只支持 macOS。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
> 秘密存储按平台自动选择:macOS 使用 Keychain,Linux 使用数据目录下 0600 权限的 `secrets.json` 文件;也可用 `MULTI_SIMADMIN_SECRET_BACKEND=macos-keychain|secret-file` 显式指定。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
### 服务管理
@@ -43,7 +67,17 @@ sh /tmp/multi-simadmin-install.sh start
sh /tmp/multi-simadmin-install.sh uninstall
```
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置为 `~/Library/Application Support/multi-simadmin`,可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置 macOS 为 `~/Library/Application Support/multi-simadmin`,Linux 为 `~/.local/share/multi-simadmin`(遵循 XDG),均可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
#### Linux systemd(可选,但推荐)
`sh install.sh unit` 会生成 `~/.config/systemd/user/` 下的 API 与 Gateway 用户单元(`Restart=on-failure`、开机自启、密钥经 0600 的 `service-env` 注入)。随后:
```bash
systemctl --user enable --now multi-simadmin-api.service multi-simadmin-gateway.service
```
单元内声明了 `MULTI_SIMADMIN_SYSTEMD_UNIT`,因此 Web 控制台的“在线更新”在 Linux 上会把重启交给 systemd 完成,形成自更新闭环。由 systemd 托管后请用 `systemctl` 管理进程,不要再用本脚本 `start/stop`,避免双进程争抢端口。
### Fleet 节点总览与跨域健康
@@ -80,10 +114,12 @@ sh /tmp/multi-simadmin-install.sh uninstall
```bash
cp config.example.json config.json
npm install
npm start
corepack pnpm install
corepack pnpm start
```
> 仓库统一使用 pnpm(`pnpm-lock.yaml`)作为唯一 lockfile;陈旧的 `package-lock.json` 已移除。
## 配置
编辑 `config.json`:
@@ -139,6 +139,17 @@ function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem
export class AuditQueryService {
constructor(private readonly db: Database.Database) {}
/** Retention hook: drops audit records older than the cutoff. */
pruneBefore(cutoffIso: string): number {
if (typeof cutoffIso !== 'string' || !Number.isFinite(Date.parse(cutoffIso)))
throw new RangeError('cutoffIso must be a valid date-time');
return Number(
this.db
.prepare('DELETE FROM audit_events WHERE created_at < ?')
.run(new Date(cutoffIso).toISOString()).changes,
);
}
summary(limit = 5): ControlPlaneAuditSummary {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) validation('limit is invalid');
const totalRow = this.db.prepare('SELECT COUNT(*) AS total FROM audit_events').get() as
@@ -6,6 +6,25 @@ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 1024;
const SCRYPT_KEY_LENGTH = 32;
// Legacy derivations used N=16384; OWASP guidance for interactive login sits at
// N=2^16/r=8. Rows keep their derivation tag so verification stays correct and
// the upgrade happens transparently on the next successful login.
interface ScryptParams {
readonly N: number;
readonly r: number;
readonly p: number;
}
const SCRYPT_LEGACY: ScryptParams = { N: 16_384, r: 8, p: 1 };
const SCRYPT_CURRENT: ScryptParams = { N: 65_536, r: 8, p: 1 };
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
const scryptTag = (params: ScryptParams): string => `scrypt:${params.N}:${params.r}:${params.p}`;
const parseScryptTag = (value: string | null | undefined): ScryptParams => {
if (!value) return SCRYPT_LEGACY;
const match = /^scrypt:(\d+):(\d+):(\d+)$/.exec(value);
if (!match) return SCRYPT_LEGACY;
const [N, r, p] = match.slice(1).map(Number) as [number, number, number];
return { N, r, p };
};
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
const MIN_IDLE_TIMEOUT_MINUTES = 5;
@@ -16,6 +35,7 @@ interface AuthConfigRow {
readonly password_salt: string | null;
readonly password_hash: string | null;
readonly password_revision: number;
readonly password_kdf: string | null;
}
export interface ConsoleAuthStatus {
@@ -87,10 +107,23 @@ export class ConsoleAuthService {
!config.password_hash
)
throw new ConsoleAuthError('INVALID_CREDENTIALS');
const actual = await this.derive(password, config.password_salt);
const params = parseScryptTag(config.password_kdf);
const actual = await this.derive(password, config.password_salt, params);
const expected = Buffer.from(config.password_hash, 'hex');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
throw new ConsoleAuthError('INVALID_CREDENTIALS');
if (params !== SCRYPT_CURRENT) {
// Transparent upgrade: same salt, stronger derivation, same revision so
// live sessions survive.
const hash = (await this.derive(password, config.password_salt, SCRYPT_CURRENT)).toString(
'hex',
);
this.db
.prepare(
'UPDATE console_auth_config SET password_hash=?,password_kdf=?,updated_at=? WHERE singleton=1',
)
.run(hash, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
}
return this.createSession(config.password_revision);
}
@@ -121,14 +154,14 @@ export class ConsoleAuthService {
this.db
.prepare(
`INSERT INTO console_auth_config
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
VALUES (1,1,?,?,?,?)
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
VALUES (1,1,?,?,?,?,?)
ON CONFLICT(singleton) DO UPDATE SET
protection_enabled=1,password_salt=excluded.password_salt,
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
updated_at=excluded.updated_at`,
password_kdf=excluded.password_kdf,updated_at=excluded.updated_at`,
)
.run(salt, hash, revision, this.now().toISOString());
.run(salt, hash, revision, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
this.db.prepare('DELETE FROM console_auth_sessions').run();
return this.createSession(revision);
})();
@@ -178,7 +211,7 @@ export class ConsoleAuthService {
private config(): AuthConfigRow | undefined {
return this.db
.prepare(
`SELECT protection_enabled,password_salt,password_hash,password_revision
`SELECT protection_enabled,password_salt,password_hash,password_revision,password_kdf
FROM console_auth_config WHERE singleton=1`,
)
.get() as AuthConfigRow | undefined;
@@ -221,13 +254,17 @@ export class ConsoleAuthService {
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
}
private derive(password: string, salt: string): Promise<Buffer> {
private derive(
password: string,
salt: string,
params: ScryptParams = SCRYPT_CURRENT,
): Promise<Buffer> {
return new Promise((resolvePromise, reject) => {
deriveScrypt(
password,
Buffer.from(salt, 'hex'),
SCRYPT_KEY_LENGTH,
{ N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
{ ...params, maxmem: SCRYPT_MAXMEM },
(error, derivedKey) => {
if (error) reject(error);
else resolvePromise(derivedKey);
@@ -9,7 +9,7 @@ describe('ScheduledOperationDispatcher', () => {
it('renders SMS time and random macros separately for each target', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
const send = vi.fn(async (_instanceId: string, input: { content: string }) => ({
const send = vi.fn(async (_instanceId: string, _input: { content: string }) => ({
sent: true as const,
}));
const dispatcher = new ScheduledOperationDispatcher({
@@ -17,6 +17,7 @@ describe('ScheduledOperationDispatcher', () => {
operations: { prepare: vi.fn(), execute: vi.fn() },
messages: { send },
store: {
provider: 'macos-keychain',
set: vi.fn(),
get: vi.fn(async () =>
JSON.stringify({
@@ -90,7 +91,7 @@ describe('ScheduledOperationDispatcher', () => {
execute,
},
messages: { send: vi.fn() },
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
repository: new ScheduledTaskRepository(db),
});
const result = await dispatcher.dispatch(
@@ -179,6 +180,7 @@ describe('ScheduledOperationDispatcher', () => {
operations: { prepare: vi.fn(), execute: vi.fn() },
messages: { send },
store: {
provider: 'macos-keychain',
set: vi.fn(),
get: vi.fn(async () =>
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
@@ -216,7 +218,7 @@ describe('ScheduledOperationDispatcher', () => {
execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }),
},
messages: { send: vi.fn() },
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
repository: new ScheduledTaskRepository(db),
});
const result = await dispatcher.dispatch(
@@ -114,6 +114,14 @@ export class ScheduledOperationDispatcher {
const schema = operationSchemas[task.operationType];
if (!operationId || !schema)
return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] };
// The execution baseline pins delay_seconds to 3; anything else could never
// succeed, so refuse up front instead of exhausting the retry policy.
if (task.operationType === 'reboot-system' && (task.delaySeconds ?? 3) !== 3)
return {
outcome: 'needs-attention',
reason: 'Scheduled reboots only support the pinned 3-second delay',
jobIds: [],
};
const fields =
task.operationType === 'reboot-system'
? [
@@ -7,6 +7,7 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { ScheduledTaskService } from './scheduled-task-service.js';
class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>();
readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
blockRotations = false;
@@ -8,6 +8,7 @@ import { ConnectionProbe } from './connection-probe.js';
import { ConnectionLogService } from '../system/connection-log-service.js';
class Store implements SecretStore {
readonly provider = 'macos-keychain';
async set(key: { instanceId: string; purpose: string; slot?: string }) {
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
}
@@ -27,6 +27,7 @@ describe('InstanceCredentialResolver', () => {
it('resolves only the canonical password reference for the requested existing instance', async () => {
let asked: string | undefined;
const { db, resolver } = fixture({
provider: 'macos-keychain',
set: async () => '',
get: async (reference) => {
asked = reference;
@@ -44,6 +45,7 @@ describe('InstanceCredentialResolver', () => {
it('does not call SecretStore when no saved credential exists', async () => {
let calls = 0;
const { resolver } = fixture({
provider: 'macos-keychain',
set: async () => '',
get: async () => {
calls += 1;
@@ -57,6 +59,7 @@ describe('InstanceCredentialResolver', () => {
it('rejects a reference bound to another instance before SecretStore access', async () => {
let calls = 0;
const { db, resolver } = fixture({
provider: 'macos-keychain',
set: async () => '',
get: async () => {
calls += 1;
@@ -1,9 +1,8 @@
import type Database from 'better-sqlite3';
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
export class CredentialResolverError extends Error {
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
super(code);
@@ -24,10 +23,12 @@ export class InstanceCredentialResolver {
.prepare(
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
)
.get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined;
.get(instanceId, PURPOSE, this.options.store.provider) as
| { external_reference: string }
| undefined;
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
try {
const parsed = parseKeychainReference(row.external_reference);
const parsed = parseSecretReference(row.external_reference);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
throw new Error('invalid binding');
const secret = await this.options.store.get(row.external_reference);
@@ -185,7 +185,6 @@ export class EventJournal {
const sequence = Number(result.lastInsertRowid);
if (!Number.isSafeInteger(sequence))
throw new Error('event journal sequence is not a safe integer');
this.#prune();
const appended = { sequence, envelope };
for (const subscriber of [...this.#subscribers]) subscriber(appended);
return appended;
@@ -236,7 +235,11 @@ export class EventJournal {
};
}
#prune(): void {
/**
* Maintenance hook for a periodic timer. Pruning per append turned every
* insert into an unindexable full-table json_extract scan.
*/
prune(): void {
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
this.#database
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
@@ -10,6 +10,7 @@ import type { Instance } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from './instance-service.js';
class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>();
readonly deletes: string[] = [];
readonly sets: Array<{
@@ -10,10 +10,9 @@ import type {
SnapshotFreshness,
} from '@multi-simadmin/contracts';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const MAX_TAGS = 50;
const MAX_TAG_LENGTH = 100;
@@ -408,7 +407,7 @@ export class InstanceService {
.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
)
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
.run(id, instanceId, PURPOSE, this.store.provider, external, now, now);
}
private async storeSecret(
instanceId: string,
@@ -422,7 +421,7 @@ export class InstanceService {
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
}
try {
const parsed = parseKeychainReference(external);
const parsed = parseSecretReference(external);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
throw new Error('secret store returned a reference with an invalid binding');
} catch {
@@ -504,7 +503,7 @@ export class InstanceService {
AND secret_cleanup_tasks.purpose=excluded.purpose
AND secret_cleanup_tasks.provider=excluded.provider`,
)
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
.run(reference, instanceId, PURPOSE, this.store.provider, now, now);
if (queued.changes !== 1)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
}
@@ -529,13 +528,13 @@ export class InstanceService {
);
}
private validateCleanupEntry(entry: CleanupEntry): void {
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
if (entry.purpose !== PURPOSE || entry.provider !== this.store.provider)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
this.validateCleanupReference(entry.instanceId, entry.reference);
}
private validateCleanupReference(instanceId: string, reference: string): void {
try {
const parsed = parseKeychainReference(reference);
const parsed = parseSecretReference(reference);
if (
parsed.instanceId !== instanceId ||
parsed.purpose !== PURPOSE ||
@@ -39,6 +39,7 @@ function pendingDatabase(): Database.Database {
}
class FakeStore implements SecretStore {
readonly provider = 'macos-keychain';
readonly sets: { key: SecretKey; value: string }[] = [];
readonly deletes: string[] = [];
setError?: Error;
@@ -4,7 +4,6 @@ import { getManagedDatabaseIdentity } from '../../infrastructure/database/databa
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
@@ -163,7 +162,7 @@ async function activatePendingSecretExclusive({
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
db.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now);
).run(id, instanceId, PURPOSE, store.provider, externalReference, now, now);
const updated = db
.prepare(
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
@@ -9,6 +9,7 @@ import {
} from './central-notification-service.js';
class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
@@ -559,7 +559,28 @@ export class CentralNotificationService {
async deleteChannel(id: string): Promise<void> {
const channel = this.getChannel(id);
this.#db.transaction(() => {
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id);
// Rules keep channel ids in a JSON column, so no FK cascade applies; a
// dangling id would make every referencing rule unreadable and drop
// future notifications on the floor.
const rules = this.#db
.prepare('SELECT id, channel_ids_json FROM notification_rules')
.all() as Array<{ id: string; channel_ids_json: string }>;
for (const rule of rules) {
let ids: unknown;
try {
ids = JSON.parse(String(rule.channel_ids_json));
} catch {
continue;
}
if (!Array.isArray(ids) || !ids.includes(id)) continue;
const next = JSON.stringify(ids.filter((entry) => entry !== id));
this.#db
.prepare('UPDATE notification_rules SET channel_ids_json=?, updated_at=? WHERE id=?')
.run(next, this.#now(), rule.id);
}
})();
if (channel.secretReference)
await this.#options.store.delete(channel.secretReference).catch(() => false);
}
@@ -1202,6 +1223,19 @@ export class CentralNotificationService {
return { processed: succeeded + failed, succeeded, failed };
}
/** Startup sweep: rows claimed as 'sending' before a crash would never drain again. */
reconcileInterrupted(): number {
return Number(
this.#db
.prepare(
`UPDATE notification_queue
SET status='pending',updated_at=?
WHERE status='sending'`,
)
.run(this.#now()).changes,
);
}
retryQueueItem(id: string): void {
const result = this.#db
.prepare(
@@ -1214,6 +1248,20 @@ export class CentralNotificationService {
if (result.changes !== 1) throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
}
/** Retention for terminal queue rows; pending/sending backlog is never touched. */
pruneFinishedQueue(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(Date.parse(this.#now()) - olderThanMs).toISOString();
return Number(
this.#db
.prepare(
"DELETE FROM notification_queue WHERE status IN ('succeeded','failed','cancelled') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
deleteQueueItem(id: string): void {
if (this.#db.prepare('DELETE FROM notification_queue WHERE id=?').run(id).changes !== 1)
throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
@@ -1338,7 +1386,12 @@ export class CentralNotificationService {
#queueItem(row: Record<string, unknown>): NotificationQueueItem {
const status = String(row.status) as NotificationQueueItem['status'];
const attempts = Number(row.attempts ?? 0);
const payload = record(JSON.parse(String(row.payload_json))) ?? {};
let payload: Record<string, unknown> = {};
try {
payload = record(JSON.parse(String(row.payload_json))) ?? {};
} catch {
payload = {};
}
const deliveredAt =
typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined;
const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined;
@@ -1560,7 +1613,12 @@ export class CentralNotificationService {
enabled: row.enabled === 1,
condition: parseJson(String(row.condition_json), this.#condition.bind(this)),
scope: parseJson(String(row.scope_json), this.#scope.bind(this)),
channels: Object.freeze(channelIds.map((id) => this.getChannel(id))),
channels: Object.freeze(
channelIds.flatMap((id) => {
const row = this.#row(`SELECT * FROM notification_channels WHERE id = ?`, id);
return row ? [this.#channel(row)] : [];
}),
),
templates: parseJson(String(row.templates_json), this.#templates.bind(this)),
rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit),
quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours),
@@ -160,6 +160,8 @@ async function defaultRequester(request: HttpRequest, timeoutMs: number): Promis
headers: request.headers,
...(request.body === undefined ? {} : { body: request.body }),
signal: AbortSignal.timeout(timeoutMs),
// A signed payload must never be replayed to a redirect target.
redirect: 'manual',
});
const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
return { status: response.status, body: text };
@@ -220,6 +220,9 @@ export async function sendEmailNotification(
const receivers = splitAddresses(configText(input.config, 'receiver_addresses'));
if (!host) return { ok: false, detail: '缺少 SMTP 地址' };
if (!sender) return { ok: false, detail: '缺少发件地址' };
// Envelope commands are line-framed; reject anything that could break the frame.
if (!/^[^\s@<>,;:"']+@[^\s@<>,;:"']+$/u.test(sender))
return { ok: false, detail: '发件地址无效' };
if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' };
const port = configNumber(input.config, 'smtp_port', 465);
const security = configText(input.config, 'smtp_security') || 'implicit_tls';
@@ -80,7 +80,9 @@ describe('MetricsService', () => {
const text = metrics.render();
expect(text).toContain('multi_simadmin_nodes_total 2');
expect(text).toContain('multi_simadmin_node_enabled{node="node-b"} 0');
expect(text).toContain('multi_simadmin_node_info{node="node-a",name="机房 A"} 1');
expect(text).toContain('multi_simadmin_node_info{node="node-a"} 1');
// Operator-assigned names stay out of the session-free exposition entirely.
expect(text).not.toContain('机房 A');
expect(text).toContain('multi_simadmin_node_probe_total{node="node-a",outcome="success"} 38');
expect(text).toContain('multi_simadmin_node_availability_ratio{node="node-a"} 0.950');
expect(text).toContain('multi_simadmin_sms_outbox{status="failed"} 1');
@@ -108,14 +110,15 @@ describe('MetricsService', () => {
expect(text).toContain('multi_simadmin_identity_pending_total 2');
});
it('escapes label values so a node name cannot forge a series', () => {
it('never emits a node name, so a hostile name cannot forge a series', () => {
const { db: database, metrics } = fixture();
seedNode(database, 'node-a', 'evil" name\nwith break');
const text = metrics.render();
expect(text).toContain('name="evil\\" name\\nwith break"');
// The injected newline must not become its own sample line.
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(1);
// Names are excluded from the session-free exposition outright; escaping a
// label is not the right tool for untrusted operator input.
expect(text).not.toContain('evil"');
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(0);
});
it('emits a valid exposition: help and type precede every series', () => {
@@ -77,9 +77,9 @@ export class MetricsService {
]),
);
const info = nodes.map(
(node) => `${PREFIX}_node_info{node="${label(node.id)}",name="${label(node.name)}"} 1`,
);
// Operator-assigned names can leak hostnames/locations to unauthenticated
// scrapers; the series intentionally stays opaque to node ids.
const info = nodes.map((node) => `${PREFIX}_node_info{node="${label(node.id)}"} 1`);
lines.push(
...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info),
...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [
@@ -12,6 +12,7 @@ import {
} from './delete-instance-operation.js';
class Store implements SecretStore {
readonly provider = 'macos-keychain';
async set() {
return '';
}
@@ -1,4 +1,5 @@
import type Database from 'better-sqlite3';
import type { EventEnvelope } from '../events/event-journal.js';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { Job, Preparation, PrepareOperationRequest } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
@@ -75,6 +76,8 @@ interface Options {
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
/** Receives job/instance envelopes so the SSE journal can invalidate fleet views. */
readonly emit?: (envelope: EventEnvelope) => void;
}
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
@@ -93,6 +96,7 @@ export class DeleteInstanceOperation {
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
private readonly emit: Options['emit'];
constructor(options: Options) {
this.db = options.db;
@@ -101,6 +105,7 @@ export class DeleteInstanceOperation {
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
this.emit = options.emit;
}
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
@@ -318,6 +323,7 @@ export class DeleteInstanceOperation {
const release = await this.releaseBindingPort(input.instanceId, input.requestId);
if (release?.status === 'failed') {
this.closeAsFailed(ids, input.requestId, BINDING_REFUSAL_CODE);
this.#emitJob(ids.job, input.requestId);
return this.job(ids.job);
}
try {
@@ -342,6 +348,16 @@ export class DeleteInstanceOperation {
)
.run(finished, finished, ids.job);
})();
// Events only after the terminal rows are committed, so a subscriber that
// refetches on the notification sees the finished job, not 'running'.
this.#emit({
kind: 'instance',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId: input.requestId,
instanceId: input.instanceId,
});
this.#emitJob(ids.job, input.requestId);
} catch (error) {
const finished = this.clock().toISOString();
const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result';
@@ -361,6 +377,7 @@ export class DeleteInstanceOperation {
.prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
.run(state, finished, finished, ids.job);
})();
this.#emitJob(ids.job, input.requestId);
}
return this.job(ids.job);
}
@@ -379,6 +396,25 @@ export class DeleteInstanceOperation {
}
/** Closes the job without touching the instance row, so the device stays listed and retryable. */
#emit(envelope: EventEnvelope): void {
if (!this.emit) return;
try {
this.emit(envelope);
} catch {
// A journal failure must never fail the operation itself.
}
}
#emitJob(jobId: string, requestId: string): void {
this.#emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
}
private closeAsFailed(
ids: { job: string; item: string; attempt: string },
requestId: string,
@@ -416,9 +452,10 @@ export class DeleteInstanceOperation {
const finished = this.clock().toISOString();
return this.db.transaction(() => {
const jobs = this.db
.prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string }>;
for (const { id } of jobs) {
.prepare("SELECT id, request_id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string; request_id: string }>;
for (const { id, request_id: requestId } of jobs) {
this.#emitJob(id, requestId);
this.db
.prepare(
"UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { EventEnvelope } from '../events/event-journal.js';
import type {
ExecuteOperationRequest,
Job,
@@ -121,6 +122,8 @@ interface Options {
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
readonly nonceFactory?: () => string;
/** Receives job terminal transitions so the SSE journal has a live producer. */
readonly emit?: (envelope: EventEnvelope) => void;
}
interface PreparationRow {
operation_id: string;
@@ -442,9 +445,28 @@ export class SecureOperationExecution {
}
}
this.finish(ids, state, code);
this.#emitTerminal(ids.job, state, requestId);
return this.job(ids.job);
}
/**
* Retention for consumed/expired/invalidated preparations. Every prepare
* (manual or a scheduled retry attempt) inserts a row, so without this the
* table grows one row per attempt forever.
*/
pruneTerminalPreparations(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(this.clock().getTime() - olderThanMs).toISOString();
return Number(
this.options.db
.prepare(
"DELETE FROM operation_preparations WHERE status IN ('consumed','expired','invalidated') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
reconcileInterruptedJobs(): number {
const now = this.clock().toISOString();
return this.options.db.transaction(() => {
@@ -453,7 +475,10 @@ export class SecureOperationExecution {
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
)
.all() as Array<{ id: string }>;
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
for (const row of jobs) {
this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
this.#emitTerminal(row.id, 'unknown-result', now);
}
return jobs.length;
})();
}
@@ -479,6 +504,21 @@ export class SecureOperationExecution {
.run(state, now, now, ids.job);
})();
}
#emitTerminal(jobId: string, state: string, requestId: string): void {
if (!this.options.emit) return;
try {
this.options.emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
} catch {
// A journal failure must never fail the operation itself.
}
}
private finishByJob(jobId: string, now: string, state: string, code: string): void {
this.options.db
.prepare(
@@ -1,4 +1,6 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
InstanceResourceService,
@@ -155,6 +157,112 @@ describe('instance resource allowlist parsing', () => {
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
});
describe('persisted resource snapshots', () => {
it('serves the first overview after a restart from status_snapshots with zero upstream calls', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
// status_snapshots carries an FK to instances; seed the node it belongs to.
db.prepare(
"INSERT INTO instances (id,name,base_url,created_at,updated_at) VALUES ('device-1','device-1','http://192.168.3.55:3000','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')",
).run();
let clock = 1_000;
let livePasses = 0;
const build = () => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
db,
now: () => clock,
} as never);
return { service, requests };
};
const first = build();
await first.service.get('device-1');
livePasses = first.requests.length;
expect(livePasses).toBe(6);
// A brand-new service (process restart) reads the persisted snapshot.
const second = build();
const resources = await second.service.get('device-1');
expect(second.requests).toHaveLength(0);
expect(resources).toEqual({ controlOnline: true });
// Past the freshness window the probe goes live again and repersists.
clock += 121_000;
await second.service.get('device-1');
expect(second.requests.length).toBe(livePasses);
db.close();
});
});
describe('overview cache', () => {
const minimal = (cacheTtlMs?: number) => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
} as never);
return { service, requests };
};
it('serves repeat overview reads from the TTL cache and refreshes after expiry', async () => {
let clock = 1_000;
const { service, requests } = minimal();
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
await service.get('device-1');
const firstPass = requests.length;
await service.get('device-1');
expect(requests.length).toBe(firstPass);
clock += 31_000;
await service.get('device-1');
expect(requests.length).toBeGreaterThan(firstPass);
});
it('coalesces concurrent overview reads into one upstream pass', async () => {
const { service, requests } = minimal();
await Promise.all([
service.get('device-1'),
service.get('device-1'),
service.get('device-1'),
]);
expect(requests).toHaveLength(6);
});
it('probes live again when a detail view forces a refresh', async () => {
const clock = 5_000;
const { service, requests } = minimal();
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
await service.get('device-1');
const afterOverview = requests.length;
await service.get('device-1', { force: true });
expect(requests.length).toBe(afterOverview + 6);
});
it('keeps reads uncached when the TTL is disabled', async () => {
const { service, requests } = minimal(0);
await service.get('device-1');
await service.get('device-1');
expect(requests).toHaveLength(12);
});
});
it('probes passwordless instances without manufacturing a cookie', async () => {
const requests: Array<{ url: string; headers: Readonly<Record<string, string>> }> = [];
const service = new InstanceResourceService({
@@ -1,4 +1,6 @@
import { lookupOperator } from '@multi-simadmin/contracts';
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { InstanceService } from '../instances/instance-service.js';
import type {
@@ -163,7 +165,17 @@ export function parseSignalStrength(response: UpstreamResponse): InstanceResourc
return signalPercent === undefined ? {} : { signalPercent };
}
const DEFAULT_CACHE_TTL_MS = 30_000;
const DEFAULT_SNAPSHOT_FRESH_MS = 120_000;
const RESOURCE_SNAPSHOT_CATEGORY = 'resources';
export class InstanceResourceService {
readonly #cache = new Map<
string,
{ readonly resources: InstanceResources; readonly fetchedAt: number }
>();
readonly #inflight = new Map<string, Promise<InstanceResources>>();
constructor(
private readonly options: {
readonly instances: InstanceService;
@@ -174,10 +186,115 @@ export class InstanceResourceService {
origin: string,
force?: boolean,
) => Promise<void>;
/** Fleet overview fan-out reads through this cache; 0 disables it. */
readonly cacheTtlMs?: number;
/**
* When set, every live fetch is persisted into status_snapshots
* (category 'resources') and a snapshot younger than snapshotFreshMs is
* served before any upstream call, so the first overview after a restart
* costs zero device requests.
*/
readonly db?: Database.Database;
readonly snapshotFreshMs?: number;
readonly now?: () => number;
},
) {}
async get(instanceId: string): Promise<InstanceResources> {
/**
* Fleet overview fans out one read per registered node; without a shared TTL
* that is six live upstream requests per device on every page render (plus a
* re-login storm when sessions expired). Detail views pass `force` to probe
* the selected device immediately.
*/
async get(
instanceId: string,
{ force = false }: { readonly force?: boolean } = {},
): Promise<InstanceResources> {
const ttl = this.options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
const now = this.options.now?.() ?? Date.now();
if (ttl > 0 && !force) {
const cached = this.#cache.get(instanceId);
if (cached && now - cached.fetchedAt < ttl) return cached.resources;
const persisted = this.#readSnapshot(instanceId, now);
if (persisted) {
this.#cache.set(instanceId, { resources: persisted, fetchedAt: now });
return persisted;
}
const inflight = this.#inflight.get(instanceId);
if (inflight) return inflight;
const pending = this.#fetch(instanceId)
.then((resources) => {
const fetchedAt = this.options.now?.() ?? now;
this.#cache.set(instanceId, { resources, fetchedAt });
this.#writeSnapshot(instanceId, resources, fetchedAt);
return resources;
})
.finally(() => {
this.#inflight.delete(instanceId);
});
this.#inflight.set(instanceId, pending);
return pending;
}
const resources = await this.#fetch(instanceId);
if (ttl > 0) {
this.#cache.set(instanceId, { resources, fetchedAt: now });
this.#writeSnapshot(instanceId, resources, now);
}
return resources;
}
#readSnapshot(instanceId: string, now: number): InstanceResources | undefined {
const db = this.options.db;
if (!db) return undefined;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
try {
const row = db
.prepare(
'SELECT payload_json, observed_at FROM status_snapshots WHERE instance_id=? AND category=?',
)
.get(instanceId, RESOURCE_SNAPSHOT_CATEGORY) as
| { payload_json: string; observed_at: string }
| undefined;
if (!row) return undefined;
const age = now - Date.parse(row.observed_at);
if (!Number.isFinite(age) || age < 0 || age >= freshMs) return undefined;
const payload: unknown = JSON.parse(row.payload_json);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
return undefined;
return payload as InstanceResources;
} catch {
return undefined;
}
}
#writeSnapshot(instanceId: string, resources: InstanceResources, observedAt: number): void {
const db = this.options.db;
if (!db) return;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
const observed = new Date(observedAt).toISOString();
const expiresAt = new Date(observedAt + freshMs).toISOString();
try {
db.prepare(
`INSERT INTO status_snapshots (id, instance_id, category, state, payload_json, observed_at, expires_at, created_at)
VALUES (?, ?, ?, 'fresh', ?, ?, ?, ?)
ON CONFLICT(instance_id, category) DO UPDATE SET
id=excluded.id, state=excluded.state, payload_json=excluded.payload_json,
observed_at=excluded.observed_at, expires_at=excluded.expires_at, created_at=excluded.created_at`,
).run(
randomUUID(),
instanceId,
RESOURCE_SNAPSHOT_CATEGORY,
JSON.stringify(resources),
observed,
expiresAt,
observed,
);
} catch {
// Persistence is an optimization; a failed write must not fail the read.
}
}
async #fetch(instanceId: string): Promise<InstanceResources> {
const [instance, session] = await Promise.all([
this.options.instances.get(instanceId),
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
@@ -6,6 +6,8 @@ import {
ConsoleUpdateService,
UpdateError,
createLaunchdRestartLauncher,
createPlatformRestartLauncher,
createSystemdRestartLauncher,
parseManifestVersion,
parseShortStat,
type UpdateCommandResult,
@@ -466,3 +468,76 @@ describe('createLaunchdRestartLauncher', () => {
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
});
});
describe('createSystemdRestartLauncher', () => {
it('is unsupported without a unit or with a malformed unit name', () => {
for (const unit of [undefined, '', 'evil; rm -rf /', 'multi-simadmin']) {
const launcher = createSystemdRestartLauncher({
env: unit === undefined ? {} : { MULTI_SIMADMIN_SYSTEMD_UNIT: unit },
runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) },
});
expect(launcher.supported).toBe(false);
}
});
it('restarts through the configured scope and unit', async () => {
const calls: string[][] = [];
const launcher = createSystemdRestartLauncher({
env: { MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service' },
runner: {
run: async (command, args) => {
calls.push([command, ...args]);
return { code: 0, stdout: '', stderr: '' };
},
},
});
expect(launcher.supported).toBe(true);
await launcher.restart();
expect(calls[0]).toEqual(['systemctl', 'user', 'restart', 'multi-simadmin-api.service']);
});
it('supports the system scope and surfaces systemctl failures', async () => {
const calls: string[][] = [];
const launcher = createSystemdRestartLauncher({
env: {
MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service',
MULTI_SIMADMIN_SYSTEMD_SCOPE: 'system',
},
runner: {
run: async (command, args) => {
calls.push([command, ...args]);
return { code: 1, stdout: '', stderr: 'not logged in' };
},
},
});
await launcher.restart().catch(() => {});
expect(calls[0]).toEqual(['systemctl', 'system', 'restart', 'multi-simadmin-api.service']);
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
});
});
describe('createPlatformRestartLauncher', () => {
const runner = { run: async () => ({ code: 0, stdout: '', stderr: '' }) };
it('prefers systemd on Linux when a unit is configured', () => {
const launcher = createPlatformRestartLauncher({
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux', MULTI_SIMADMIN_SYSTEMD_UNIT: 'api.service' },
runner,
});
expect(launcher.supported).toBe(true);
});
it('keeps the launchd launcher everywhere else', () => {
const linux = createPlatformRestartLauncher({
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux' },
runner,
});
const darwin = createPlatformRestartLauncher({
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'darwin', XPC_SERVICE_NAME: 'x' },
runner,
uid: () => 501,
});
expect(linux.supported).toBe(false);
expect(darwin.supported).toBe(true);
});
});
@@ -241,6 +241,51 @@ export function createLaunchdRestartLauncher(input: {
};
}
/**
* Linux systemd counterpart to the launchd launcher. The unit and scope are
* supplied explicitly through the environment so a supervised deployment can
* hand restarts to its service manager; unset variables keep it unsupported.
*/
export function createSystemdRestartLauncher(input: {
readonly env: Readonly<Record<string, string | undefined>>;
readonly runner: UpdateCommandRunner;
}): RestartLauncher {
const unit = input.env.MULTI_SIMADMIN_SYSTEMD_UNIT ?? '';
const scope = input.env.MULTI_SIMADMIN_SYSTEMD_SCOPE === 'system' ? 'system' : 'user';
const validUnit = /^[A-Za-z0-9@_.\-]+\.service$/.test(unit);
const supported = validUnit;
return {
supported,
async restart() {
if (!supported)
throw new UpdateError(
'NOT_SUPPORTED',
'未配置 systemd 服务单元(MULTI_SIMADMIN_SYSTEMD_UNIT)。',
501,
);
const result = await input.runner.run('systemctl', [scope, 'restart', unit]);
if (result.code !== 0)
throw new UpdateError(
'COMMAND',
text(result.stderr, 200) || 'systemctl restart 失败。',
500,
);
},
};
}
/** Picks the restart launcher matching the host service manager. */
export function createPlatformRestartLauncher(input: {
readonly env: Readonly<Record<string, string | undefined>>;
readonly runner: UpdateCommandRunner;
readonly uid?: () => number | undefined;
}): RestartLauncher {
const platform = input.env.MULTI_SIMADMIN_HOST_PLATFORM ?? process.platform;
return platform === 'linux' && input.env.MULTI_SIMADMIN_SYSTEMD_UNIT !== undefined
? createSystemdRestartLauncher(input)
: createLaunchdRestartLauncher(input);
}
export interface ConsoleUpdateServiceOptions {
readonly db: Database.Database;
readonly version: string;
+3
View File
@@ -107,6 +107,9 @@ function trustedUpstreamHeaders(
delete sanitized[GATEWAY_CLIENT_IP_HEADER];
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp;
// The browser connection's protocol is the gateway's to declare; the API uses
// it to decide whether session cookies may carry the Secure flag.
sanitized['x-forwarded-proto'] = (headers['x-forwarded-proto'] as string | undefined) ?? 'http';
return sanitized;
}
+3 -1
View File
@@ -131,7 +131,9 @@ afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
describe.sequential('executable package runtimes', () => {
// The runtime lifecycle under test is macOS/POSIX-only: process-group SIGTERM,
// lsof port inspection, and corepack as a direct spawn target.
describe.sequential('executable package runtimes', { skip: process.platform === 'win32' }, () => {
it('makes the exact production gateway executable attempt 8788 only with acknowledgement', async () => {
await expectPortFree(8788);
const fixture = await listenOn(8788);
@@ -9,7 +9,12 @@ it('registers the read-only operations catalog without touching upstream', async
let upstreamCalls = 0;
const app = buildControlPlaneApp({
db,
store: { set: async () => '', get: async () => undefined, delete: async () => false },
store: {
provider: 'macos-keychain',
set: async () => '',
get: async () => undefined,
delete: async () => false,
},
upstream: {
get: async () => {
upstreamCalls += 1;
+1
View File
@@ -13,6 +13,7 @@ afterEach(async () => {
for (const db of dbs.splice(0)) db.close();
});
class Store implements SecretStore {
readonly provider = 'macos-keychain';
private readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
const account = Buffer.from(
+42 -1
View File
@@ -167,6 +167,12 @@ function createHubDeviceReader(
};
}
// Retention windows for operator-facing history. Preparations are short-lived
// protocol state and cycle faster; audit records are the longest-lived.
const RETENTION_HISTORY_MS = 30 * 24 * 60 * 60 * 1000;
const RETENTION_AUDIT_MS = 90 * 24 * 60 * 60 * 1000;
const RETENTION_PREPARATION_MS = 7 * 24 * 60 * 60 * 1000;
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
@@ -245,6 +251,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
sessions,
request: options.upstream.request,
ensureSession,
db: options.db,
});
const messages = new InstanceMessageService({
instances,
@@ -320,6 +327,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
...(options.now ? { now: options.now } : {}),
});
smsOutbox.reconcileInterrupted();
centralNotifications.reconcileInterrupted();
// Removal follows the hub rule the operator already knows: an online device is told to release
// its own binding first and a refusal keeps the record, while a device that cannot be reached
// at all is forgotten locally. The closure is late-bound because the device action service is
@@ -346,8 +354,14 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
instances,
releaseBinding,
now: options.now,
emit: (envelope) => eventJournal.append(envelope),
})
: new DeleteInstanceOperation({ db: options.db, instances, releaseBinding });
: new DeleteInstanceOperation({
db: options.db,
instances,
releaseBinding,
emit: (envelope) => eventJournal.append(envelope),
});
deletion.reconcileInterruptedJobs();
const listHubDevices = createHubDeviceReader(options.db, instances);
const resolveOperationCookie = async (
@@ -380,6 +394,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
const secureExecution = new SecureOperationExecution({
db: options.db,
registry: secureOperationRegistry,
emit: (envelope) => eventJournal.append(envelope),
transport: {
request: async ({ origin, path, body, contentType, instanceId }) => {
const cookie = await resolveOperationCookie(instanceId, origin);
@@ -509,6 +524,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
resources,
messages,
registerDeletionPreparationRoute: false,
onEvent: (envelope) => eventJournal.append(envelope),
});
registerFleetRoutes(app, {
instances,
@@ -581,6 +597,31 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
void Promise.resolve()
.then(() => centralNotifications.pruneLogs())
.catch(() => undefined);
// Delivered and abandoned outbox rows otherwise accumulate forever.
void Promise.resolve()
.then(() => smsOutbox.prune(RETENTION_HISTORY_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => eventJournal.prune())
.catch(() => undefined);
// Connection probes write a log row per beat per node; without a sweep
// that alone is thousands of rows a day.
void Promise.resolve()
.then(() =>
connectionLogs.prune({
before: new Date(Date.now() - RETENTION_HISTORY_MS).toISOString(),
}),
)
.catch(() => undefined);
void Promise.resolve()
.then(() => audit.pruneBefore(new Date(Date.now() - RETENTION_AUDIT_MS).toISOString()))
.catch(() => undefined);
void Promise.resolve()
.then(() => secureExecution.pruneTerminalPreparations(RETENTION_PREPARATION_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => centralNotifications.pruneFinishedQueue(RETENTION_HISTORY_MS))
.catch(() => undefined);
}, logPruneIntervalMs)
: undefined;
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
+6 -2
View File
@@ -23,7 +23,7 @@ const STATE_FILE = 'cutover-state.json';
const GATEWAY_ARGV = [
process.execPath,
'--import',
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
'tsx',
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
] as const;
const DIGEST = /^[a-f0-9]{64}$/u;
@@ -289,12 +289,15 @@ async function durableState(plan: CutoverPlan, state: CutoverState): Promise<voi
await handle.close();
}
await rename(temporary, target);
if (process.platform !== 'win32') {
// directory fsync only exists on POSIX
const directory = await open(plan.stateDir, constants.O_RDONLY);
try {
await directory.sync();
} finally {
await directory.close();
}
}
}
function preparedState(plan: CutoverPlan): CutoverState {
@@ -311,7 +314,8 @@ function preparedState(plan: CutoverPlan): CutoverState {
async function readState(plan: CutoverPlan): Promise<CutoverState> {
const path = join(plan.stateDir, STATE_FILE);
const info = await lstat(path);
if (!info.isFile() || (info.mode & 0o777) !== 0o600)
// Windows keeps no POSIX mode bits (chmod is a no-op); ACLs govern access there.
if (!info.isFile() || (process.platform !== 'win32' && (info.mode & 0o777) !== 0o600))
throw new Error('Cutover state must be a mode-0600 regular file');
const input = record(JSON.parse(await readFile(path, 'utf8')) as unknown, 'Cutover state');
const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart'];
@@ -48,10 +48,13 @@ describe('verified backup, restore, and rollback foundation', () => {
]);
copy.close();
expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/);
expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
if (process.platform !== 'win32') expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
});
it('verifies a separate candidate and digest-binds activation', async () => {
// Activation renames a WAL file that a live connection still holds open;
// POSIX permits that, Windows file locking does not.
const itPosix = process.platform === 'win32' ? it.skip : it;
itPosix('verifies a separate candidate and digest-binds activation', async () => {
const { livePath, backupPath, database } = await fixture();
const snapshot = await backupDatabase(database, backupPath);
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
@@ -118,7 +121,7 @@ describe('verified backup, restore, and rollback foundation', () => {
expect(await readFile(livePath)).toEqual(before);
});
it('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
itPosix('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
const { livePath, backupPath, database } = await fixture();
await backupDatabase(database, backupPath);
database.close();
@@ -159,7 +159,8 @@ async function digestFileHandle(handle: FileHandle): Promise<string> {
}
async function syncFile(path: string): Promise<void> {
const handle = await open(path, 'r');
// Windows FlushFileBuffers requires a writable handle; POSIX allows fsync on 'r'.
const handle = await open(path, process.platform === 'win32' ? 'r+' : 'r');
try {
await handle.sync();
} finally {
@@ -168,6 +169,7 @@ async function syncFile(path: string): Promise<void> {
}
async function syncDirectory(path: string): Promise<void> {
if (process.platform === 'win32') return; // directory fsync only exists on POSIX
const handle = await open(path, 'r');
try {
await handle.sync();
@@ -264,7 +266,9 @@ export async function activateRestoreCandidate(
await assertValidDatabase(stagedPath);
if ((await digestFileHandle(stagedHandle)) !== stagedDigest)
throw new Error('Staged restore candidate changed during validation');
await stagedHandle.sync();
// The staged handle is intentionally read-only after chmod(0400); Windows
// FlushFileBuffers requires a writable handle, so skip the durability sync there.
if (process.platform !== 'win32') await stagedHandle.sync();
await activateStagedDatabase(
databasePath,
stagedPath,
@@ -362,7 +362,8 @@ describe('database backup and restore', () => {
migrateDatabase(database);
const backupPath = join(directory, 'backup.sqlite');
await backupDatabase(database, backupPath);
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
// Windows keeps no POSIX mode bits; the 0600 guarantee is POSIX-only.
if (process.platform !== 'win32') expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
database.close();
});
@@ -670,6 +670,16 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_device_identities_imei ON device_identities(imei, instance_id)',
],
},
{
id: 18,
name: 'console-auth-scrypt-params',
statements: [
// Legacy hashes were derived with N=16384; new derivations use stronger
// parameters. The column records the derivation per row; NULL means legacy
// and is transparently upgraded after the next successful login.
'ALTER TABLE console_auth_config ADD COLUMN password_kdf TEXT',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -0,0 +1,174 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import {
accountFor,
parseSecretAccount,
validateKey,
SecretReferenceError,
} from './secret-reference.js';
import type { SecretKey, SecretStore } from './secret-store.js';
const PROVIDER = 'secret-file';
const SERVICE = 'multi-simadmin';
const MAX_SECRET_BYTES = 16_384;
const MAX_STORE_BYTES = 4 * 1024 * 1024;
export type FileSecretStoreErrorCode =
| 'INVALID_KEY'
| 'INVALID_SECRET'
| 'INVALID_REFERENCE'
| 'INVALID_STORE_PATH'
| 'SIZE_LIMIT'
| 'PAYLOAD_INVALID';
export class FileSecretStoreError extends Error {
constructor(
readonly code: FileSecretStoreErrorCode,
message: string,
) {
super(message);
this.name = 'FileSecretStoreError';
}
}
function invalid(code: FileSecretStoreErrorCode, message: string): never {
throw new FileSecretStoreError(code, message);
}
/**
* POSIX filesystem-backed secret store for hosts without a system keyring
* (e.g. Linux servers). Secrets live in a single 0600 JSON map under the data
* root, replaced atomically via temp file + rename, with writes serialized
* in-process.
*/
export class FileSecretStore implements SecretStore {
readonly provider = PROVIDER;
private queue: Promise<unknown> = Promise.resolve();
constructor(readonly filePath: string) {
if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath))
invalid('INVALID_STORE_PATH', 'Secret file path must be absolute');
}
async set(key: SecretKey, value: string): Promise<string> {
if (
typeof value !== 'string' ||
value.length === 0 ||
/[\0\r\n]/.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES
) {
invalid('INVALID_SECRET', 'Secret is not valid for file storage');
}
validateKey(key);
const account = accountFor(key);
await this.enqueue(() => this.withStore((store) => ({ ...store, [account]: value })));
return `secret-file://${SERVICE}/${account}`;
}
async get(reference: string): Promise<string | undefined> {
const { account } = this.parse(reference);
const store = await this.readStore();
return store[account];
}
async delete(reference: string): Promise<boolean> {
const { account } = this.parse(reference);
let removed = false;
await this.enqueue(() =>
this.withStore((store) => {
if (!(account in store)) return store;
removed = true;
const next = { ...store };
delete next[account];
return next;
}),
);
return removed;
}
private parse(reference: string): { account: string } {
if (typeof reference !== 'string' || reference.length > 512)
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
const match = /^secret-file:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
try {
parseSecretAccount(match[1], PROVIDER);
} catch (error) {
if (error instanceof SecretReferenceError)
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
throw error;
}
return { account: match[1] };
}
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.queue.then(task, task);
this.queue = run.catch(() => {});
return run;
}
private async readStore(): Promise<Record<string, string>> {
let raw: string;
try {
raw = await readFile(this.filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw error;
}
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES)
invalid('SIZE_LIMIT', 'Secret file exceeds the size limit');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
}
if (!isPlainStringMap(parsed)) invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
return parsed;
}
/** Reads the current map, applies `mutate`, and durably replaces the file. */
private async withStore(
mutate: (store: Record<string, string>) => Record<string, string>,
): Promise<void> {
const current = await this.readStore();
const next = mutate(current);
const directory = dirname(this.filePath);
await mkdir(directory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') {
const info = await stat(directory);
if (info.isDirectory() && (info.mode & 0o077) !== 0) await chmod(directory, 0o700);
}
const temporary = `${this.filePath}.${randomUUID()}.tmp`;
const handle = await open(temporary, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8');
// Windows FlushFileBuffers is skipped along with the directory fsync below;
// durability anchoring is POSIX-only by design.
if (process.platform !== 'win32') await handle.sync();
} finally {
await handle.close();
}
try {
await rename(temporary, this.filePath);
if (process.platform !== 'win32') {
const directoryHandle = await open(directory, 'r');
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
}
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
}
function isPlainStringMap(value: unknown): value is Record<string, string> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
return Object.values(value).every((entry) => typeof entry === 'string');
}
@@ -1,11 +1,11 @@
import { spawn } from 'node:child_process';
import { accountFor, parseSecretAccount, SecretReferenceError } from './secret-reference.js';
import type { SecretKey, SecretStore } from './secret-store.js';
const SECURITY_PATH = '/usr/bin/security';
const SERVICE = 'multi-simadmin';
const TIMEOUT_MS = 5_000;
const MAX_STDOUT_BYTES = 65_536;
const safeComponent = /^[A-Za-z0-9_.-]+$/;
export type SecretStoreErrorCode =
| 'INVALID_KEY'
@@ -105,43 +105,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne
throw new SecretStoreError(code, message);
}
function validateKey(key: SecretKey): void {
if (
typeof key.instanceId !== 'string' ||
key.instanceId.length === 0 ||
key.instanceId.length > 128 ||
!safeComponent.test(key.instanceId)
) {
invalid('INVALID_KEY', 'Secret key instance id is invalid');
}
if (
typeof key.purpose !== 'string' ||
key.purpose.length === 0 ||
key.purpose.length > 64 ||
!safeComponent.test(key.purpose)
) {
invalid('INVALID_KEY', 'Secret key purpose is invalid');
}
if (
key.slot !== undefined &&
(typeof key.slot !== 'string' ||
key.slot.length === 0 ||
key.slot.length > 64 ||
!safeComponent.test(key.slot))
) {
invalid('INVALID_KEY', 'Secret key slot is invalid');
}
}
function accountFor(key: SecretKey): string {
validateKey(key);
const components =
key.slot === undefined
? [key.instanceId, key.purpose]
: [key.instanceId, key.purpose, key.slot];
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
}
function referenceFor(account: string): string {
return `keychain://${SERVICE}/${account}`;
}
@@ -152,28 +115,26 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
const account = match[1];
let decoded: unknown;
let parsed;
try {
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
} catch {
parsed = parseSecretAccount(account, 'macos-keychain');
} catch (error) {
if (error instanceof SecretReferenceError)
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
throw error;
}
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
const [instanceId, purpose, slot] = decoded;
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
validateKey(key);
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
return {
service: SERVICE,
account,
instanceId: key.instanceId,
purpose: key.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }),
instanceId: parsed.instanceId,
purpose: parsed.purpose,
...(parsed.slot === undefined ? {} : { slot: parsed.slot }),
};
}
export class MacOSKeychainSecretStore implements SecretStore {
readonly provider = 'macos-keychain';
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
async set(key: SecretKey, value: string): Promise<string> {
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, readdir, rm, stat, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FileSecretStore, FileSecretStoreError } from './file-secret-store.js';
import { SecretReferenceError } from './secret-reference.js';
import {
createDefaultSecretStore,
defaultSecretFileMetadataCheck,
resolveSecretBackend,
SecretBackendError,
} from './secret-backend.js';
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
describe('FileSecretStore', () => {
it('round-trips set/get/delete with stable secret-file references', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const store = new FileSecretStore(join(directory, 'secrets.json'));
const reference = await store.set(
{ instanceId: 'device-1', purpose: 'instance-password', slot: 'slot-1' },
'pa$$word',
);
expect(reference).toMatch(/^secret-file:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/);
expect(await store.get(reference)).toBe('pa$$word');
expect(await store.delete(reference)).toBe(true);
expect(await store.get(reference)).toBeUndefined();
expect(await store.delete(reference)).toBe(false);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('rejects empty, framing, oversize, and malformed keys/values', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const store = new FileSecretStore(join(directory, 'secrets.json'));
await expect(store.set({ instanceId: 'x', purpose: 'p' }, '')).rejects.toThrow(
FileSecretStoreError,
);
await expect(store.set({ instanceId: 'x', purpose: 'p' }, 'a\nb')).rejects.toThrow(
FileSecretStoreError,
);
await expect(
store.set({ instanceId: 'x', purpose: 'p' }, 'a'.repeat(16_385)),
).rejects.toThrow(FileSecretStoreError);
await expect(store.set({ instanceId: '', purpose: 'p' }, 'v')).rejects.toThrow(
SecretReferenceError,
);
await expect(store.get('https://evil.example/one')).rejects.toThrow(FileSecretStoreError);
await expect(store.get('secret-file://other-service/abc')).rejects.toThrow(
FileSecretStoreError,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the map private (0600) and leaves no temporary files behind', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const path = join(directory, 'nested', 'secrets.json');
const store = new FileSecretStore(path);
const reference = await store.set({ instanceId: 'x', purpose: 'p', slot: 's' }, 'secret');
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600);
expect(await readdir(join(directory, 'nested'))).toEqual(['secrets.json']);
const raw = await readFile(path, 'utf8');
expect(JSON.parse(raw)).toEqual({
[reference.slice('secret-file://multi-simadmin/'.length)]: 'secret',
});
expect(await store.get(reference)).toBe('secret');
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('serializes concurrent writes so every mutation lands', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const store = new FileSecretStore(join(directory, 'secrets.json'));
const references = await Promise.all(
Array.from({ length: 25 }, (_, index) =>
store.set({ instanceId: 'x', purpose: `p${index}`, slot: 's' }, `v${index}`),
),
);
for (const [index, reference] of references.entries())
expect(await store.get(reference)).toBe(`v${index}`);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('refuses a corrupted or non-map payload instead of failing open', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const path = join(directory, 'secrets.json');
await writeFile(path, 'not json', 'utf8');
const store = new FileSecretStore(path);
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
FileSecretStoreError,
);
await writeFile(path, '{"account": 42}', 'utf8');
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
FileSecretStoreError,
);
expect(() => new FileSecretStore('relative/secrets.json')).toThrow(FileSecretStoreError);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe('secret backend selection', () => {
it('defaults to the keychain on darwin and the file store elsewhere', () => {
expect(resolveSecretBackend({}, 'darwin')).toBe('macos-keychain');
expect(resolveSecretBackend({}, 'linux')).toBe('secret-file');
expect(resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'secret-file' }, 'darwin')).toBe(
'secret-file',
);
expect(() =>
resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'vault' }, 'darwin'),
).toThrow(SecretBackendError);
});
it('builds the matching store and refuses keychain off macOS', () => {
const directory = join(tmpdir(), 'msa-backend-selection');
expect(
createDefaultSecretStore({
env: {},
secretFilePath: join(directory, 's.json'),
platform: 'linux',
}),
).toBeInstanceOf(FileSecretStore);
expect(
createDefaultSecretStore({
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
secretFilePath: join(directory, 's.json'),
platform: 'darwin',
}),
).toBeInstanceOf(MacOSKeychainSecretStore);
expect(() =>
createDefaultSecretStore({
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
secretFilePath: join(directory, 's.json'),
platform: 'linux',
}),
).toThrow(SecretBackendError);
});
it('file metadata probe accepts a missing or private file and rejects a public one', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-meta-'));
try {
const path = join(directory, 'secrets.json');
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
await writeFile(path, '{}\n', { mode: 0o600 });
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
if (process.platform !== 'win32') {
await chmod(path, 0o644);
expect(await defaultSecretFileMetadataCheck(path)).toBe(false);
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,78 @@
import { stat } from 'node:fs/promises';
import { FileSecretStore } from './file-secret-store.js';
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
import type { SecretStore } from './secret-store.js';
export type SecretBackend = 'macos-keychain' | 'secret-file';
const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file'];
export class SecretBackendError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecretBackendError';
}
}
/**
* Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override,
* otherwise the macOS Keychain on darwin and the 0600 file store elsewhere.
*/
export function resolveSecretBackend(
env: Readonly<Record<string, string | undefined>> = process.env,
platform: NodeJS.Platform = process.platform,
): SecretBackend {
const raw = env.MULTI_SIMADMIN_SECRET_BACKEND;
if (raw !== undefined) {
if (!BACKENDS.includes(raw as SecretBackend))
throw new SecretBackendError(
'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file',
);
return raw as SecretBackend;
}
return platform === 'darwin' ? 'macos-keychain' : 'secret-file';
}
export interface CreateDefaultSecretStoreOptions {
readonly env?: Readonly<Record<string, string | undefined>>;
/** Absolute path of the file store map; required for the secret-file backend. */
readonly secretFilePath: string;
readonly platform?: NodeJS.Platform;
}
export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore {
const platform = options.platform ?? process.platform;
const backend = resolveSecretBackend(options.env, platform);
if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath);
if (platform !== 'darwin')
throw new SecretBackendError(
'The macos-keychain backend requires /usr/bin/security (macOS only)',
);
return new MacOSKeychainSecretStore();
}
interface SecretFileMetadata {
isFile(): boolean;
readonly mode: number;
}
/**
* Readiness metadata probe for the file backend. Availability only — this must
* never read secret material.
*/
export async function defaultSecretFileMetadataCheck(
filePath: string,
platform: NodeJS.Platform = process.platform,
statFn: (path: string) => Promise<SecretFileMetadata> = stat,
): Promise<boolean> {
try {
const info = await statFn(filePath);
if (!info.isFile()) return false;
// Windows keeps no POSIX mode bits; NTFS ACLs govern access there.
if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false;
return true;
} catch (error) {
// A missing map is healthy: secrets are added lazily by instance setup.
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
}
@@ -0,0 +1,104 @@
import type { SecretKey } from './secret-store.js';
export const SECRET_SERVICE = 'multi-simadmin';
export type SecretProvider = 'macos-keychain' | 'secret-file';
export const SECRET_PROVIDERS: readonly SecretProvider[] = ['macos-keychain', 'secret-file'];
const safeComponent = /^[A-Za-z0-9_.-]+$/;
export type SecretReferenceErrorCode = 'INVALID_KEY' | 'INVALID_REFERENCE';
export function parseSecretAccount(
account: string,
provider: SecretProvider,
): ParsedSecretReference {
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
} catch {
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
}
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
const [instanceId, purpose, slot] = decoded;
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
validateKey(key);
if (accountFor(key) !== account)
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
return {
provider,
instanceId: key.instanceId,
purpose: key.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }),
};
}
export function validateKey(key: SecretKey): void {
if (
typeof key.instanceId !== 'string' ||
key.instanceId.length === 0 ||
key.instanceId.length > 128 ||
!safeComponent.test(key.instanceId)
) {
throw new SecretReferenceError('INVALID_KEY', 'Secret key instance id is invalid');
}
if (
typeof key.purpose !== 'string' ||
key.purpose.length === 0 ||
key.purpose.length > 64 ||
!safeComponent.test(key.purpose)
) {
throw new SecretReferenceError('INVALID_KEY', 'Secret key purpose is invalid');
}
if (
key.slot !== undefined &&
(typeof key.slot !== 'string' ||
key.slot.length === 0 ||
key.slot.length > 64 ||
!safeComponent.test(key.slot))
) {
throw new SecretReferenceError('INVALID_KEY', 'Secret key slot is invalid');
}
}
export function accountFor(key: SecretKey): string {
validateKey(key);
const components =
key.slot === undefined
? [key.instanceId, key.purpose]
: [key.instanceId, key.purpose, key.slot];
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
}
export interface ParsedSecretReference {
readonly provider: SecretProvider;
readonly instanceId: string;
readonly purpose: string;
readonly slot?: string;
}
export class SecretReferenceError extends Error {
constructor(
readonly code: SecretReferenceErrorCode,
message: string,
) {
super(message);
this.name = 'SecretReferenceError';
}
}
/** Accepts `keychain://multi-simadmin/<account>` and `secret-file://multi-simadmin/<account>`. */
export function parseSecretReference(reference: string): ParsedSecretReference {
if (typeof reference !== 'string' || reference.length > 512)
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
const match = /^([a-z-]+):\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1] || !match[2])
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
const scheme = match[1];
const provider =
scheme === 'keychain' ? 'macos-keychain' : scheme === 'secret-file' ? 'secret-file' : undefined;
if (!provider) throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
return parseSecretAccount(match[2], provider);
}
@@ -5,6 +5,8 @@ export interface SecretKey {
}
export interface SecretStore {
/** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */
readonly provider: string;
set(key: SecretKey, value: string): Promise<string>;
get(reference: string): Promise<string | undefined>;
delete(reference: string): Promise<boolean>;
@@ -9,6 +9,7 @@ import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret
import { registerAutomationRoutes } from './automation-routes.js';
class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>();
async set(key: SecretKey, value: string): Promise<string> {
const reference = `memory://${key.instanceId}/${key.purpose}`;
@@ -8,6 +8,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerCentralNotificationRoutes } from './central-notification-routes.js';
class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string }, value: string) {
@@ -49,6 +49,49 @@ afterEach(async () => {
for (const db of dbs.splice(0)) if (db.open) db.close();
});
async function seedLegacyHash(db: Database.Database, password: string): Promise<void> {
const { scrypt } = await import('node:crypto');
const salt = 'a'.repeat(32);
const hash = await new Promise<Buffer>((resolve, reject) =>
scrypt(password, Buffer.from(salt, 'hex'), 32, { N: 16_384, r: 8, p: 1 }, (error, key) =>
error ? reject(error) : resolve(key),
),
);
db.prepare(
`INSERT INTO console_auth_config
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
VALUES (1,1,?,?,1,NULL,?)`,
).run(salt, hash.toString('hex'), new Date().toISOString());
}
describe('console password derivation upgrade', () => {
it('verifies a legacy N=16384 hash and transparently rehashes with current params', async () => {
const { app, db, auth } = fixture();
await seedLegacyHash(db, PASSWORD);
const before = db
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
.get() as { password_hash: string; password_kdf: string | null };
expect(before.password_kdf).toBeNull();
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { password: PASSWORD },
});
expect(login.statusCode).toBe(204);
const after = db
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
.get() as { password_hash: string; password_kdf: string | null };
expect(after.password_kdf).toBe('scrypt:65536:8:1');
expect(after.password_hash).not.toBe(before.password_hash);
// The upgraded hash still verifies with a plain login through the service.
await expect(auth.login(PASSWORD)).resolves.toBeTruthy();
});
});
describe('aggregate-console password protection HTTP boundary', () => {
it('starts migration-safe as disabled and never returns password material', async () => {
const { app } = fixture();
@@ -33,7 +33,8 @@ const cookie = (token: string, secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
const clearCookie = (secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`;
const usesSecureCookie = (request: FastifyRequest): boolean => request.protocol === 'https';
const usesSecureCookie = (request: FastifyRequest): boolean =>
request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https';
const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
type: 'about:blank',
@@ -83,8 +84,26 @@ const loginSchema = {
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
} as const;
const LOGIN_BUCKETS_MAX = 10_000;
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const rememberAttempt = (key: string, attempt: { count: number; resetAt: number }): void => {
// Direct-to-API callers can send arbitrary client-ip values; cap the map so
// bucket keys cannot grow without bound.
if (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
const now = Date.now();
for (const [existing, value] of loginAttempts) {
if (value.resetAt <= now) loginAttempts.delete(existing);
}
while (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
const oldest = loginAttempts.keys().next().value;
if (oldest === undefined) break;
loginAttempts.delete(oldest);
}
}
loginAttempts.set(key, attempt);
};
app.addHook('preHandler', async (request, reply) => {
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
if (!auth.isProtectedPath(pathname)) return;
@@ -121,7 +140,7 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
attempts && attempts.resetAt > now
? attempts
: { count: 0, resetAt: now + LOGIN_WINDOW_MS };
loginAttempts.set(key, { count: active.count + 1, resetAt: active.resetAt });
rememberAttempt(key, { count: active.count + 1, resetAt: active.resetAt });
return handle(request, reply, error);
}
});
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import { buildApp } from '../../app.js';
import {
InstanceSessionStore,
@@ -21,6 +22,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerInstanceRoutes } from './instance-routes.js';
class FakeStore implements SecretStore {
readonly provider = 'macos-keychain';
async set(key: { instanceId: string; purpose: string; slot?: string }) {
return `keychain://multi-simadmin/${Buffer.from(
JSON.stringify([key.instanceId, key.purpose, key.slot]),
@@ -77,13 +79,36 @@ const fixture = () => {
idFactory: () => `operation-${++id}`,
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
});
const events: Array<Record<string, unknown>> = [];
const onEvent = (envelope: EventEnvelope): void => {
events.push(envelope as unknown as Record<string, unknown>);
};
const app = buildApp({
registerRoutes: (scope) =>
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
registerInstanceRoutes(scope, { instances, connections, login, deletion, onEvent }),
});
return { app, instances };
return { app, instances, events };
};
describe('instance route SSE events', () => {
it('emits an instance envelope when a node is created', async () => {
const { app, events } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
password: { action: 'set', password: 'never-return-this' },
},
});
expect(created.statusCode).toBe(201);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ kind: 'instance', requestId: expect.any(String) });
expect(String(events[0]?.instanceId)).toBeTruthy();
});
});
describe('instance HTTP routes', () => {
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
const { app } = fixture();
@@ -201,6 +226,62 @@ describe('instance HTTP routes', () => {
await app.close();
});
it('accepts the bodyless confirmation DELETE the console sends, and rejects a JSON claim', async () => {
// Regression: the console used to send Content-Type: application/json on this bodyless
// request. Fastify then rejected the empty body with 400 HTTP_400 before the route ran, so
// "delete node" silently did nothing. Deletion carries its intent in headers, so a request
// that merely *claims* a JSON body must fail loudly while the real shape must reach the route.
const { app } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
});
const id = (created.json() as { id: string }).id;
const preparation = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'deleteInstance',
targets: [{ instanceId: id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
});
const prepared = preparation.json() as { id: string; confirmationToken: string };
const confirmationHeaders = {
'if-match': '"rev-1"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
};
// The old broken shape: an empty body that still declares itself as JSON.
const claimedJsonBody = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${id}`,
headers: { ...confirmationHeaders, 'content-type': 'application/json' },
});
expect(claimedJsonBody.statusCode).toBe(400);
expect(claimedJsonBody.json()).toMatchObject({ code: 'HTTP_400' });
// The node must survive the rejected attempt.
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
200,
);
// The real console shape: headers only, no Content-Type, no body.
const accepted = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${id}`,
headers: confirmationHeaders,
});
expect(accepted.statusCode).toBe(202);
expect(accepted.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
404,
);
await app.close();
});
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
const { app } = fixture();
const invalid = await app.inject({
+29 -1
View File
@@ -20,6 +20,8 @@ import {
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import { randomUUID } from 'node:crypto';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import {
MessageServiceError,
type InstanceMessageService,
@@ -33,6 +35,8 @@ export interface InstanceRoutesOptions {
readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean;
/** Receives instance lifecycle envelopes so the SSE journal can invalidate fleet views. */
readonly onEvent?: (envelope: EventEnvelope) => void;
}
const problem = (
request: FastifyRequest,
@@ -56,6 +60,25 @@ const problem = (
detail,
requestId: request.id,
});
const emitInstanceEvent = (
options: { readonly onEvent?: (envelope: EventEnvelope) => void },
instanceId: string,
requestId: string,
): void => {
if (!options.onEvent) return;
try {
options.onEvent({
kind: 'instance',
id: randomUUID(),
occurredAt: new Date().toISOString(),
requestId,
instanceId,
});
} catch {
// A journal failure must never fail the mutation itself.
}
};
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
const status = instanceErrorStatus[error.code];
return problem(
@@ -344,6 +367,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
async (request, reply) => {
try {
const created = await options.instances.create(bodyInput(request.body));
emitInstanceEvent(options, created.id, request.id);
return reply.header('ETag', etag(created)).code(201).send(created);
} catch (error) {
if (error instanceof InstanceServiceError)
@@ -370,7 +394,10 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
app.get(
'/api/v1/instances/:instanceId/resources',
wrap(async (request) =>
options.resources!.get((request.params as { instanceId: string }).instanceId),
// A single selected device is the one place a live probe is expected.
options.resources!.get((request.params as { instanceId: string }).instanceId, {
force: true,
}),
),
);
if (options.messages) {
@@ -431,6 +458,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
revision(request),
bodyPatch(request.body),
);
emitInstanceEvent(options, (request.params as { instanceId: string }).instanceId, request.id);
reply.header('ETag', etag(result));
return result;
}),
@@ -23,6 +23,7 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
databasePath: join(directory, 'db.sqlite'),
gatewayToken,
store: {
provider: 'macos-keychain',
set: async () => '',
get: async () => undefined,
delete: async () => false,
+19 -3
View File
@@ -10,7 +10,11 @@ import {
} from './application/system/console-update-service.js';
import { openDatabase } from './infrastructure/database/database.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
import {
createDefaultSecretStore,
resolveSecretBackend,
defaultSecretFileMetadataCheck,
} from './infrastructure/secrets/secret-backend.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
@@ -21,6 +25,7 @@ export interface ProductionControlPlaneOptions {
readonly upstream?: SafeControlPlaneUpstream;
readonly gatewayToken?: string;
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly runtimeVersion?: string;
readonly recoveryMarkersCheck?: () => number | Promise<number>;
@@ -44,8 +49,10 @@ export function buildProductionControlPlane(
const db = openDatabase(options.databasePath);
try {
migrateDatabase(db);
const store = options.store ?? new MacOSKeychainSecretStore();
const env = options.env ?? process.env;
const secretFilePath = join(dirname(options.databasePath), 'secrets.json');
const store = options.store ?? createDefaultSecretStore({ env, secretFilePath });
const secretBackend = resolveSecretBackend(env);
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
const app = buildControlPlaneApp({
@@ -71,7 +78,16 @@ export function buildProductionControlPlane(
readiness: createProductionReadiness({
db,
gatewayToken: options.gatewayToken ?? '',
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
...(secretBackend === 'macos-keychain'
? {
keychainMetadataCheck:
options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
}
: {
secretFileMetadataCheck:
options.secretFileMetadataCheck ??
(async () => defaultSecretFileMetadataCheck(secretFilePath)),
}),
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
...(options.recoveryMarkersCheck
? { recoveryMarkersCheck: options.recoveryMarkersCheck }
+9 -7
View File
@@ -45,9 +45,9 @@ async function fixture(name = 'cutover'): Promise<CutoverPlan> {
drillEvidence: drill,
drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'),
legacyPid: 41,
legacyCommand: '/usr/local/bin/node /absolute/legacy/server.js',
legacyCommand: `${process.execPath} /absolute/legacy/server.js`,
legacyIdentity: 'legacy-start-1',
legacyStart: ['/usr/local/bin/node', '/absolute/legacy/server.js'],
legacyStart: [process.execPath, '/absolute/legacy/server.js'],
stateDir: join(root, 'state'),
};
}
@@ -130,11 +130,11 @@ describe('cutover validation and reversible orchestration', () => {
expect(signals).toEqual([41]);
expect(starts[0]?.environment?.MULTI_SIMADMIN_GATEWAY_TOKEN).toBe(token);
const statePath = join(plan.stateDir, 'cutover-state.json');
expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
if (process.platform !== 'win32') expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
expect(await readFile(statePath, 'utf8')).not.toContain(token);
expect(await rollback(plan, system)).toBe(43);
expect(signals).toEqual([41, 42]);
expect(starts[1]?.argv).toEqual(['/usr/local/bin/node', '/absolute/legacy/server.js']);
expect(starts[1]?.argv).toEqual([process.execPath, '/absolute/legacy/server.js']);
});
it('automatically restarts legacy if the gateway fails to own authenticated 8788', async () => {
@@ -146,10 +146,10 @@ describe('cutover validation and reversible orchestration', () => {
[
process.execPath,
'--import',
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
'tsx',
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
],
['/usr/local/bin/node', '/absolute/legacy/server.js'],
[process.execPath, '/absolute/legacy/server.js'],
]);
const state = JSON.parse(await readFile(join(plan.stateDir, 'cutover-state.json'), 'utf8')) as {
phase: string;
@@ -176,11 +176,13 @@ describe('cutover validation and reversible orchestration', () => {
const first = fakeSystem(plan);
await cutover(plan, first.system, token);
const statePath = join(plan.stateDir, 'cutover-state.json');
// Windows chmod is a no-op, so the mode-0600 refusal only applies on POSIX.
if (process.platform !== 'win32') {
await chmod(statePath, 0o644);
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
expect(first.signals).toEqual([41]);
await chmod(statePath, 0o600);
}
const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>;
await writeFile(
statePath,
+15 -4
View File
@@ -7,7 +7,9 @@ export interface ProductionReadinessOptions {
readonly db: Database.Database;
readonly gatewayToken: string;
/** Metadata/availability probe only. This callback must never retrieve a secret. */
readonly keychainMetadataCheck: () => boolean | Promise<boolean>;
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
/** Metadata/availability probe for the file secret backend (never reads secrets). */
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
/** Release gates are deliberately required: omitting one fails readiness closed. */
readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly recoveryMarkersCheck?: () => number | Promise<number>;
@@ -56,11 +58,20 @@ export function createProductionReadiness(
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
if (checks.gatewayAuth !== 'ready') ready = false;
try {
if (options.keychainMetadataCheck !== undefined) {
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
} catch {
checks.keychain = 'unavailable';
}
if (checks.keychain !== 'ready') ready = false;
} else if (options.secretFileMetadataCheck !== undefined) {
checks.secretFile = (await options.secretFileMetadataCheck()) ? 'ready' : 'unavailable';
if (checks.secretFile !== 'ready') ready = false;
} else {
checks.secrets = 'unconfigured';
ready = false;
}
} catch {
checks.secrets = 'unavailable';
ready = false;
}
try {
const row = options.db
+3
View File
@@ -254,12 +254,15 @@ export async function appendReleaseEvidence(
await handle.close();
}
await rename(temporary, absolute);
if (process.platform !== 'win32') {
// directory fsync only exists on POSIX
const directory = await open(parent, 'r');
try {
await directory.sync();
} finally {
await directory.close();
}
}
return Object.freeze(record);
} finally {
await lock.close();
+1
View File
@@ -0,0 +1 @@
.build/
+31
View File
@@ -0,0 +1,31 @@
<!-- 设计参考稿,构图与 Design/generate_app_icon.swift 一致;构建实际用 Swift 脚本光栅化 1024 PNG。 -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="field" x1="0.06" y1="0" x2="0.94" y2="1">
<stop offset="0" stop-color="#202020"/>
<stop offset="0.55" stop-color="#131313"/>
<stop offset="1" stop-color="#0A0A0A"/>
</linearGradient>
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FCE5AA"/>
<stop offset="0.46" stop-color="#F0C76F"/>
<stop offset="1" stop-color="#D39B3D"/>
</linearGradient>
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#101010"/>
<stop offset="1" stop-color="#202020"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#field)"/>
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
<path d="M216,403.7 Q216,372 238.4,349.6 L361.6,226.4 Q384,204 415.7,204 L686,204
Q808,204 808,326 L808,674 Q808,796 686,796 L304,796 Q216,796 216,674 Z"
fill="url(#brass)"/>
<!-- 三格插槽:底边对齐,右高,读作多卡队列健康度 -->
<rect x="324" y="504" width="92" height="136" rx="28" fill="url(#slot)"/>
<rect x="470" y="408" width="92" height="232" rx="28" fill="url(#slot)"/>
<rect x="616" y="312" width="92" height="328" rx="28" fill="url(#slot)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+246
View File
@@ -0,0 +1,246 @@
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
// Usage: swift Design/generate_app_icon.swift <output.png>
//
// The mark is the web favicon grown up: a flat gold SIM plate with three
// ascending slots cut out of it, on a neutral graphite field. Two colours, one
// light source from the upper left, no metal texture, so it still reads at the
// 29pt settings size. The plate keeps the classic single SIM cut, top-left.
//
// The field is warm-neutral, not mathematically neutral. A perfectly grey R=G=B
// plate sitting next to saturated gold loses the comparison and reads cool on a
// lit home screen — that is how "there is no blue in it" still looked "blue" to
// the eye. Red runs 2..4 levels over blue instead, which is invisible as a cast
// but impossible to misread. The gold stays warm and deep so it reads metal
// rather than mustard.
//
// Every coordinate is in visual space (origin top-left, y grows down).
import AppKit
import CoreGraphics
import Foundation
let side: CGFloat = 1024
let output = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: "Design/AppIcon.png"
func envDouble(_ name: String, _ fallback: Double) -> CGFloat {
guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else {
return CGFloat(fallback)
}
return CGFloat(value)
}
func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor {
CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
}
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(
data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else {
FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!)
exit(1)
}
ctx.translateBy(x: 0, y: side)
ctx.scaleBy(x: 1, y: -1)
typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int))
func makeGradient(_ stops: [Stop]) -> CGGradient? {
var components: [CGFloat] = []
for stop in stops {
components.append(CGFloat(stop.color.0) / 255)
components.append(CGFloat(stop.color.1) / 255)
components.append(CGFloat(stop.color.2) / 255)
components.append(CGFloat(stop.color.3) / 255)
}
let locations = stops.map(\.location)
return CGGradient(
colorSpace: space,
colorComponents: components,
locations: locations,
count: stops.count
)
}
func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) {
guard let gradient = makeGradient(stops) else { return }
ctx.saveGState()
ctx.addPath(path)
ctx.clip()
ctx.drawLinearGradient(
gradient, start: start, end: end,
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
)
ctx.restoreGState()
}
func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) {
guard let gradient = makeGradient(stops) else { return }
ctx.saveGState()
ctx.drawRadialGradient(
gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius,
options: [.drawsAfterEndLocation]
)
ctx.restoreGState()
}
func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) }
func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
/// SIM plate outline: rounded square with the top-left corner cut at 45 degrees.
func simPath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
let bevel = corner * 0.26
let vertices: [(point: CGPoint, radius: CGFloat)] = [
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
(CGPoint(x: rect.maxX, y: rect.minY), corner),
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
(CGPoint(x: rect.minX, y: rect.maxY), corner),
]
let path = CGMutablePath()
path.move(to: vertices[0].point)
for index in vertices.indices {
path.addArc(
tangent1End: vertices[index].point,
tangent2End: vertices[(index + 1) % vertices.count].point,
radius: vertices[index].radius
)
}
path.closeSubpath()
return path
}
/// Bright along the upper-left edges, gone by the lower-right: an even outline
/// reads like a sticker cut rather than a plate lit from above.
func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) {
ctx.saveGState()
ctx.addPath(path)
ctx.clip()
ctx.addPath(path)
ctx.setLineWidth(width)
ctx.replacePathWithStrokedPath()
ctx.clip()
fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops,
CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY))
ctx.restoreGState()
}
// MARK: - Field
fillLinear(
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
[
// 石墨底走暖一档:R 比 B 高 4 级,肉眼看不出偏色,但再也不可能被读成冷调。
(0.00, (34, 32, 30, 255)),
(0.55, (20, 19, 18, 255)),
(1.00, (11, 10, 10, 255)),
] as [Stop],
CGPoint(x: side * 0.06, y: 0),
CGPoint(x: side * 0.94, y: side)
)
fillRadial(
CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86),
[(0.0, (232, 226, 214, 24)), (0.60, (232, 226, 214, 6)), (1.0, (232, 226, 214, 0))] as [Stop]
)
// MARK: - Plate
let plateSide = envDouble("ICON_PLATE_SIDE", 592)
let plateCenterY = envDouble("ICON_PLATE_Y", 500)
let plateCorner = envDouble("ICON_PLATE_CORNER", 122)
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 168)
let plateRect = CGRect(
x: (side - plateSide) / 2,
y: plateCenterY - plateSide / 2,
width: plateSide,
height: plateSide
)
let plate = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(2, 1, 0, 150))
ctx.addPath(plate)
ctx.setFillColor(rgb(0, 0, 0))
ctx.fillPath()
ctx.restoreGState()
fillLinear(
plate,
[
// 金色往香槟靠,收掉底部的芥末/棕调:小尺寸下这段最容易被读成"脏黄"。
(0.00, (252, 229, 170, 255)),
(0.46, (240, 199, 111, 255)),
(1.00, (211, 155, 61, 255)),
] as [Stop],
CGPoint(x: plateRect.minX, y: plateRect.minY),
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
)
directionalRim(
plate, plateRect, plateSide * 0.007,
[
(0.00, (255, 246, 224, 140)),
(0.36, (255, 240, 206, 32)),
(0.68, (255, 236, 196, 0)),
] as [Stop]
)
// MARK: - Slots
let slotWidth = envDouble("ICON_SLOT_W", 92)
let slotGap = envDouble("ICON_SLOT_GAP", 54)
let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640)
let slotHeights: [CGFloat] = [136, 232, 328]
// 说明:以上默认值等价于 plate 相对比例(宽 .155 / 间距 .091 / 底边内缩 .265),
// 改 plate 尺寸时按同一比例缩放才不会让槽和卡片脱节。
let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap
let slotsStartX = (side - slotsWidth) / 2
for (index, height) in slotHeights.enumerated() {
let rect = CGRect(
x: slotsStartX + CGFloat(index) * (slotWidth + slotGap),
y: slotBottom - height,
width: slotWidth,
height: height
)
let radius = slotWidth * 0.30
let path = roundedPath(rect, radius)
fillLinear(
path,
[
// 插槽与底同色温,避免金面板上的洞看起来发青。
(0.00, (17, 16, 14, 255)),
(1.00, (34, 32, 30, 255)),
] as [Stop],
CGPoint(x: rect.minX, y: rect.minY),
CGPoint(x: rect.minX, y: rect.maxY)
)
directionalRim(
path, rect, radius * 0.30,
[
(0.00, (120, 82, 24, 96)),
(0.52, (120, 82, 24, 16)),
(1.00, (255, 240, 202, 64)),
] as [Stop]
)
}
guard let image = ctx.makeImage() else {
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
exit(1)
}
let rep = NSBitmapImageRep(cgImage: image)
guard let data = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!)
exit(1)
}
try! data.write(to: URL(fileURLWithPath: output))
print(output)
+24
View File
@@ -0,0 +1,24 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MultiSimAdmin",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.executable(name: "MultiSimAdmin", targets: ["MultiSimAdmin"])
],
dependencies: [],
targets: [
.executableTarget(
name: "MultiSimAdmin",
exclude: ["Resources/Info.plist"],
resources: [
.copy("Resources/Assets.xcassets")
]
),
.testTarget(
name: "MultiSimAdminTests",
dependencies: ["MultiSimAdmin"]
)
]
)
+56
View File
@@ -0,0 +1,56 @@
# MultiSimAdmin iOS
iPhone 原生应用的 Swift Package。产品与技术说明见 [docs/ios-native.md](../../docs/ios-native.md),
本文件只写这个目录里怎么动手。
## 目录
```text
Sources/MultiSimAdmin/
AppMetadata.swift 版本号与 Bundle ID 的唯一来源
MultiSimAdminApp.swift 入口、三个底部 Tab、调试直达参数
Models/ 实例、标签、查询等值类型
Features/{Fleet,Device,Messages,Logs,Settings}/ 页面与 view model
Upstream/ 路由白名单、HTTP 客户端、按实例隔离的会话 Cookie
Services/ 连接探测、凭据存储、依赖装配
Persistence/ 沙盒 JSON 文件仓储与日志存储
Theme/ 颜色令牌、按钮与卡片样式、骨架、主题模式
Support/ 错误文案、时间显示、调试启动
Resources/ Info.plist、Assets.xcassets
Tests/MultiSimAdminTests/ 48 个用例
Design/ 图标生成脚本与参考稿
```
## 常用命令
```bash
swift build
swift test
xcodebuild -scheme MultiSimAdmin \
-destination 'platform=iOS Simulator,id=<UDID>' \
-configuration Debug build
swift Design/generate_app_icon.swift \
Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
```
未签名 IPA 从仓库根目录执行 `./scripts/build-ios-ipa.sh` 生成。
## 发版要改的地方
版本号必须四处一致,否则测试会失败:
1. `Sources/MultiSimAdmin/Resources/Info.plist` 的 `CFBundleShortVersionString` 与 `CFBundleVersion`
2. `Sources/MultiSimAdmin/AppMetadata.swift`
3. `Tests/MultiSimAdminTests/AppMetadataTests.swift`
4. `Tests/MultiSimAdminTests/SettingsViewModelTests.swift`
改完补一份 `docs/ios-native-<version>.md` 发布说明,并同步更新 `docs/ios-native.md`、仓库根
README 的版本引用。
## 界面约定
界面不含任何蓝色:主操作、选中态、进度填充一律用石墨墨色(`AITheme.primary` / `AITheme.ink`),
颜色只留给状态语义——危险红、告警琥珀、成功绿,且只在真的表示状态时出现,健康读数保持墨色。
选中态用实心主色加 `onPrimary` 文字,不用低透明度色雾,浅色深色两套主题下都必须一眼可读;
同屏并排控件等高;选中态不得改变控件尺寸;标签统一 28pt 胶囊;卡片内部不再嵌套卡片,
并列读数改用发丝线分隔的栅格;不得出现因配色而在深色背景上消失的按钮或文字。
@@ -0,0 +1,6 @@
public enum AppMetadata {
public static let name = "MultiSimAdmin"
public static let bundleIdentifier = "fun.chick.multi-simadmin"
public static let marketingVersion = "0.2.2"
public static let buildVersion = "10"
}
@@ -0,0 +1,118 @@
import Foundation
public struct DeviceSection: Equatable, Identifiable, Sendable {
public let id: String
public let title: String
public let values: [String: String]
}
public struct DeviceSnapshot: Equatable, Sendable {
public let sections: [DeviceSection]
}
public struct DeviceLinkState: Equatable, Sendable {
public init(
hardwareOnline: Bool? = nil,
controlOnline: Bool? = nil,
simPresent: Bool? = nil,
cellularOnline: Bool? = nil
) {
self.hardwareOnline = hardwareOnline
self.controlOnline = controlOnline
self.simPresent = simPresent
self.cellularOnline = cellularOnline
}
public var hardwareOnline: Bool?
public var controlOnline: Bool?
public var simPresent: Bool?
public var cellularOnline: Bool?
}
extension DeviceService {
static var placeholderSections: [DeviceSection] {
[
DeviceSection(id: "device", title: "设备", values: [:]),
DeviceSection(id: "sim", title: "SIM", values: [:]),
DeviceSection(id: "network", title: "蜂窝网络", values: [:]),
DeviceSection(id: "stats", title: "系统资源", values: [:]),
DeviceSection(id: "cpu", title: "CPU", values: [:]),
DeviceSection(id: "connectivity", title: "连接状态", values: [:])
]
}
}
public struct DeviceSummary: Equatable, Sendable {
public init(
deviceName: String? = nil,
phoneNumber: String? = nil,
operatorName: String? = nil,
networkTechnology: String? = nil,
signalPercent: Int? = nil,
cpuPercent: Double? = nil,
memoryPercent: Double? = nil,
cpuCount: Int? = nil,
temperatureCelsius: Double? = nil,
batteryPercent: Double? = nil,
ipAddress: String? = nil,
latencyMilliseconds: Int? = nil,
connectivity: String? = nil,
firmwareVersion: String? = nil,
totalStorageBytes: Int64? = nil,
availableStorageBytes: Int64? = nil,
downloadRateBytesPerSecond: Double? = nil,
uploadRateBytesPerSecond: Double? = nil,
messagesToday: Int? = nil,
uptimeSeconds: Double? = nil,
linkState: DeviceLinkState = DeviceLinkState()
) {
self.deviceName = deviceName
self.phoneNumber = phoneNumber
self.operatorName = operatorName
self.networkTechnology = networkTechnology
self.signalPercent = signalPercent
self.cpuPercent = cpuPercent
self.memoryPercent = memoryPercent
self.cpuCount = cpuCount
self.temperatureCelsius = temperatureCelsius
self.batteryPercent = batteryPercent
self.ipAddress = ipAddress
self.latencyMilliseconds = latencyMilliseconds
self.connectivity = connectivity
self.firmwareVersion = firmwareVersion
self.totalStorageBytes = totalStorageBytes
self.availableStorageBytes = availableStorageBytes
self.downloadRateBytesPerSecond = downloadRateBytesPerSecond
self.uploadRateBytesPerSecond = uploadRateBytesPerSecond
self.messagesToday = messagesToday
self.uptimeSeconds = uptimeSeconds
self.linkState = linkState
}
public var deviceName: String?
public var phoneNumber: String?
public var operatorName: String?
public var networkTechnology: String?
public var signalPercent: Int?
public var cpuPercent: Double?
public var memoryPercent: Double?
public var cpuCount: Int?
public var temperatureCelsius: Double?
public var batteryPercent: Double?
public var ipAddress: String?
public var latencyMilliseconds: Int?
public var connectivity: String?
public var firmwareVersion: String?
public var totalStorageBytes: Int64?
public var availableStorageBytes: Int64?
public var downloadRateBytesPerSecond: Double?
public var uploadRateBytesPerSecond: Double?
public var messagesToday: Int?
public var uptimeSeconds: Double?
public var linkState: DeviceLinkState
}
public enum DeviceServiceError: Error, Equatable {
case unauthorized
case invalidResponse
}
@@ -0,0 +1,223 @@
import Foundation
public struct DeviceService: Sendable {
private let transport: any NodeTransport
public init(transport: any NodeTransport) {
self.transport = transport
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
}
public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot {
let routes: [(id: String, title: String, route: UpstreamRoute)] = [
("device", "设备", .device),
("sim", "SIM", .sim),
("network", "蜂窝网络", .network),
("stats", "系统资源", .stats),
("cpu", "CPU", .statsCpu),
("connectivity", "连接状态", .connectivity)
]
var sections: [DeviceSection] = []
var successful = 0
var unauthorized = false
for item in routes {
do {
let response = try await request(item.route, instance)
let values = try Self.flatten(Self.dataObject(response.data))
sections.append(DeviceSection(id: item.id, title: item.title, values: values))
successful += 1
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
unauthorized = true
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
} catch {
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
}
}
if successful == 0 {
throw unauthorized ? DeviceServiceError.unauthorized : DeviceServiceError.invalidResponse
}
return DeviceSnapshot(sections: sections)
}
public func summary(_ instance: Instance) async throws -> DeviceSummary {
let routes: [(id: String, route: UpstreamRoute)] = [
("device", .device),
("sim", .sim),
("network", .network),
("stats", .stats),
("cpu", .statsCpu),
("connectivity", .connectivity)
]
var sections: [(String, [String: String])] = []
var successful = 0
for item in routes {
do {
let response = try await request(item.route, instance)
let values = try Self.flatten(Self.dataObject(response.data))
sections.append((item.id, values))
successful += 1
} catch {
continue
}
}
guard successful > 0 else {
throw DeviceServiceError.invalidResponse
}
return Self.summary(from: sections)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
static func dataObject(_ data: Data) throws -> [String: Any] {
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw DeviceServiceError.invalidResponse
}
if let payload = root["data"] as? [String: Any] { return payload }
if let response = root["response"] as? [String: Any],
let payload = response["data"] as? [String: Any] {
return payload
}
return root
}
static func summary(from sections: [(String, [String: String])]) -> DeviceSummary {
func key(_ sectionID: String, _ names: [String]) -> String? {
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
}
func number(_ text: String?) -> Double? {
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
return Double(text)
}
func bool(_ text: String?) -> Bool? {
switch text?.lowercased() {
case "是", "true", "yes", "1": return true
case "否", "false", "no", "0": return false
default: return nil
}
}
let linkState = DeviceLinkState(
hardwareOnline: bool(key("device", ["powered"])),
controlOnline: bool(key("device", ["online"])),
simPresent: sections.contains { $0.0 == "sim" },
cellularOnline: key("network", ["registration_status"])?
.lowercased()
.hasPrefix("registered")
)
return DeviceSummary(
deviceName: key("device", ["model", "name", "device"]),
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
operatorName: key("network", ["operator_name", "operator", "carrier"]),
networkTechnology: key(
"network",
["network_type", "technology_preference", "technology", "radio_mode"]
),
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
.flatMap { Int($0.rounded()) },
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
Int($0.rounded())
},
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
firmwareVersion: key("device", ["revision", "firmware_version"]),
totalStorageBytes: Self.byteCount(
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
),
availableStorageBytes: Self.byteCount(
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
),
downloadRateBytesPerSecond: Self.byteRate(
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
),
uploadRateBytesPerSecond: Self.byteRate(
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
),
messagesToday: number(
key("connectivity", ["messages_today", "messages", "sms_today"])
).flatMap { Int($0.rounded()) },
uptimeSeconds: number(
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
) ?? number(key("stats", ["uptime_uptime_seconds"]))
?? number(key("stats", ["system_uptime_seconds"])),
linkState: linkState
)
}
private static func byteCount(_ text: String?) -> Int64? {
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
return nil
}
return Int64(value.rounded())
}
private static func byteRate(_ text: String?) -> Double? {
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
return nil
}
return value
}
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
var output: [String: String] = [:]
flatten(source, prefix: "", into: &output, remaining: maximum)
return output
}
private static func flatten(
_ source: [String: Any],
prefix: String,
into output: inout [String: String],
remaining: Int
) {
for (key, value) in source.sorted(by: { $0.key < $1.key }) {
guard output.count < remaining else { return }
let fullKey = prefix.isEmpty ? key : "\(prefix)_\(key)"
switch value {
case let text as String:
output[fullKey] = text
case let flag as Bool:
output[fullKey] = flag ? "是" : "否"
case let number as Int:
output[fullKey] = String(number)
case let number as Double:
output[fullKey] = String(format: "%.1f", number)
case let array as [Any]:
let text = array.compactMap { entry -> String? in
if let record = entry as? [String: Any] {
let preferred = ["name", "zone", "mount_point", "interface", "model_name"]
.compactMap { record[$0] as? String }
.first
if let preferred { return preferred }
}
return entry as? String
}.joined(separator: "、")
output[fullKey] = text
case let nested as [String: Any]:
flatten(nested, prefix: fullKey, into: &output, remaining: remaining - output.count)
default:
break
}
}
}
}
@@ -0,0 +1,41 @@
import Foundation
@MainActor
final class DeviceViewModel: ObservableObject {
@Published var snapshot: DeviceSnapshot?
@Published var isLoading = false
@Published var errorMessage: String?
@Published private(set) var connectionStatus: ConnectionStatus = .unknown
let instance: Instance
private let service: DeviceService
private let probe: ConnectionProbe?
init(instance: Instance, service: DeviceService, probe: ConnectionProbe? = nil) {
self.instance = instance
self.service = service
self.probe = probe
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
snapshot = try await service.snapshot(instance)
} catch DeviceServiceError.unauthorized, UpstreamError.unauthorized {
errorMessage = "需要实例密码登录后才能读取设备数据。"
} catch let error as UpstreamError {
errorMessage = ErrorMessage.describe(error, nodeName: instance.name)
} catch {
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
}
guard let probe else {
connectionStatus = .offline
isLoading = false
return
}
let result = try? await probe.test(instance)
connectionStatus = result?.status ?? .offline
isLoading = false
}
}
@@ -0,0 +1,990 @@
import SwiftUI
struct InstanceDetailView: View {
@StateObject private var model: DeviceViewModel
@State private var revealsSensitive = false
@State private var showsSensitiveInfo = false
init(
instance: Instance,
model: DeviceViewModel? = nil
) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
}
var body: some View {
ScrollView {
VStack(spacing: 12) {
if isUnavailable {
unavailableCard
} else {
headerCard
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
}
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle(model.instance.name)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $showsSensitiveInfo) {
SensitiveValueSheet(
values: sensitiveValues,
isRevealed: $revealsSensitive
)
}
}
// 读取失败且从未拿到数据时,整屏骨架没有意义,改成一屏可读的失败卡片。
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
private var unavailableCard: some View {
VStack(spacing: 12) {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "wifi.slash")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AITheme.danger)
.frame(width: 38, height: 38)
.background(AITheme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
Text("节点无法连接")
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Spacer(minLength: 8)
statusChip(title: "不可达", tint: AITheme.danger)
}
Text(model.errorMessage ?? "无法加载设备数据。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
Button {
Task { await model.refresh() }
} label: {
Label("重新检测", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
}
}
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
AIKeyValueRow("地址", value: model.instance.endpoint.displayAddress, monospaced: true)
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? model.instance.endpoint.credentialDescription : "未设置")
}
}
// 配置是否丢失不是用户需要被告知的事,卡片本身已经说明了一切。
}
}
private var headerCard: some View {
AICard(padding: 16, emphasis: true) {
VStack(alignment: .leading, spacing: 12) {
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
HStack(spacing: 8) {
Image(systemName: "globe")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
Text(model.instance.endpoint.displayAddress)
.font(.footnote.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
statusChip()
}
DetailMetricGrid(metrics: gaugeMetrics)
DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading)
moduleAvailability
// 与节点卡片同一枚标签形状(同一胶囊、同一图标),两处只有一处能点。
if model.instance.tags.isEmpty == false {
FlowLayout(spacing: 7) {
ForEach(model.instance.tags, id: \.self) { tag in
AITag(tag, systemImage: "tag")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private func statusChip(title: String? = nil, tint overrideTint: Color? = nil) -> some View {
let loading = model.isLoading && model.snapshot == nil
let tint = overrideTint ?? (loading ? AITheme.muted : statusColor(model.connectionStatus))
let label = title ?? (loading ? "检测中" : model.connectionStatus.label)
return HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: 7, height: 7)
.accessibilityHidden(true)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(tint.opacity(0.12))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityLabel("连接状态 \(label)")
}
/// 模块可用性压成一行读数:六个胶囊和下方分栏标题一一对应,属于纯重复。
/// 只有真的读不到时才展开,直接点名是哪几个模块。
private var moduleAvailability: some View {
let unavailable = moduleSections.filter { $0.values.isEmpty }.map(\.title)
return VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Text("数据模块")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
Spacer()
if let available = availableCount {
Image(systemName: available == moduleSections.count
? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
.accessibilityHidden(true)
Text("\(available)/\(moduleSections.count) 可用")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.foregroundStyle(available == moduleSections.count
? AITheme.success : AITheme.warning)
} else {
Text("读取中")
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
}
}
if unavailable.isEmpty == false {
Text("未返回:" + unavailable.joined(separator: " · "))
.font(.caption2)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private struct HeaderMetric: Identifiable {
let title: String
let value: String?
let systemImage: String
let tint: Color?
let progress: Double?
var id: String { title }
// 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
}
private var allHeaderMetrics: [HeaderMetric] {
let loading = model.isLoading
let phoneValue = stringValue(
section: "sim",
keys: ["phone_numbers", "phone_number"]
)
let storageValue = storageSummary
let uptimeValue = stringValue(
section: "stats",
keys: ["uptime_seconds", "uptime", "system_uptime_seconds"]
).flatMap(durationValue)
let signal = summaryValue(
section: "network",
keys: ["signal_percent", "signal_strength"],
format: loading ? nil : { "\(Int($0.rounded()))%" },
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { signalTint($0) }
)
let latency = summaryValue(
section: "connectivity",
keys: ["latency_ms", "latency"],
format: loading ? nil : { "\(Int($0.rounded())) ms" },
// 进度条读的是"链路质量",和节点卡片同一把尺子(1000ms 归零),
// 之前恒为 1,满格黑条压在 38ms 上会被读成"延迟占满了什么"。
progress: loading ? nil : { max(0.04, min(1, 1 - $0 / 1000)) },
tint: loading ? nil : { latencyTint($0) }
)
let cpu = summaryValue(
section: "cpu",
keys: ["cpu_percent", "cpu_load", "usage"],
format: loading ? nil : { "\(Int($0.rounded()))%" },
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { resourceTint($0) }
)
let memory = summaryValue(
section: "stats",
keys: ["memory_percent", "usage"],
format: loading ? nil : { "\(Int($0.rounded()))%" },
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
tint: loading ? nil : { resourceTint($0) }
)
return [
makeMetric(
title: "号码",
value: loading ? nil : phoneValue ?? "无数据",
systemImage: "simcard",
tint: loading ? nil : AITheme.ink
),
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
makeMetric(
title: "CPU",
value: loading ? nil : cpu?.value ?? "无数据",
systemImage: "cpu",
tint: loading ? nil : cpu?.tint ?? AITheme.muted,
progress: loading ? nil : cpu?.progress
),
makeMetric(
title: "内存",
value: loading ? nil : memory?.value ?? "无数据",
systemImage: "memorychip",
tint: loading ? nil : memory?.tint ?? AITheme.muted,
progress: loading ? nil : memory?.progress
),
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
]
}
// 头部只保留四块带进度语义的量表,其余信息压缩成文本行。
private static let gaugeOrder: [String] = ["信号", "延迟", "CPU", "内存"]
private var gaugeMetrics: [HeaderMetric] {
let metrics = allHeaderMetrics
return Self.gaugeOrder.compactMap { title in
metrics.first { $0.title == title }
}
}
private var identitySummary: [(label: String, value: String)] {
let metrics = allHeaderMetrics
func derived(_ title: String) -> String? {
metrics.first { $0.title == title }?.value
}
// 顶部只留"这是哪张卡、连在哪个网上"四件事;
// 设备型号、固件、存储、运行时间属于硬件清单,下方分栏里已经逐条列出。
let pairs: [(String, String?)] = [
("号码", derived("号码")),
("运营商", stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])),
("网络", stringValue(
section: "network",
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
)),
("IP", stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"]))
]
return pairs.compactMap { label, value in
guard let value, value.isEmpty == false, value != "无数据" else { return nil }
return (label, value)
}
}
private func makeMetric(
title: String,
value: String?,
systemImage: String,
tint: Color?,
progress: Double? = nil
) -> HeaderMetric {
HeaderMetric(title: title, value: value, systemImage: systemImage, tint: tint, progress: progress)
}
private var storageSummary: String {
guard let total = numericValue(section: "stats", keys: [
"disk_total_bytes", "storage_total_bytes", "total_bytes"
]), let available = numericValue(section: "stats", keys: [
"disk_available_bytes", "storage_available_bytes", "available_bytes"
]), total > 0, available >= 0, total >= available else {
return "无数据"
}
return "\(Int((Double(total - available) / Double(total) * 100).rounded()))% 已用"
}
private var storageTint: Color {
if storageRatio == nil { return AITheme.ink }
return resourceTint(storageRatio! * 100)
}
private var storageProgress: Double {
storageRatio ?? 0
}
private var storageRatio: Double? {
guard let total = numericValue(section: "stats", keys: [
"disk_total_bytes", "storage_total_bytes", "total_bytes"
]), let available = numericValue(section: "stats", keys: [
"disk_available_bytes", "storage_available_bytes", "available_bytes"
]), total > 0, available >= 0, total >= available else {
return nil
}
return Double(total - available) / Double(total)
}
private func numericValue(
section: String,
keys: [String]
) -> Int64? {
stringValue(section: section, keys: keys)
.flatMap { Double($0.replacingOccurrences(of: "%", with: "")) }
.flatMap { Int64($0.rounded()) }
}
private func durationValue(_ text: String) -> String? {
guard let seconds = Double(text.replacingOccurrences(of: "%", with: "")), seconds >= 0 else {
return nil
}
return Duration.seconds(seconds).formatted(
.units(allowed: [.days, .hours, .minutes], width: .abbreviated)
)
}
private func statusColor(_ value: ConnectionStatus) -> Color {
switch value {
case .online: return AITheme.success
case .degraded, .authRequired: return AITheme.warning
case .offline: return AITheme.danger
case .unknown: return AITheme.muted
}
}
private struct FormattedMetric {
let value: String
let tint: Color
let progress: Double
}
private func stringValue(
section: String,
keys: [String]
) -> String? {
guard let values = model.snapshot?.sections.first(where: { $0.id == section })?.values else {
return nil
}
return keys.lazy.compactMap { values[$0] }.first { !$0.isEmpty }
}
private func summaryValue(
section: String,
keys: [String],
format: ((Double) -> String)?,
progress: ((Double) -> Double)?,
tint: ((Double) -> Color)?
) -> FormattedMetric? {
guard let snapshot = model.snapshot,
let values = snapshot.sections.first(where: { $0.id == section })?.values
else { return nil }
let raw = keys.compactMap { values[$0] }.first { !$0.isEmpty }
guard let raw, let number = Double(raw.replacingOccurrences(of: "%", with: "")), number >= 0 else {
return nil
}
guard let format, let progress, let tint else { return nil }
return FormattedMetric(
value: format(number),
tint: tint(number),
progress: progress(number)
)
}
/// 阈值只回答"要不要提醒",健康值不着色,四格量表不会染成一片绿。
private func signalTint(_ value: Double) -> Color {
value >= 60 ? AITheme.ink : (value >= 30 ? AITheme.warning : AITheme.danger)
}
private func latencyTint(_ value: Double) -> Color {
value < 300 ? AITheme.ink : (value < 800 ? AITheme.warning : AITheme.danger)
}
private func resourceTint(_ value: Double) -> Color {
value >= 85 ? AITheme.danger : (value >= 65 ? AITheme.warning : AITheme.ink)
}
private struct DetailHeaderMetric: View {
let metric: HeaderMetric
var title: String { metric.title }
var value: String? { metric.value }
var systemImage: String { metric.systemImage }
var tint: Color { metric.tint ?? AITheme.muted }
var progress: Double { metric.progress ?? 0 }
var isProgressVisible: Bool {
metric.title == "连接状态" || metric.progress != nil
}
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(metric.isAlert ? tint : AITheme.faint)
.accessibilityHidden(true)
Text(title)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
if let value {
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(metric.isAlert ? tint : AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.minimumScaleFactor(0.72)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.55))
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
// 读数在上、进度在下,缺数据时也留一条轨道,数值到位后格子不跳动。
if isProgressVisible {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule(style: .continuous).fill(AITheme.line)
Capsule(style: .continuous)
.fill(metric.isAlert ? tint : AITheme.primary)
.frame(width: max(3, proxy.size.width * CGFloat(min(1, max(0, progress)))))
}
}
.frame(height: 4)
.accessibilityHidden(true)
} else {
Color.clear
.frame(height: 4)
}
}
.frame(maxWidth: .infinity, minHeight: 56, alignment: .topLeading)
.accessibilityElement(children: .combine)
}
}
/// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。
private struct DetailMetricGrid: View {
let metrics: [HeaderMetric]
private var rows: [[HeaderMetric]] {
stride(from: 0, to: metrics.count, by: 2).map {
Array(metrics[$0..<min($0 + 2, metrics.count)])
}
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, rowMetrics in
if rowIndex > 0 {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
HStack(alignment: .top, spacing: 0) {
ForEach(Array(rowMetrics.enumerated()), id: \.element.id) { columnIndex, metric in
DetailHeaderMetric(metric: metric)
.padding(.leading, columnIndex == 0 ? 0 : 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
if rowMetrics.count < 2 {
Color.clear
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 10)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
}
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
private struct DetailIdentityStrip: View {
let items: [(label: String, value: String)]
let isLoading: Bool
var body: some View {
if isLoading {
// 首屏还没有数据时用骨架条,避免出现一整列"读取中"。
VStack(alignment: .leading, spacing: 9) {
ForEach(0..<3, id: \.self) { _ in
HStack(spacing: 8) {
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.7))
.frame(width: 42, height: 10)
Capsule(style: .continuous)
.fill(AITheme.line.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 10)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityLabel("设备信息读取中")
} else if items.isEmpty == false {
VStack(alignment: .leading, spacing: 6) {
ForEach(items, id: \.label) { item in
// 值一律右对齐,和下方分区卡片的键值行同一节奏;
// 之前左边缩进 52pt、值顶在中间,同屏两种排法读起来像两张不同的表。
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: true, vertical: false)
Spacer(minLength: 12)
Text(item.value)
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.multilineTextAlignment(.trailing)
.layoutPriority(1)
}
.accessibilityElement(children: .combine)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.padding(.top, 1)
}
}
}
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
HStack(spacing: 4) {
Circle()
.fill(isAvailable ? AITheme.success : AITheme.control)
.frame(width: 6, height: 6)
.overlay {
Circle()
.strokeBorder(AITheme.line)
}
Text(title)
.font(.caption2.weight(.medium))
.foregroundStyle(isAvailable ? AITheme.ink : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
// FlowLayout 按固有尺寸排布,这里不能再用 maxWidth: .infinity。
private struct FlowLayout: Layout {
let spacing: CGFloat
var lineSpacing: CGFloat?
private var rowGap: CGFloat { lineSpacing ?? spacing }
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var rowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight + rowGap
x = 0
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, x - spacing)
totalHeight += rowHeight
return CGSize(width: max(0, widestRow), height: max(0, totalHeight))
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + rowGap
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
private var availableCount: Int? {
guard let snapshot = model.snapshot else { return nil }
return snapshot.sections.filter { !$0.values.isEmpty }.count
}
private var moduleSections: [DeviceSection] {
model.snapshot?.sections ?? DeviceService.placeholderSections
}
private var sensitiveValues: [(title: String, value: String)] {
guard let snapshot = model.snapshot else { return [] }
return snapshot.sections.flatMap { section in
section.values
.filter { sensitiveKeys.contains($0.key) }
.sorted { $0.key < $1.key }
.map { (fieldLabel($0.key), $0.value) }
}
}
@ViewBuilder
private func sectionCard(_ section: DeviceSection) -> some View {
if section.values.isEmpty {
AICard(padding: 14) {
HStack(spacing: 10) {
Image(systemName: "wifi.slash")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.muted)
.frame(width: 32, height: 32)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Text("模块未返回数据")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
}
} else {
AICard(padding: 16) {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
// 行与行只靠发丝线分隔,纵向节奏交给 row 自己的内边距,
// 否则卡片会越拉越长,一屏读不到两组数据。
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
.padding(.vertical, 9)
}
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
}
}
}
}
}
}
private func sectionIcon(_ id: String) -> String {
switch id {
case "device": return "iphone.gen3"
case "sim": return "simcard"
case "network": return "antenna.radiowaves.left.and.right"
case "stats": return "chart.bar.fill"
case "cpu": return "cpu"
case "connectivity": return "dot.radiowaves.left.and.right"
default: return "list.bullet.rectangle"
}
}
private let fieldLabels: [String: String] = [
"model": "型号",
"manufacturer": "制造商",
"imei": "IMEI",
"revision": "固件版本",
"online": "在线",
"powered": "基带电源",
"present": "SIM 在位",
"iccid": "ICCID",
"imsi": "IMSI",
"phone_numbers": "号码",
"sms_center": "短信中心",
"operator_name": "运营商",
"registration_status": "注册状态",
"technology_preference": "网络制式",
"signal_strength": "信号强度",
"brand": "品牌",
"meid": "MEID",
"phone_number": "号码",
"firmware_version": "固件版本",
"os_version": "系统版本",
"baseband_version": "基带版本",
"uptime": "运行时间",
"uptime_seconds": "运行时间",
"slots": "卡槽数",
"active_slots": "活跃数",
"active": "已激活",
"enabled": "已启用",
"operator": "运营商",
"carrier": "运营商",
"network_type": "接入制式",
"technology": "技术",
"radio_mode": "网络模式",
"signal": "信号强度",
"signal_dbm": "信号强度(dBm)",
"signal_percent": "信号质量",
"rsrp": "RSRP",
"rsrq": "RSRQ",
"sinr": "SINR",
"mcc": "MCC",
"mnc": "MNC",
"registration": "注册状态",
"registration_state": "注册状态",
"roaming": "漫游",
"airplane_mode": "飞行模式",
"ipv4": "IPv4 地址",
"ipv6": "IPv6 地址",
"ip_address": "IP 地址",
"gateway": "网关",
"dns": "DNS",
"download": "下行速率",
"upload": "上行速率",
"download_bytes": "下行流量",
"upload_bytes": "上行流量",
"messages_today": "今日消息数",
"calls": "通话数",
"usage": "使用率",
"temperature": "温度",
"max_temperature_c": "最高温度",
"cpu_percent": "CPU 使用率",
"cpu_load": "CPU 负载",
"memory_percent": "内存使用率",
"memory_total_mb": "内存总量",
"memory_available_mb": "可用内存",
"battery_level": "电量",
"battery_percent": "电量",
"latency": "延迟",
"connected": "连接状态",
"interface": "网络接口",
"ssid": "无线网络",
"apn": "APN",
"core_count": "核心数",
"model_name": "CPU 型号",
"state": "连接状态",
"latency_ms": "延迟(ms)",
"name": "设备名称",
"disk_total_bytes": "存储总量",
"disk_available_bytes": "可用存储",
"percent": "使用率"
]
private let valueOrder: [String] = [
"model", "manufacturer", "brand", "name", "imei", "meid", "revision", "firmware_version",
"online", "powered", "present", "slots", "active_slots", "active", "iccid", "imsi",
"phone_numbers", "phone_number", "sms_center", "operator_name", "operator", "carrier",
"registration_status", "registration_state", "technology_preference", "network_type",
"radio_mode", "signal_strength", "signal_dbm", "signal_percent", "rsrp", "rsrq", "sinr",
"state", "connected", "latency", "latency_ms", "uptime", "uptime_seconds", "cpu_percent",
"cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage",
"temperature", "max_temperature_c", "battery_level", "battery_percent", "download",
"upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway",
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls",
"disk_total_bytes", "disk_available_bytes", "percent"
]
private let durationKeys: Set<String> = ["uptime", "uptime_seconds"]
private let percentKeys: Set<String> = ["usage", "cpu_percent", "memory_percent"]
private let byteRateKeys: Set<String> = ["download", "upload"]
private let byteCountKeys: Set<String> = ["download_bytes", "upload_bytes"]
private let sensitiveKeys: Set<String> = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"]
private func sortedValues(_ values: [String: String]) -> [(key: String, value: String)] {
values
.map { (key: $0.key, value: $0.value) }
.sorted { lhs, rhs in
let lhsRank = valueOrder.firstIndex(of: lhs.key) ?? Int.max
let rhsRank = valueOrder.firstIndex(of: rhs.key) ?? Int.max
if lhsRank != rhsRank { return lhsRank < rhsRank }
return lhs.key < rhs.key
}
}
private func fieldLabel(_ key: String) -> String {
if let exact = fieldLabels[key] { return exact }
let parts = key.split(separator: "_").map(String.init)
for count in [2, 1] where parts.count >= count {
let suffix = parts.suffix(count).joined(separator: "_")
if let label = fieldLabels[suffix] { return label }
}
return parts.joined(separator: " ")
}
private func display(_ key: String, _ value: String) -> String {
if matches(key, durationKeys), let seconds = Double(value), seconds >= 0 {
return Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated))
}
if matches(key, percentKeys), let number = Double(value), number >= 0 {
return "\(number.formatted(.number.precision(.fractionLength(0...1))))%"
}
if matches(key, byteRateKeys), let bytesPerSecond = Double(value), bytesPerSecond >= 0 {
return ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s"
}
if matches(key, byteCountKeys), let bytes = Double(value), bytes >= 0 {
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .decimal)
}
if matches(key, ["memory_total_mb"]), let megabytes = Double(value) {
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
}
if matches(key, ["memory_available_mb"]), let megabytes = Double(value) {
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
}
if key.hasSuffix("temperature"), let temperature = Double(value) {
return "\(temperature.formatted(.number.precision(.fractionLength(0...1))))°C"
}
if sensitiveKeys.contains(key), !revealsSensitive {
return masked(value)
}
return value
}
private func masked(_ value: String) -> String {
guard let first = value.first, value.count > 6 else {
return String(repeating: "•", count: min(value.count, 8))
}
let tail = value.suffix(4)
return "\(first) •••• \(tail)"
}
private func matches(_ key: String, _ keys: Set<String>) -> Bool {
let parts = key.split(separator: "_").map(String.init)
let suffixes = [key] + (1...2).compactMap { count in
parts.count >= count ? parts.suffix(count).joined(separator: "_") : nil
}
return suffixes.contains { keys.contains($0) }
}
private struct SensitiveValueSheet: View {
let values: [(title: String, value: String)]
@Binding var isRevealed: Bool
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
if values.isEmpty {
AICard {
AIEmptyState(
systemImage: "lock.open",
title: "暂无敏感值",
message: "设备数据加载后,这里会集中显示 IMEI、ICCID 等信息。"
)
}
} else {
AICard {
VStack(alignment: .leading, spacing: 14) {
Toggle(isOn: $isRevealed) {
VStack(alignment: .leading, spacing: 2) {
Text("显示敏感值")
.font(.subheadline.weight(.semibold))
Text("离开页面或手动关闭后自动隐藏。")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
.toggleStyle(.switch)
.tint(AITheme.primary)
Divider()
.overlay(AITheme.line.opacity(0.6))
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
if index > 0 {
Divider()
.overlay(AITheme.line.opacity(0.6))
}
AIKeyValueRow(
item.title,
value: isRevealed ? item.value : masked(item.value)
)
}
}
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("敏感值")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("完成") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func masked(_ value: String) -> String {
guard let first = value.first, value.count > 6 else {
return String(repeating: "•", count: min(value.count, 8))
}
let tail = value.suffix(4)
return "\(first) •••• \(tail)"
}
}
}
@@ -0,0 +1,225 @@
import SwiftUI
/// 分组被移除后,批量归类这件事由标签承担:一次点选给多台节点贴同一个标签。
struct FleetTagBatchSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var draft = ""
@State private var isBusy = false
private var targets: [UUID] { model.selectedIDs.sorted { $0.uuidString < $1.uuidString } }
private var selectedNames: [String] {
model.rows.filter { model.selectedIDs.contains($0.id) }.map(\.instance.name).sorted {
$0.localizedCompare($1) == .orderedAscending
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
targetsCard
tagGridCard
newTagCard
}
.padding(16)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("批量标签")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
AIToolbarButton("完成", kind: .primary) { dismiss() }
}
}
}
}
private var targetsCard: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("已选 \(targets.count) 台", systemImage: "checklist")
if targets.isEmpty {
Text("先在节点列表勾选要归类的节点。")
.font(.caption)
.foregroundStyle(AITheme.muted)
} else {
Text(selectedNames.joined(separator: "、"))
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(3)
.truncationMode(.tail)
}
}
}
}
private var tagGridCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("现有标签", systemImage: "tag")
if model.allTags.isEmpty {
Text("还没有标签,先在下方新建一个。")
.font(.caption)
.foregroundStyle(AITheme.faint)
} else {
// 与节点卡片的标签行同一排法:胶囊按标签长度取宽。栅格会把每枚拉到
// 同一列宽,短标签右侧空出一片,看起来像控件没加载完。
AIFlowLayout(spacing: 8) {
ForEach(model.allTags, id: \.self) { tag in
CoverageChip(
title: tag,
covered: model.tagCoverage(tag, in: targets),
total: targets.count,
isBusy: isBusy
) {
Task { await apply(tag, removing: model.tagCoverage(tag, in: targets) == targets.count) }
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private var newTagCard: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
AIFieldLabel("新建标签并贴上")
HStack(spacing: 10) {
TextField("如 深圳-现场", text: $draft)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("新标签名称")
.submitLabel(.done)
.onSubmit { submitDraft() }
.disabled(isBusy || targets.isEmpty)
Button(isBusy ? "处理中" : "添加") { submitDraft() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fixedWidth: 84))
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isBusy || targets.isEmpty)
}
// 没有勾选节点时这个表单按下去只会清空输入,先说清楚为什么不能动。
if targets.isEmpty {
Text("勾选节点后即可新建标签并批量贴上。")
.font(.caption2)
.foregroundStyle(AITheme.faint)
}
}
}
}
private func submitDraft() {
let tag = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard tag.isEmpty == false, isBusy == false else { return }
draft = ""
Task { await apply(tag, removing: false) }
}
private func apply(_ tag: String, removing: Bool) async {
guard isBusy == false, targets.isEmpty == false else { return }
isBusy = true
await model.applyTag(tag, to: targets, removing: removing)
isBusy = false
}
/// 覆盖状态直接写在胶囊上:全部已贴 / 部分已贴 / 未贴,点击即切换整批。
private struct CoverageChip: View {
let title: String
let covered: Int
let total: Int
let isBusy: Bool
let action: () -> Void
private var isComplete: Bool { total > 0 && covered == total }
private var isPartial: Bool { covered > 0 && covered < total }
// 没有勾选任何节点时这一整片都是不可操作状态,此时不该用「未贴」这种带暗示的
// 灰字,直接按中性标签渲染,避免看起来像加载失败的禁用控件。
private var isInert: Bool { total == 0 }
private var stateText: String {
if isInert { return "" }
if isComplete { return "全部" }
if isPartial { return "\(covered)/\(total)" }
return "未贴"
}
var body: some View {
if isInert {
// 不可操作时不渲染 Button:系统禁用态会把整枚胶囊压成灰色,
// 看起来像加载失败,而标签本身是需要读清楚的内容。
label
.accessibilityLabel("标签 \(title),勾选节点后可贴上")
} else {
Button(action: action) { label }
.buttonStyle(.plain)
.disabled(isBusy)
.accessibilityLabel(isComplete ? "从已选节点移除标签 \(title)" : "给已选节点贴上标签 \(title)")
.accessibilityAddTraits(isComplete ? [.isButton, .isSelected] : .isButton)
}
}
private var label: some View {
HStack(spacing: 6) {
Image(systemName: iconName)
.font(.system(size: 12, weight: .semibold))
.accessibilityHidden(true)
Text(title)
.font(.footnote.weight(isComplete || isPartial ? .semibold : .medium))
.lineLimit(1)
.truncationMode(.middle)
if stateText.isEmpty == false {
Text(stateText)
.font(.caption2.weight(.semibold))
.monospacedDigit()
.opacity(0.72)
}
}
.padding(.horizontal, 10)
.frame(height: 38)
.foregroundStyle(foreground)
.background(background)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(border)
}
.contentShape(Capsule(style: .continuous))
}
private var iconName: String {
if isInert { return "tag" }
if isComplete { return "checkmark.circle.fill" }
if isPartial { return "circle.lefthalf.filled" }
return "circle"
}
private var foreground: Color {
if isInert { return AITheme.ink }
if isComplete { return AITheme.onPrimary }
if isPartial { return AITheme.ink }
return AITheme.muted
}
private var background: Color {
if isInert { return AITheme.tile }
if isComplete { return AITheme.primary }
if isPartial { return AITheme.tile }
return AITheme.surface
}
private var border: Color {
if isInert { return AITheme.tileLine }
if isComplete { return AITheme.primary }
if isPartial { return AITheme.primary.opacity(0.42) }
return AITheme.line
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
import Foundation
import SwiftUI
@MainActor
final class FleetViewModel: ObservableObject {
struct Row: Equatable, Identifiable {
let id: UUID
var instance: Instance
var status: ConnectionStatus
var deviceName: String?
var phoneNumber: String?
var operatorName: String?
var networkTechnology: String?
var signalPercent: Int?
var cpuPercent: Double?
var memoryPercent: Double?
var cpuCount: Int?
var temperatureCelsius: Double?
var batteryPercent: Double?
var ipAddress: String?
var latencyMilliseconds: Int?
var connectivity: String?
var firmwareVersion: String?
var totalStorageBytes: Int64?
var availableStorageBytes: Int64?
var downloadRateBytesPerSecond: Double?
var uploadRateBytesPerSecond: Double?
var messagesToday: Int?
var uptimeSeconds: Double?
var chainValues: DeviceLinkState?
var testing: Bool
/// Why the last probe ended where it did, for the detail text.
var probeError: UpstreamError?
/// When the last probe finished. The list shows how old a reading is, which in relay mode is
/// the difference between "online" meaning now and meaning forty seconds ago.
var checkedAt: Date?
/// Which path this node is reached by. The badge exists because without it "this one is
/// slow" has no answer that a user can act on.
var channel: InstanceChannel { instance.endpoint.channel }
var freshness: SnapshotFreshness {
testing ? .unknown : SnapshotFreshness.classify(age: checkedAt.map { Date().timeIntervalSince($0) })
}
var storageUsageRatio: Double? {
guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else {
return nil
}
return Double(max(0, total - available)) / Double(total)
}
init(
id: UUID,
instance: Instance,
status: ConnectionStatus = .unknown,
testing: Bool = true
) {
self.id = id
self.instance = instance
self.status = status
self.testing = testing
}
}
@Published var rows: [Row] = []
@Published var searchText = ""
@Published var statusFilter: ConnectionStatus?
@Published var selectedTagFilters: Set<String> = [] {
didSet {
let values = Array(selectedTagFilters).sorted()
preferences.set(values, forKey: "fleet.selectedTagFilters")
}
}
@Published var selectedIDs: Set<UUID> = []
@Published var selectionMode = false
@Published var isLoading = false
@Published private(set) var lastRefresh: Date?
@Published var errorMessage: String?
@Published var showingForm = false
@Published var showingTagBatch = false
private let repository: any InstanceRepository
private let probe: ConnectionProbe
private let credentials: any CredentialStore
private let deviceService: (any DeviceSummaryService)?
private let preferences: UserDefaults
init(
repository: any InstanceRepository,
probe: ConnectionProbe,
credentials: any CredentialStore,
deviceService: (any DeviceSummaryService)? = nil,
preferences: UserDefaults = .standard
) {
self.repository = repository
self.probe = probe
self.credentials = credentials
self.deviceService = deviceService
self.preferences = preferences
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
}
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
func pruneTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
if live != selectedTagFilters {
selectedTagFilters = live
}
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
let instances = try await repository.list(query)
rows = instances.map {
Row(id: $0.id, instance: $0, status: .unknown, testing: true)
}
await withTaskGroup(of: Void.self) { group in
for instance in instances {
group.addTask { [probe, deviceService] in
let result = try? await probe.test(instance)
let summary = try? await deviceService?.summary(instance)
await self.applyProbe(instance.id, result, summary)
}
}
}
} catch {
errorMessage = "无法刷新节点列表"
}
pruneTagFilters()
lastRefresh = Date()
isLoading = false
}
@discardableResult
func add(_ input: InstanceInput) async throws -> Instance {
let created = try await repository.create(input)
if let password = input.password, !password.isEmpty {
try credentials.set(instanceID: created.id, password: password)
}
showingForm = false
await refresh()
return created
}
func update(_ id: UUID, _ input: InstanceInput) async throws {
guard let original = rows.first(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
_ = try await repository.update(
id,
expectedRevision: original.instance.configRevision,
InstancePatch(
name: input.name,
endpoint: input.endpoint,
tags: input.tags,
password: input.password
)
)
if let password = input.password, !password.isEmpty {
try credentials.set(instanceID: id, password: password)
}
showingForm = false
await refresh()
}
func delete(_ ids: [UUID]) async {
for id in ids {
if let row = rows.first(where: { $0.id == id }) {
do {
try await repository.delete(id, expectedRevision: row.instance.configRevision)
try credentials.remove(instanceID: id)
} catch {
errorMessage = "删除 \(row.instance.name) 失败"
}
}
}
selectedIDs.removeAll()
selectionMode = false
await refresh()
}
func deleteSelected() async {
await delete(Array(selectedIDs))
}
/// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。
func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
var failures = 0
for id in ids {
guard let row = rows.first(where: { $0.id == id }) else { continue }
var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized }
if removing == false {
updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines))
}
do {
_ = try await repository.update(
id,
expectedRevision: row.instance.configRevision,
InstancePatch(tags: updated)
)
} catch {
failures += 1
errorMessage = "更新 \(row.instance.name) 标签失败"
}
}
if failures > 0, errorMessage == nil {
errorMessage = "部分节点标签更新失败"
}
await refresh()
}
func tagNames(in ids: [UUID]) -> [String] {
let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) })
return set.sorted { $0.localizedCompare($1) == .orderedAscending }
}
private func applyProbe(
_ id: UUID,
_ result: ConnectionResult?,
_ summary: DeviceSummary?
) {
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
rows[index].status = result?.status ?? .unknown
rows[index].probeError = result?.error
rows[index].checkedAt = result?.checkedAt
if let summary {
rows[index].deviceName = summary.deviceName
rows[index].phoneNumber = summary.phoneNumber
rows[index].operatorName = summary.operatorName
rows[index].networkTechnology = summary.networkTechnology
rows[index].signalPercent = summary.signalPercent
rows[index].cpuPercent = summary.cpuPercent
rows[index].memoryPercent = summary.memoryPercent
rows[index].cpuCount = summary.cpuCount
rows[index].temperatureCelsius = summary.temperatureCelsius
rows[index].batteryPercent = summary.batteryPercent
rows[index].ipAddress = summary.ipAddress
rows[index].latencyMilliseconds = summary.latencyMilliseconds
rows[index].connectivity = summary.connectivity
rows[index].firmwareVersion = summary.firmwareVersion
rows[index].totalStorageBytes = summary.totalStorageBytes
rows[index].availableStorageBytes = summary.availableStorageBytes
rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond
rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond
rows[index].messagesToday = summary.messagesToday
rows[index].uptimeSeconds = summary.uptimeSeconds
rows[index].chainValues = summary.linkState
}
if rows[index].latencyMilliseconds == nil {
rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds
}
rows[index].testing = false
}
/// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。
var visibleRows: [Row] {
rows.filter { row in
if let statusFilter, row.status != statusFilter {
return false
}
if selectedTagFilters.isEmpty == false {
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
guard needles.isSubset(of: tags) else {
return false
}
}
guard !searchText.isEmpty else { return true }
let haystacks: [String] = [
row.instance.name,
row.instance.endpoint.displayAddress,
row.instance.tags.joined(separator: " "),
row.operatorName ?? "",
row.phoneNumber ?? "",
row.networkTechnology ?? "",
row.deviceName ?? ""
]
let strings: [String] = haystacks
return strings.contains {
$0.localizedCaseInsensitiveContains(searchText)
}
}
.sorted { lhs, rhs in
let left = severity(lhs.status)
let right = severity(rhs.status)
if left != right { return left < right }
return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending
}
}
private func severity(_ status: ConnectionStatus) -> Int {
switch status {
case .offline: return 0
case .authRequired, .degraded: return 1
case .unknown: return 2
case .online: return 3
}
}
var allTags: [String] {
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
}
var tagCounts: [String: Int] {
var counts: [String: Int] = [:]
for row in rows {
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
counts[tag, default: 0] += 1
}
}
return counts
}
func countForTagFilter(_ tag: String) -> Int {
tagCounts[Self.normalizedTag(tag)] ?? 0
}
func isTagFilterActive(_ tag: String) -> Bool {
selectedTagFilters.contains(Self.normalizedTag(tag))
}
/// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。
func tagCoverage(_ tag: String, in ids: [UUID]) -> Int {
let needle = Self.normalizedTag(tag)
guard needle.isEmpty == false else { return 0 }
let targets = Set(ids)
return rows.filter { row in
targets.contains(row.id)
&& row.instance.tags.map(Self.normalizedTag).contains(needle)
}.count
}
func toggleTagFilter(_ tag: String) {
let normalized = Self.normalizedTag(tag)
guard normalized.isEmpty == false else { return }
if selectedTagFilters.contains(normalized) {
selectedTagFilters.remove(normalized)
} else {
selectedTagFilters.insert(normalized)
}
}
func clearTagFilters() {
guard selectedTagFilters.isEmpty == false else { return }
selectedTagFilters.removeAll()
}
private static func normalizedTag(_ tag: String) -> String {
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
protocol DeviceSummaryService {
func summary(_ instance: Instance) async throws -> DeviceSummary
}
extension DeviceService: DeviceSummaryService {}
@@ -0,0 +1,193 @@
import SwiftUI
struct InstanceFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
let instance: Instance?
@State private var name = ""
@State private var originText = ""
@State private var tags: [String] = []
@State private var tagDraft = ""
@State private var password = ""
@State private var validationMessage: String?
@State private var isSaving = false
init(model: FleetViewModel, instance: Instance? = nil) {
self.model = model
self.instance = instance
_name = State(initialValue: instance?.name ?? "")
_originText = State(initialValue: instance?.endpoint.displayAddress ?? "")
_tags = State(initialValue: instance?.tags ?? [])
}
/// A relay instance is edited by name and tags only. Its address is not a URL the user typed —
/// it is a pairing, and letting the form rewrite it would either drop the node's keys or produce
/// an endpoint that fails validation. Re-pairing is the way to change it.
private var isRelay: Bool { instance?.endpoint.isRelay == true }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (isRelay || !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
if isRelay {
relayChannelRow
} else {
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
}
tagEditor
if !isRelay {
field(
"实例密码",
text: $password,
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
secure: true
)
}
}
}
if isRelay {
AIStatusBanner(
"中继节点的密码保存在节点 agent 上,手机不持有;更换中继需要重新配对。",
tint: AITheme.muted,
systemImage: "lock.shield"
)
}
if let validationMessage {
AIStatusBanner(
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
save()
} label: {
HStack(spacing: 8) {
if isSaving {
ProgressView()
.controlSize(.small)
}
Text(isSaving ? "保存中" : "保存实例")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave || isSaving)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
#if os(iOS)
// 表单表统一用行内标题:大标题会在卡片上方留出一整条空白,
// 而「批量标签」等其它表已经是行内样式。
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
/// The relay channel is read-only here, but it must still be visible: without it the form shows
/// a node with no address at all, which reads as missing data rather than as a different mode.
private var relayChannelRow: some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("通道")
HStack(spacing: 8) {
Text("中继")
.font(.caption.weight(.semibold))
.foregroundStyle(AITheme.muted)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(AITheme.canvas, in: Capsule())
Text(originText.isEmpty ? "已配对节点" : originText)
.font(.footnote)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
}
}
.accessibilityElement(children: .combine)
}
private var tagEditor: some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("标签")
TagEditorView(tags: $tags, suggestions: model.allTags)
}
.accessibilityElement(children: .contain)
}
private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel(title)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.textContentType(.password)
.accessibilityLabel(title)
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel(title)
.submitLabel(.done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
}
}
}
private func save() {
guard !isSaving else { return }
isSaving = true
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https",
origin.host != nil
else {
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
isSaving = false
return
}
// An existing relay instance keeps its endpoint: the address field only edits a direct
// origin, and rewriting a pairing into a direct one here would silently drop the node's keys.
let endpoint = instance?.endpoint.isRelay == true ? instance!.endpoint : InstanceEndpoint.direct(origin)
let input = InstanceInput(
name: normalizedName,
endpoint: endpoint,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
if let instance {
try await model.update(instance.id, input)
} else {
try await model.add(input)
}
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
isSaving = false
}
}
}
@@ -0,0 +1,114 @@
import SwiftUI
/// Pairs this phone with a node over the LAN.
///
/// The flow needs the phone to be inside the network, which is why it is a separate sheet rather
/// than part of the instance form: the address here is the node's LAN address, not the relay's, and
/// the password is the node's own — the one fact the pairing flow leans on.
struct RelayPairingView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: RelayPairingViewModel
@ObservedObject var fleet: FleetViewModel
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("节点内网地址", text: $model.host, placeholder: "192.168.1.10")
field("节点访问密码", text: $model.password, placeholder: "SimAdmin 密码", secure: true)
field("显示名称", text: $model.name, placeholder: "可留空,默认使用节点 ID")
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("标签")
TagEditorView(tags: $model.tags, suggestions: fleet.allTags)
}
}
}
AIStatusBanner(
"配对需要手机与节点在同一内网。节点上先运行一次 msa-agent --pair,配对成功后它的管理端口会自动关闭。",
tint: AITheme.muted,
systemImage: "wifi"
)
if let message = model.errorMessage {
AIStatusBanner(message, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if case .done(let nodeID) = model.stage {
AIStatusBanner(
"配对完成,节点 \(nodeID) 已加入列表。",
tint: AITheme.success,
systemImage: "checkmark.circle"
)
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("配对中继节点")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
.aiBottomActionBar {
AIActionRow {
Button(model.isDone ? "完成" : "取消") {
if model.isDone {
Task { await fleet.refresh() }
}
dismiss()
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
if !model.isDone {
Button {
Task { await model.pair() }
} label: {
HStack(spacing: 8) {
if model.stage == .pairing {
ProgressView().controlSize(.small)
}
Text(model.stage == .pairing ? "配对中" : "开始配对")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!model.canSubmit)
}
}
}
}
}
private func field(
_ label: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel(label)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(.plain)
.font(.body)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.font(.body)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
}
@@ -0,0 +1,106 @@
import CryptoKit
import Foundation
import SwiftUI
/// Drives the pairing sheet: collect the node's LAN address and password, run the pairing exchange,
/// and hand the result to the repository as a new instance.
///
/// The pairing window on the node closes after one success, so a failure here is not merely "try
/// again later": it may mean the window is shut and the operator has to re-open it on the node. The
/// error text says which of the two happened, because the two call for different actions.
@MainActor
final class RelayPairingViewModel: ObservableObject {
enum Stage: Equatable {
case idle
case pairing
case done(nodeID: String)
}
@Published var host = ""
@Published var password = ""
@Published var name = ""
@Published var tags: [String] = []
@Published var stage: Stage = .idle
@Published var errorMessage: String?
private let pairing: RelayPairing
private let identities: FileDeviceIdentityStore
private let repository: any InstanceRepository
init(
repository: any InstanceRepository,
identities: FileDeviceIdentityStore,
pairing: RelayPairing = RelayPairing()
) {
self.repository = repository
self.identities = identities
self.pairing = pairing
}
var canSubmit: Bool {
!host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !password.isEmpty
&& stage != .pairing
}
var isDone: Bool {
if case .done = stage { return true }
return false
}
func pair() async {
guard canSubmit else { return }
errorMessage = nil
stage = .pairing
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = name.trimmingCharacters(in: .whitespacesAndNewlines)
do {
// The identity is created on first use: the phone needs keys before it can pair, but it
// cannot know its device id until the node assigns one.
let identity = try identities.loadOrCreate()
let result = try await pairing.pair(
host: trimmedHost,
password: password,
deviceName: displayName.isEmpty ? trimmedHost : displayName,
identity: identity
)
// The id the node assigned has to be recorded before any request is sent, because it is
// signed into every envelope and the node verifies it against its own device list.
_ = try identities.assignDeviceID(result.deviceID)
let instance = try await repository.create(InstanceInput(
name: displayName.isEmpty ? (result.endpoint.nodeName ?? result.endpoint.nodeID) : displayName,
endpoint: .relay(result.endpoint),
tags: tags
))
_ = instance
stage = .done(nodeID: result.endpoint.nodeID)
password = ""
} catch let failure as RelayPairing.Failure {
stage = .idle
errorMessage = Self.describe(failure, host: trimmedHost)
} catch let error as RepositoryError {
stage = .idle
errorMessage = error == .duplicateOrigin
? "该节点已经在中继列表中,无需重复配对。"
: "配对成功但保存失败,请重试。"
} catch {
stage = .idle
errorMessage = "配对失败:\(error)"
}
}
/// The pairing failure vocabulary is small and each case has one obvious next step, so the text
/// states the step rather than the condition.
static func describe(_ failure: RelayPairing.Failure, host: String) -> String {
switch failure {
case .rejected:
return "节点密码不正确。请确认使用的是该节点 SimAdmin 的访问密码。"
case .windowClosed:
return "节点上的配对窗口已关闭。请在节点上重新运行一次配对(msa-agent --pair)后重试。"
case .unreachable:
return "无法连接 \(host):3001。请确认手机与节点在同一内网,且节点上已启动配对。"
case .malformedResponse:
return "节点返回的内容不是有效的配对响应。请确认节点上运行的是本方案的 agent。"
}
}
}
@@ -0,0 +1,161 @@
import SwiftUI
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
struct TagEditorView: View {
@Binding var tags: [String]
let suggestions: [String]
@State private var draft = ""
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
TextField("新标签,回车添加", text: $draft)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
#endif
.onSubmit { commit(draft) }
Button {
commit(draft)
} label: {
Image(systemName: "plus")
// 加号按钮和左边的输入框同一高度、同一圆角,两个控件才读得出是一组。
.font(.system(size: 16, weight: .semibold))
.frame(width: AITheme.inputHeight, height: AITheme.inputHeight)
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
}
.buttonStyle(.plain)
.disabled(canAdd == false)
.accessibilityLabel("添加标签")
}
if tags.isEmpty == false || suggestions.isEmpty == false {
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
if tags.isEmpty == false {
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
removableTag(tags[index])
}
}
if availableSuggestions.isEmpty == false {
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
suggestionTag(availableSuggestions[index])
}
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("标签")
}
private func chipStrip<Item: View>(
ids: [String],
@ViewBuilder content: @escaping (Int) -> Item
) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
content(index)
}
}
.padding(.vertical, 2)
}
.accessibilityHidden(false)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canAdd: Bool {
trimmedDraft.isEmpty == false
}
private var availableSuggestions: [String] {
let taken = Set(tags.map(Self.key))
return suggestions
.filter { taken.contains(Self.key($0)) == false }
.prefix(10)
.map { $0 }
}
private func removableTag(_ tag: String) -> some View {
Button {
tags.removeAll { Self.key($0) == Self.key(tag) }
} label: {
HStack(spacing: 5) {
Text(tag)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.opacity(0.65)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.ink)
.background(AITheme.control)
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.line)
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("移除标签 \(tag)")
}
private func suggestionTag(_ tag: String) -> some View {
Button {
add(tag)
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 10, weight: .bold))
Text(tag)
.font(.footnote.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 11)
.frame(height: 32)
.foregroundStyle(AITheme.primary)
.background(AITheme.primary.opacity(0.09))
.clipShape(Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
}
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("使用已有标签 \(tag)")
}
private func commit(_ raw: String) {
let parts = raw
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
parts.forEach(add)
draft = ""
}
private func add(_ tag: String) {
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.isEmpty == false else { return }
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
tags.append(value)
}
private static func key(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
@@ -0,0 +1,218 @@
import Foundation
public enum RuntimeLogSource: String, Codable, Equatable, Sendable, CaseIterable {
case delivery
public var label: String {
switch self {
case .delivery: return "节点运行"
}
}
}
public enum RuntimeLogLevel: String, Codable, Equatable, Sendable, CaseIterable {
case info
case warning
case error
public var label: String {
switch self {
case .info: return "信息"
case .warning: return "警告"
case .error: return "错误"
}
}
}
public enum ConnectionOutcome: String, Codable, Equatable, Sendable, CaseIterable {
case success
case stale
case failed
case unsupported
public var label: String {
switch self {
case .success: return "成功"
case .stale: return "需登录"
case .failed: return "失败"
case .unsupported: return "不支持"
}
}
}
public struct RuntimeLogEntry: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let source: RuntimeLogSource
public let level: RuntimeLogLevel
public let message: String
public let eventType: String?
public let instanceID: UUID?
public let instanceName: String?
public let occurredAt: Date
public let code: String?
public let durationMs: Int?
public init(
id: String,
source: RuntimeLogSource,
level: RuntimeLogLevel,
message: String,
eventType: String? = nil,
instanceID: UUID? = nil,
instanceName: String? = nil,
occurredAt: Date,
code: String? = nil,
durationMs: Int? = nil
) {
self.id = id
self.source = source
self.level = level
self.message = message
self.eventType = eventType
self.instanceID = instanceID
self.instanceName = instanceName
self.occurredAt = occurredAt
self.code = code
self.durationMs = durationMs
}
}
public struct ConnectionLogEntry: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let instanceID: UUID
public let instanceName: String
public let outcome: ConnectionOutcome
public let state: String
public let errorCode: String?
public let httpStatus: Int?
public let durationMs: Int
public let observedAt: Date
public init(
id: String,
instanceID: UUID,
instanceName: String,
outcome: ConnectionOutcome,
state: String,
errorCode: String? = nil,
httpStatus: Int? = nil,
durationMs: Int,
observedAt: Date
) {
self.id = id
self.instanceID = instanceID
self.instanceName = instanceName
self.outcome = outcome
self.state = state
self.errorCode = errorCode
self.httpStatus = httpStatus
self.durationMs = durationMs
self.observedAt = observedAt
}
}
public struct LogCapabilitySummary: Equatable, Sendable {
public let total: Int
public let supported: Int
public let unsupported: Int
public let authRequired: Int
public let degraded: Int
public let unknown: Int
public init(
total: Int,
supported: Int,
unsupported: Int,
authRequired: Int,
degraded: Int,
unknown: Int
) {
self.total = total
self.supported = supported
self.unsupported = unsupported
self.authRequired = authRequired
self.degraded = degraded
self.unknown = unknown
}
}
public struct ConnectionSummary: Equatable, Sendable {
public let total: Int
public let success: Int
public let failed: Int
public let averageDurationMs: Int
public let availabilityPercent: Int
}
public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
public let id: UUID
public let name: String
public let origin: String
public let tags: [String]
public let state: ConnectionStatus
public let healthStatus: String?
public let version: String?
public let platform: String?
public let observedAt: Date
public let featureStatus: [FeatureStatus]
public let connection: ConnectionSummary?
public init(
id: UUID,
name: String,
origin: String,
tags: [String],
state: ConnectionStatus,
healthStatus: String?,
version: String?,
platform: String?,
observedAt: Date,
featureStatus: [FeatureStatus],
connection: ConnectionSummary?
) {
self.id = id
self.name = name
self.origin = origin
self.tags = tags
self.state = state
self.healthStatus = healthStatus
self.version = version
self.platform = platform
self.observedAt = observedAt
self.featureStatus = featureStatus
self.connection = connection
}
}
public struct FeatureStatus: Equatable, Identifiable, Sendable {
public enum State: Equatable, Sendable {
case ready
case requiresLogin
case unavailable
}
public var id: String { name }
public let name: String
public let state: State
public init(name: String, state: State) {
self.name = name
self.state = state
}
}
public struct LogSnapshot: Equatable, Sendable {
public let observedAt: Date
public let runtime: [RuntimeLogEntry]
public let counts: [RuntimeLogSource: Int]
public let connections: [ConnectionLogEntry]
public let diagnostics: [InstanceDiagnostic]
}
public protocol LogStore: Sendable {
func runtimeLogs() async throws -> [RuntimeLogEntry]
func connectionLogs() async throws -> [ConnectionLogEntry]
func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws
func pruneConnections(before: Date) async throws -> Int
func clearRuntimeLogs() async throws -> Int
}
@@ -0,0 +1,266 @@
import Foundation
public struct LogCenterService: Sendable {
private struct ProbeOutcome: Sendable {
var status: ConnectionStatus
var outcome: ConnectionOutcome
var state: String
var errorCode: String?
var httpStatus: Int?
var durationMs: Int
var message: String
var version: String?
var platform: String?
}
private struct InstanceCollection: Sendable {
var runtime: [RuntimeLogEntry] = []
var connections: [ConnectionLogEntry] = []
var diagnostic: InstanceDiagnostic?
}
private let transport: any NodeTransport
private let store: any LogStore
public init(transport: any NodeTransport, store: any LogStore) {
self.transport = transport
self.store = store
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil,
store: any LogStore
) {
self.init(
transport: DirectTransport(client: client, sessions: sessions, credentials: credentials),
store: store
)
}
public func refresh(_ instances: [Instance]) async throws -> LogSnapshot {
var collected: [InstanceCollection] = []
await withTaskGroup(of: InstanceCollection.self) { group in
for instance in instances {
group.addTask { await self.collect(instance) }
}
for await value in group {
collected.append(value)
}
}
collected.sort { ($0.diagnostic?.name ?? "") < ($1.diagnostic?.name ?? "") }
let runtime = collected.flatMap(\.runtime).sorted { $0.occurredAt > $1.occurredAt }
let connections = collected.flatMap(\.connections).sorted { $0.observedAt > $1.observedAt }
let diagnostics = collected.compactMap(\.diagnostic)
try await store.append(runtime: runtime, connections: connections)
var counts: [RuntimeLogSource: Int] = [:]
for source in RuntimeLogSource.allCases {
counts[source] = runtime.filter { $0.source == source }.count
}
return LogSnapshot(
observedAt: Date(),
runtime: runtime,
counts: counts,
connections: connections,
diagnostics: diagnostics
)
}
private func collect(_ instance: Instance) async -> InstanceCollection {
var result = InstanceCollection()
let probe = await probe(instance)
var modules: [String: Result<Bool, Error>] = [
"connection": .success(true),
"device": await moduleResult(instance, .device)
]
if probe.outcome == .failed {
modules["connection"] = .failure(UpstreamError.notSupported)
modules["device"] = .failure(UpstreamError.notSupported)
}
result.connections.append(ConnectionLogEntry(
id: "probe-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
instanceID: instance.id,
instanceName: instance.name,
outcome: probe.outcome,
state: probe.state,
errorCode: probe.errorCode,
httpStatus: probe.httpStatus,
durationMs: probe.durationMs,
observedAt: Date()
))
if probe.outcome == .failed || probe.outcome == .stale {
result.runtime.append(RuntimeLogEntry(
id: "connection-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
source: .delivery,
level: probe.outcome == .failed ? .error : .warning,
message: probe.message,
instanceID: instance.id,
instanceName: instance.name,
occurredAt: Date(),
code: probe.errorCode,
durationMs: probe.durationMs
))
}
modules["status"] = .success(true)
result.diagnostic = diagnostic(
instance,
probe: probe,
modules: modules,
connections: result.connections
)
return result
}
private func probe(_ instance: Instance) async -> ProbeOutcome {
let startedAt = Date()
do {
let response = try await transport.send(
.health,
instanceID: instance.id,
endpoint: instance.endpoint
)
let duration = elapsed(startedAt)
let root = (try? JSONSerialization.jsonObject(with: response.data)) as? [String: Any] ?? [:]
return ProbeOutcome(
status: .online,
outcome: .success,
state: "online",
errorCode: nil,
httpStatus: response.status,
durationMs: duration,
message: "节点连接成功",
version: Self.text(root["version"]),
platform: Self.text(root["platform"])
)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
return ProbeOutcome(
status: .authRequired,
outcome: .stale,
state: "auth_required",
errorCode: "UNAUTHORIZED",
httpStatus: 401,
durationMs: elapsed(startedAt),
message: "节点需要登录",
version: nil,
platform: nil
)
} catch UpstreamError.timeout {
return ProbeOutcome(
status: .offline,
outcome: .failed,
state: "unreachable",
errorCode: "TIMEOUT",
httpStatus: nil,
durationMs: elapsed(startedAt),
message: "节点连接超时",
version: nil,
platform: nil
)
} catch {
return ProbeOutcome(
status: .degraded,
outcome: .failed,
state: "unreachable",
errorCode: "TRANSPORT",
httpStatus: nil,
durationMs: elapsed(startedAt),
message: "节点连接失败",
version: nil,
platform: nil
)
}
}
private func moduleResult(
_ instance: Instance,
_ route: UpstreamRoute
) async -> Result<Bool, Error> {
do {
_ = try await request(route, instance)
return .success(true)
} catch {
return .failure(error)
}
}
private func diagnostic(
_ instance: Instance,
probe: ProbeOutcome,
modules: [String: Result<Bool, Error>],
connections: [ConnectionLogEntry]
) -> InstanceDiagnostic {
let featureStatus = modules
.sorted { $0.key < $1.key }
.map { key, result -> FeatureStatus in
let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备"
switch result {
case .success:
return FeatureStatus(name: name, state: .ready)
case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden):
return FeatureStatus(name: name, state: .requiresLogin)
case .failure:
return FeatureStatus(name: name, state: .unavailable)
}
}
let success = connections.filter { $0.outcome == .success }.count
let failed = connections.filter { $0.outcome == .failed }.count
let average = connections.isEmpty
? 0
: connections.map(\.durationMs).reduce(0, +) / connections.count
return InstanceDiagnostic(
id: instance.id,
name: instance.name,
origin: instance.endpoint.displayAddress,
tags: instance.tags,
state: probe.status,
healthStatus: Self.healthLabel(probe),
version: probe.version,
platform: probe.platform,
observedAt: Date(),
featureStatus: featureStatus,
connection: ConnectionSummary(
total: connections.count,
success: success,
failed: failed,
averageDurationMs: average,
availabilityPercent: connections.isEmpty ? 0 : success * 100 / connections.count
)
)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
private func elapsed(_ startedAt: Date) -> Int {
max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
}
private static func healthLabel(_ probe: ProbeOutcome) -> String {
switch probe.state {
case "online", "UP", "ok": return "在线"
case "auth_required": return "需登录"
case "unreachable": return probe.errorCode == "TIMEOUT" ? "超时" : "不可达"
default: return probe.state
}
}
private static func text(_ value: Any?) -> String? {
guard let value = value as? String, !value.isEmpty, value.count <= 512 else { return nil }
return value
}
private static func date(_ value: Any?) -> Date {
guard let text = value as? String else { return Date() }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let parsed = formatter.date(from: text) { return parsed }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: text) ?? Date()
}
}
@@ -0,0 +1,313 @@
import SwiftUI
struct LogsView: View {
private enum Tab: Hashable {
case runtime
case connections
case diagnostics
}
@StateObject private var model: LogsViewModel
private let autoRefreshOnAppear: Bool
@State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
init(model: LogsViewModel? = nil) {
let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false)
_model = StateObject(wrappedValue: resolvedModel)
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
}
private var isUnavailable: Bool {
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
}
/// 空列表配一排筛选器只是噪音,有内容才给筛选入口。
private var hasRowsForTab: Bool {
switch tab {
case .runtime: return model.filteredRuntime.isEmpty == false
case .connections: return model.filteredConnections.isEmpty == false
case .diagnostics: return model.filteredDiagnostics.isEmpty == false
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if isUnavailable {
AICard {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取日志",
message: model.errorMessage ?? "所有节点都没有返回日志数据。",
actionTitle: "重试"
) {
Task { await model.refresh() }
}
}
} else if model.snapshot == nil && model.isLoading {
AILoadingCard("正在同步日志数据")
} else {
stats
AISegmentedControl(
"日志视图",
selection: $tab,
options: [
(value: Tab.runtime, title: "运行日志"),
(value: Tab.connections, title: "连接日志"),
(value: Tab.diagnostics, title: "设备诊断"),
]
)
if hasRowsForTab {
filterBar
}
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"清理 7 天前的连接日志?操作不可恢复。",
isPresented: $showsPruneDialog,
titleVisibility: .visible
) {
Button("清理连接日志", role: .destructive) {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
if model.snapshot == nil && autoRefreshOnAppear {
await model.refresh()
}
}
}
/// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。
private var stats: some View {
let runtime = model.snapshot?.runtime.count ?? 0
let connections = model.snapshot?.connections.count ?? 0
let diagnostics = model.snapshot?.diagnostics.count ?? 0
return Group {
if runtime + connections + diagnostics > 0 {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(
title: "运行日志", value: "\(runtime)", systemImage: "list.bullet", tint: AITheme.primary)
AIStatCard(
title: "连接探测", value: "\(connections)", systemImage: "dot.radiowaves.left.and.right",
tint: model.offlineCount == 0 ? AITheme.success : AITheme.warning)
AIStatCard(
title: "设备诊断", value: "\(diagnostics)", systemImage: "iphone.gen3", tint: AITheme.ink)
}
}
}
}
private var filterBar: some View {
VStack(spacing: 8) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText, minHeight: AITheme.compactControlHeight)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
if tab == .runtime {
HStack(spacing: 8) {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
Picker("级别", selection: $model.selectedLevel) {
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
Text(level.label).tag(Optional(level))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
}
} else if tab == .connections {
Picker("结果", selection: $model.selectedOutcome) {
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
Text(outcome.label).tag(Optional(outcome))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
}
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
private var runtimeSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
AICard {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。")
}
} else {
ForEach(model.filteredRuntime) { entry in
AIListCard(
title: entry.message,
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
status: entry.level.label,
statusTint: levelTint(entry.level),
details: {
var values = [entry.source.label]
if let duration = entry.durationMs {
values.append("耗时 \(duration) ms")
}
return values
}()
)
}
}
}
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"连接探测记录",
systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条",
actionTitle: "清理",
actionSystemImage: "trash",
actionKind: .danger
) {
showsPruneDialog = true
}
if model.filteredConnections.isEmpty {
AICard {
AIEmptyState(
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
)
}
} else {
ForEach(model.filteredConnections) { entry in
AIListCard(
title: entry.instanceName,
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
status: entry.outcome.label,
statusTint: outcomeTint(entry.outcome),
details: [
"HTTP \(entry.httpStatus.map(String.init) ?? "—") · \(entry.durationMs) ms",
entry.errorCode ?? "无错误码",
]
)
}
}
}
}
private var diagnosticSection: some View {
VStack(alignment: .leading, spacing: 10) {
AISectionTitle(
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty {
AICard {
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
}
} else {
ForEach(model.filteredDiagnostics) { diagnostic in
AIListCard(
title: diagnostic.name,
status: diagnostic.healthStatus ?? "未知",
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
details: [
diagnostic.origin,
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
]
) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
alignment: .leading,
spacing: 6
) {
ForEach(diagnostic.featureStatus) { feature in
AITag(feature.name, tint: featureTint(feature.state))
}
if let connection = diagnostic.connection, connection.total > 1 {
AITag(
"延迟 \(connection.averageDurationMs) ms",
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
)
}
}
}
}
}
}
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level {
case .info: return AITheme.success
case .warning: return AITheme.warning
case .error: return AITheme.danger
}
}
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
switch outcome {
case .success: return AITheme.success
case .stale: return AITheme.warning
case .failed: return AITheme.danger
case .unsupported: return AITheme.muted
}
}
private func featureTint(_ state: FeatureStatus.State) -> Color {
switch state {
case .ready: return AITheme.success
case .requiresLogin: return AITheme.warning
case .unavailable: return AITheme.muted
}
}
}
@@ -0,0 +1,131 @@
import Foundation
@MainActor
final class LogsViewModel: ObservableObject {
@Published private(set) var snapshot: LogSnapshot?
@Published private(set) var instances: [Instance] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published var selectedSource: RuntimeLogSource?
@Published var selectedLevel: RuntimeLogLevel?
@Published var selectedOutcome: ConnectionOutcome?
@Published var selectedInstanceID: UUID?
@Published var searchText = ""
private let repository: any InstanceRepository
private let service: LogCenterService
private let store: any LogStore
let autoRefreshOnAppear: Bool
init(
repository: any InstanceRepository,
service: LogCenterService,
store: any LogStore,
autoRefreshOnAppear: Bool = true
) {
self.repository = repository
self.service = service
self.store = store
self.autoRefreshOnAppear = autoRefreshOnAppear
}
var filteredRuntime: [RuntimeLogEntry] {
guard let snapshot else { return [] }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return snapshot.runtime.filter { entry in
(selectedSource == nil || entry.source == selectedSource)
&& (selectedLevel == nil || entry.level == selectedLevel)
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
&& (query.isEmpty || matches(entry.message, query)
|| matches(entry.code, query)
|| matches(entry.eventType, query)
|| matches(entry.instanceName, query))
}
}
var filteredConnections: [ConnectionLogEntry] {
guard let snapshot else { return [] }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return snapshot.connections.filter { entry in
(selectedOutcome == nil || entry.outcome == selectedOutcome)
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
&& (query.isEmpty || matches(entry.errorCode, query)
|| matches(entry.instanceName, query))
}
}
var filteredDiagnostics: [InstanceDiagnostic] {
guard let snapshot else { return [] }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !query.isEmpty else { return snapshot.diagnostics }
return snapshot.diagnostics.filter { diagnostic in
matches(diagnostic.name, query)
|| matches(diagnostic.origin, query)
|| diagnostic.tags.contains { matches($0, query) }
}
}
var offlineCount: Int {
snapshot?.diagnostics.filter { $0.state == .offline }.count ?? 0
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
let fetched = try await service.refresh(loaded)
let storedRuntime = try await store.runtimeLogs()
let storedConnections = try await store.connectionLogs()
var runtime = fetched.runtime
for entry in storedRuntime where !runtime.contains(where: { $0.id == entry.id }) {
runtime.append(entry)
}
runtime.sort { $0.occurredAt > $1.occurredAt }
var connections = fetched.connections
for entry in storedConnections where !connections.contains(where: { $0.id == entry.id }) {
connections.append(entry)
}
connections.sort { $0.observedAt > $1.observedAt }
snapshot = LogSnapshot(
observedAt: fetched.observedAt,
runtime: Array(runtime.prefix(500)),
counts: Self.recount(runtime),
connections: Array(connections.prefix(200)),
diagnostics: fetched.diagnostics
)
} catch {
errorMessage = "无法加载日志中心。"
}
isLoading = false
}
func pruneConnections(olderThanDays days: Int) async -> Int {
let cutoff = Date().addingTimeInterval(TimeInterval(-days * 24 * 60 * 60))
do {
let removed = try await store.pruneConnections(before: cutoff)
if removed > 0 {
await refresh()
}
return removed
} catch {
errorMessage = "清理连接日志失败。"
return 0
}
}
private func matches(_ value: String?, _ query: String) -> Bool {
value?.localizedLowercase.contains(query) == true
}
private static func recount(_ runtime: [RuntimeLogEntry]) -> [RuntimeLogSource: Int] {
var counts: [RuntimeLogSource: Int] = [:]
for source in RuntimeLogSource.allCases {
counts[source] = runtime.filter { $0.source == source }.count
}
return counts
}
}
@@ -0,0 +1,74 @@
import Foundation
public enum SMSDirection: String, Equatable, Sendable {
case incoming
case outgoing
case unknown
public var label: String {
switch self {
case .incoming: return "收件"
case .outgoing: return "发件"
case .unknown: return "未知"
}
}
}
public struct SMSMessage: Equatable, Identifiable, Sendable {
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
public let upstreamID: String
public let instanceID: UUID
public let instanceName: String
public let phoneNumber: String
public let content: String
public let direction: SMSDirection
public let status: String
public let timestamp: String
public var id: String { "\(instanceID.uuidString)#\(upstreamID)" }
/// 上游 status 是英文原词,列表里直接展示会混进一行英文;未知值仍然原样显示,不做假结论。
public var statusLabel: String? {
let key = status.trimmingCharacters(in: .whitespaces).lowercased()
guard key.isEmpty == false else { return nil }
switch key {
case "received", "in", "incoming": return "已接收"
case "sent": return "已发送"
case "delivered": return "已送达"
case "read": return "已读"
case "failed", "error": return "发送失败"
case "pending", "queued", "sending": return "发送中"
default: return status
}
}
/// 收件的"已接收"和方向标签说的是同一件事,只有发件状态才值得再占一个位置。
public var deliveryLabel: String? {
guard direction == .outgoing else { return nil }
return statusLabel
}
public init(
upstreamID: String,
instanceID: UUID,
instanceName: String,
phoneNumber: String,
content: String,
direction: SMSDirection,
status: String,
timestamp: String
) {
self.upstreamID = upstreamID
self.instanceID = instanceID
self.instanceName = instanceName
self.phoneNumber = phoneNumber
self.content = content
self.direction = direction
self.status = status
self.timestamp = timestamp
}
}
public struct MessagePage: Equatable, Sendable {
public let messages: [SMSMessage]
public let total: Int
}
@@ -0,0 +1,124 @@
import Foundation
public enum MessageServiceError: Error, Equatable {
case invalidInput
case invalidResponse
case unauthorized
}
public struct MessageService: Sendable {
private let transport: any NodeTransport
public init(transport: any NodeTransport) {
self.transport = transport
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
}
public func messages(
_ instances: [Instance],
limit: Int = 50,
direction: SMSDirection? = nil
) async throws -> MessagePage {
let directionText: String?
switch direction {
case .incoming: directionText = "incoming"
case .outgoing: directionText = "outgoing"
default: directionText = nil
}
var collected: [SMSMessage] = []
var total = 0
var lastError: Error?
for instance in instances {
do {
let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance)
let page = try Self.parsePage(response, instance)
collected.append(contentsOf: page.messages)
total += page.total
} catch {
lastError = error
}
}
if collected.isEmpty && total == 0, let lastError {
throw lastError
}
collected.sort { $0.timestamp > $1.timestamp }
return MessagePage(messages: collected, total: total)
}
public func send(
_ instance: Instance,
phoneNumber rawPhoneNumber: String,
content rawContent: String
) async throws {
let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phoneNumber.isEmpty, !content.isEmpty else {
throw MessageServiceError.invalidInput
}
_ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance)
}
public func delete(_ ids: [String], from instance: Instance) async throws {
let numericIDs = ids.compactMap(Int.init)
guard !numericIDs.isEmpty else {
throw MessageServiceError.invalidInput
}
_ = try await request(.smsBatchDelete(ids: numericIDs), instance)
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
}
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
let root = try JSONSerialization.jsonObject(with: response.data)
guard let container = Self.dataContainer(root),
let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any]
else {
throw MessageServiceError.invalidResponse
}
let rawMessages = (data["messages"] as? [Any]) ?? []
let messages = rawMessages.compactMap { Self.message($0, instance) }
let total = (data["total"] as? Int) ?? messages.count
return MessagePage(messages: messages, total: total)
}
private static func dataContainer(_ root: Any) -> [String: Any]? {
root as? [String: Any]
}
private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? {
guard let value = raw as? [String: Any] else { return nil }
let id: String
if let number = value["id"] as? Int {
id = String(number)
} else if let text = value["id"] as? String {
id = text
} else {
return nil
}
let directionText = (value["direction"] as? String)?.lowercased() ?? ""
let direction: SMSDirection = directionText == "incoming" ? .incoming
: directionText == "outgoing" ? .outgoing
: .unknown
return SMSMessage(
upstreamID: id,
instanceID: instance.id,
instanceName: instance.name,
phoneNumber: value["phone_number"] as? String ?? "",
content: value["content"] as? String ?? "",
direction: direction,
status: value["status"] as? String ?? "",
timestamp: value["timestamp"] as? String ?? ""
)
}
}
@@ -0,0 +1,189 @@
import SwiftUI
struct MessagesView: View {
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) {
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
// 所以调试开关必须先落到这个具体的 model 对象。
let target = model ?? DefaultContainer.makeMessagesModel()
#if DEBUG
if DebugLaunch.has("--sheet-send") {
target.showingSendForm = true
}
#endif
_model = StateObject(wrappedValue: target)
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
} else if model.filteredRows.isEmpty {
AICard {
if let errorMessage = model.errorMessage {
AIEmptyState(
systemImage: "arrow.triangle.2.circlepath",
title: "未能读取消息",
message: errorMessage,
actionTitle: "重试"
) {
Task { await model.refresh() }
}
} else {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "所有节点都没有返回短信。"
)
}
}
} else {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.toolbar {
// 发送入口和节点页的「添加实例」保持一致:放在导航栏右侧,
// 底部固定条在 TabView 里会被自定义标签栏盖住,之前整条 CTA 是不可达的。
ToolbarItem(placement: .primaryAction) {
HStack(spacing: 8) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
AIIconButton("发送消息", systemImage: "paperplane") {
model.showingSendForm = true
}
}
}
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
}
}
private var filterBar: some View {
VStack(spacing: 8) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface(compact: true)
// 选择器右侧原本是一整片空白,这里补上当前条数,让这一行读完就知道结果规模。
.overlay(alignment: .trailing) {
Text("\(model.filteredRows.count) 条")
.font(.caption.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.muted)
.padding(.trailing, 12)
.accessibilityHidden(true)
}
AISearchField("搜索号码或内容", text: $model.searchText, minHeight: AITheme.compactControlHeight)
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
AITag(
message.direction.label,
systemImage: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
Spacer(minLength: 8)
Text(TimestampText.short(message.timestamp))
.font(.caption2.weight(.medium))
.monospacedDigit()
.foregroundStyle(AITheme.faint)
.lineLimit(1)
.accessibilityLabel("时间 \(message.timestamp)")
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
// 删除按钮的点击区是 44pt,留在行内会把这一行撑到 44 高,正文与落款之间
// 因此凭空多出一条约 22pt 的空带。这里让落款按自身高度排,点击区挂在
// 行尾并垂直居中,多出的 12pt 落进卡片内边距,正文右缘留出等宽避让。
HStack(alignment: .center, spacing: 8) {
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption)
.labelStyle(.titleAndIcon)
.imageScale(.small)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
if let delivery = message.deliveryLabel {
Text(delivery)
.font(.caption2.weight(.medium))
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
.lineLimit(1)
}
}
.padding(.trailing, 44)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .trailing) {
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger, quiet: true) {
pendingDeleteMessage = message
}
// 图标在 44pt 点击区里居中,右移半个内边距让图标右缘与正文右缘对齐。
.offset(x: 12)
}
}
}
}
}
@@ -0,0 +1,92 @@
import Foundation
@MainActor
final class MessagesViewModel: ObservableObject {
enum DirectionFilter: String, CaseIterable, Identifiable {
case all = "全部"
case incoming = "收件"
case outgoing = "发件"
var id: String { rawValue }
}
@Published var rows: [SMSMessage] = []
@Published var instances: [Instance] = []
@Published var selectedInstanceID: UUID?
@Published var searchText = ""
@Published var direction: DirectionFilter = .all
@Published var isLoading = false
@Published var errorMessage: String?
@Published var showingSendForm = false
private let repository: any InstanceRepository
private let service: MessageService
init(repository: any InstanceRepository, service: MessageService) {
self.repository = repository
self.service = service
}
var filteredRows: [SMSMessage] {
rows.filter { message in
selectedInstanceID == nil || message.instanceID == selectedInstanceID
}.filter { message in
switch direction {
case .all: return true
case .incoming: return message.direction == .incoming
case .outgoing: return message.direction == .outgoing
}
}.filter { message in
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return true }
return message.phoneNumber.localizedCaseInsensitiveContains(query)
|| message.content.localizedCaseInsensitiveContains(query)
|| message.instanceName.localizedCaseInsensitiveContains(query)
}
}
var selectedInstance: Instance? {
instances.first { $0.id == selectedInstanceID }
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
if selectedInstanceID == nil || !loaded.contains(where: { $0.id == selectedInstanceID }) {
selectedInstanceID = nil
}
let page = try await service.messages(loaded, limit: 100)
rows = page.messages
} catch MessageServiceError.unauthorized, UpstreamError.unauthorized {
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
} catch let error as UpstreamError {
// 中继模式下失败原因有四类以上,各自对应不同处理动作;
// 统一兜底文案会把"重试即可"和"需要重新配对"说成同一件事。
errorMessage = ErrorMessage.describe(error, nodeName: selectedInstance?.name ?? "节点")
} catch {
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
}
isLoading = false
}
func send(phoneNumber: String, content: String) async throws {
guard let instance = selectedInstance ?? instances.first else {
throw MessageServiceError.invalidInput
}
try await service.send(instance, phoneNumber: phoneNumber, content: content)
await refresh()
}
func delete(_ message: SMSMessage) async {
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
do {
try await service.delete([message.id], from: instance)
rows.removeAll { $0.id == message.id }
} catch {
errorMessage = "删除消息失败。"
}
}
}
@@ -0,0 +1,126 @@
import SwiftUI
struct SendMessageFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
@State private var isSending = false
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!model.instances.isEmpty && !trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("实例")
Picker(
"实例",
selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)
) {
if model.instances.isEmpty {
Text("暂无实例").tag(Optional<UUID>.none)
}
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("收件号码")
.submitLabel(.next)
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("消息内容")
.submitLabel(.send)
.onSubmit {
if canSend { send() }
}
.lineLimit(4...8)
}
}
}
if let validationMessage {
AIStatusBanner(
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
send()
} label: {
HStack(spacing: 8) {
if isSending {
ProgressView()
.controlSize(.small)
}
Text(isSending ? "发送中" : "发送")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend || isSending)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("发送消息")
#if os(iOS)
// 与「添加实例」一致:表行内标题,避免大标题在表单上方留出一整条空白。
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
private func send() {
guard !isSending else { return }
isSending = true
Task {
do {
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
}
isSending = false
}
}
}
@@ -0,0 +1,208 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var model: SettingsViewModel
@ObservedObject private var themeStore = ThemeStore.shared
@State private var showsClearDialog = false
init(model: SettingsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel())
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let notice = model.notice {
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
themeCard
runtimeCard
storageCard
securityCard
dangerZoneCard
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("设置")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.refreshable {
await model.refresh()
}
.confirmationDialog(
"确认清除运行日志?此操作不可恢复。",
isPresented: $showsClearDialog,
titleVisibility: .visible
) {
Button("清除运行日志", role: .destructive) {
Task { await model.clearRuntimeLogs() }
}
Button("取消", role: .cancel) {}
}
.task {
await model.refresh()
}
}
private var runtimeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
AIKeyValueRow("版本", value: runtime.marketingVersion)
// 构建号单独一行:同一营销版本会连续出多个包,只写版本号分不清装的是哪个。
AIKeyValueRow("构建", value: runtime.buildVersion)
AIKeyValueRow("系统", value: runtime.systemVersion)
} else {
Text("正在读取应用信息…")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
}
}
}
private var themeCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
AISectionTitle("主题外观", systemImage: "circle.lefthalf.filled")
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
GridRow {
ForEach(ThemeMode.allCases) { mode in
themeOption(mode)
}
}
}
}
}
}
private func themeOption(_ mode: ThemeMode) -> some View {
let isSelected = themeStore.mode == mode
return Button {
themeStore.set(mode)
} label: {
VStack(spacing: 7) {
Image(systemName: mode.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.accessibilityHidden(true)
Text(mode.label)
.font(.caption.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, minHeight: 68)
// 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。
.background(isSelected ? AITheme.primary : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(isSelected ? Color.clear : AITheme.line)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("主题:\(mode.label)")
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
}
private var storageCard: some View {
AICard {
NavigationLink {
LogsView()
} label: {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
AISectionTitle("本地数据", systemImage: "externaldrive")
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
}
AIKeyValueRow("日志记录", value: model.storageLabel)
}
}
.buttonStyle(.plain)
.accessibilityHint("进入日志与诊断")
}
}
private var securityCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("隐私与数据", systemImage: "lock.shield")
AIKeyValueRow("节点与凭据", value: "仅写入本机存储")
AIKeyValueRow("网络请求", value: "只访问你添加的节点")
// 卡内再套一个描边框会变成"卡中卡",这里退成一段脚注。
HStack(alignment: .top, spacing: 7) {
Image(systemName: "checkmark.seal")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AITheme.success)
.padding(.top, 2)
.accessibilityHidden(true)
Text("不经过中转服务器,也不上传遥测数据;节点地址、密码与日志只保存在这台 iPhone 上,删除应用即彻底清除。")
.font(.caption)
.foregroundStyle(AITheme.muted)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 2)
}
}
}
private var dangerZoneCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
Button {
showsClearDialog = true
} label: {
HStack(spacing: 8) {
Image(systemName: "trash")
.font(.system(size: 14, weight: .semibold))
.accessibilityHidden(true)
Text("清除运行日志")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.opacity(0.7)
.accessibilityHidden(true)
}
.foregroundStyle(AITheme.danger)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.compactControlHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AITheme.danger.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(AITheme.danger.opacity(0.20))
}
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
}
@@ -0,0 +1,79 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
struct SettingsRuntime: Equatable {
let appName: String
let marketingVersion: String
let buildVersion: String
let platform: String
let systemVersion: String
}
@MainActor
final class SettingsViewModel: ObservableObject {
@Published private(set) var runtime: SettingsRuntime?
@Published private(set) var isLoading = false
@Published var storageLabel = "—"
@Published var notice: String?
@Published var errorMessage: String?
private let store: any LogStore
init(store: any LogStore) {
self.store = store
}
func refresh() async {
isLoading = true
errorMessage = nil
runtime = SettingsRuntime(
appName: AppMetadata.name,
marketingVersion: AppMetadata.marketingVersion,
buildVersion: AppMetadata.buildVersion,
platform: "iOS",
systemVersion: Self.systemVersion()
)
await loadStorageLabel()
isLoading = false
}
func clearRuntimeLogs() async -> Int {
do {
let removed = try await store.clearRuntimeLogs()
notice = removed > 0 ? "已清除 \(removed) 条运行日志。" : "没有可清除的运行日志。"
errorMessage = nil
await loadStorageLabel()
return removed
} catch {
notice = nil
errorMessage = "清除运行日志失败。"
return 0
}
}
private func loadStorageLabel() async {
do {
let runtimeCount = try await store.runtimeLogs().count
let connectionCount = try await store.connectionLogs().count
storageLabel = "\(runtimeCount) 条运行日志 · \(connectionCount) 条连接日志"
} catch {
storageLabel = "—"
errorMessage = "无法读取本地数据状态。"
}
}
/// 只报对用户有意义的系统版本,不暴露机器标识之类的原始信息。
private static func systemVersion() -> String {
#if canImport(UIKit)
let device = UIDevice.current
return "\(device.systemName) \(device.systemVersion)"
#else
let version = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(version.majorVersion).\(version.minorVersion)"
#endif
}
}
@@ -0,0 +1,185 @@
import Foundation
public enum RepositoryError: Error, Equatable, Sendable {
case invalidOrigin
case duplicateOrigin
case revisionConflict
case notFound
case databaseFailed
}
public struct Instance: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var name: String
/// Where this node is reached from. Direct on the local network, or through a relay.
///
/// This replaced a non-optional `origin: URL`. The migration in `init(from:)` reads the old
/// shape so an existing node list upgrades without being reported as duplicate, and every write
/// emits only the new shape.
public var endpoint: InstanceEndpoint
public var tags: [String]
public var credentialConfigured: Bool
public var configRevision: Int
public let createdAt: Date
public var updatedAt: Date
public init(
id: UUID,
name: String,
endpoint: InstanceEndpoint,
tags: [String] = [],
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.name = name
self.endpoint = endpoint
self.tags = tags
self.credentialConfigured = credentialConfigured
self.configRevision = configRevision
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convenience for the direct case, which is what most call sites mean.
public init(
id: UUID,
name: String,
origin: URL,
tags: [String] = [],
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
updatedAt: Date
) {
self.init(
id: id,
name: name,
endpoint: .direct(origin),
tags: tags,
credentialConfigured: credentialConfigured,
configRevision: configRevision,
createdAt: createdAt,
updatedAt: updatedAt
)
}
/// The direct origin, when this instance has one.
public var origin: URL? { endpoint.directOrigin }
public var isRelay: Bool { endpoint.isRelay }
private enum CodingKeys: String, CodingKey {
case id, name, endpoint, origin, tags, credentialConfigured, configRevision, createdAt, updatedAt
}
/// Decodes the new shape, falling back to the pre-relay `origin` field.
///
/// The fallback is what makes the upgrade invisible: an app that dropped support for the old key
/// would fail to decode the whole store and lose every node. Note that `origin` is decoded
/// leniently as a string and parsed here rather than decoded as a `URL`, because the encoder
/// that wrote it may have produced a spelling `URL` will not round-trip from a bare string in
/// every case.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
if let endpoint = try container.decodeIfPresent(InstanceEndpoint.self, forKey: .endpoint) {
self.endpoint = endpoint
} else if let origin = try container.decodeIfPresent(URL.self, forKey: .origin) {
self.endpoint = .direct(origin)
} else {
throw DecodingError.keyNotFound(
CodingKeys.endpoint,
DecodingError.Context(
codingPath: container.codingPath,
debugDescription: "an instance must carry either endpoint or the legacy origin"
)
)
}
tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
credentialConfigured = try container.decodeIfPresent(Bool.self, forKey: .credentialConfigured) ?? false
configRevision = try container.decodeIfPresent(Int.self, forKey: .configRevision) ?? 1
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(endpoint, forKey: .endpoint)
try container.encode(tags, forKey: .tags)
try container.encode(credentialConfigured, forKey: .credentialConfigured)
try container.encode(configRevision, forKey: .configRevision)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
}
}
public struct InstanceInput: Sendable {
public var name: String
public var endpoint: InstanceEndpoint
public var tags: [String]
public var password: String?
public init(
name: String,
endpoint: InstanceEndpoint,
tags: [String] = [],
password: String? = nil
) {
self.name = name
self.endpoint = endpoint
self.tags = tags
self.password = password
}
public init(
name: String,
origin: URL,
tags: [String] = [],
password: String? = nil
) {
self.init(name: name, endpoint: .direct(origin), tags: tags, password: password)
}
}
public struct InstancePatch: Sendable {
public var name: String?
public var endpoint: InstanceEndpoint?
public var tags: [String]?
public var password: String?
public init(
name: String? = nil,
endpoint: InstanceEndpoint? = nil,
tags: [String]? = nil,
password: String? = nil
) {
self.name = name
self.endpoint = endpoint
self.tags = tags
self.password = password
}
}
public struct InstanceQuery: Sendable {
public var search: String?
public var tags: [String]?
public init(search: String? = nil, tags: [String]? = nil) {
self.search = search
self.tags = tags
}
}
public protocol InstanceRepository: Sendable {
func list(_ query: InstanceQuery) async throws -> [Instance]
func get(_ id: UUID) async throws -> Instance?
func create(_ input: InstanceInput) async throws -> Instance
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
func delete(_ id: UUID, expectedRevision: Int) async throws
}
@@ -0,0 +1,148 @@
import SwiftUI
@main
struct MultiSimAdminApp: App {
@StateObject private var themeStore = ThemeStore.shared
@State private var tab: AppTab
init() {
let arguments = ProcessInfo.processInfo.arguments
var initial: AppTab = .fleet
if arguments.contains("--tab-messages") {
initial = .messages
} else if arguments.contains("--tab-settings") {
initial = .settings
}
#if DEBUG
if arguments.contains("--tab-detail") {
initial = .debugDetail
}
if arguments.contains("--tab-logs") {
initial = .debugLogs
}
#endif
_tab = State(initialValue: initial)
}
var body: some Scene {
WindowGroup {
Group {
switch tab {
case .fleet:
NavigationStack { FleetView().aiInlineTitle() }
case .messages:
NavigationStack { MessagesView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
#if DEBUG
case .debugDetail:
NavigationStack { DebugDetailView().aiInlineTitle() }
case .debugLogs:
NavigationStack { LogsView().aiInlineTitle() }
#endif
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
AITabBar(selection: $tab)
}
.tint(AITheme.primary)
.preferredColorScheme(themeStore.mode.colorScheme)
#if os(iOS)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
#endif
}
}
}
#if os(iOS)
extension View {
fileprivate func aiInlineTitle() -> some View {
navigationBarTitleDisplayMode(.inline)
}
}
#else
extension View {
fileprivate func aiInlineTitle() -> some View { self }
}
#endif
enum AppTab: Hashable {
case fleet
case messages
case settings
#if DEBUG
case debugDetail
case debugLogs
#endif
}
#if DEBUG
/// 本地截图验证用的入口,Release 构建不包含。
struct DebugDetailView: View {
@State private var target: Instance?
var body: some View {
Group {
if let instance = target {
InstanceDetailView(instance: instance)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task {
let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json"))
guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return }
// 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。
target = listed.first { $0.endpoint.directOrigin?.port == 3000 } ?? listed[0]
}
}
}
#endif
struct AITabBar: View {
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
tab(.fleet, "节点", "server.rack")
tab(.messages, "消息", "message")
tab(.settings, "设置", "gearshape")
}
.frame(height: 62)
.background {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
Rectangle()
.fill(AITheme.chrome)
}
.ignoresSafeArea(edges: .bottom)
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View {
let isSelected = selection == value
return Button {
selection = value
} label: {
VStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
}
@@ -0,0 +1,214 @@
import Foundation
public struct FileInstanceRepository: InstanceRepository {
private struct Store: Codable {
var instances: [Instance] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository")
public init(location: URL) {
self.location = location
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}
public func list(_ query: InstanceQuery) async throws -> [Instance] {
try await run { store in
var instances = store.instances
if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines),
!search.isEmpty {
instances = instances.filter {
$0.name.localizedCaseInsensitiveContains(search)
|| $0.endpoint.displayAddress.localizedCaseInsensitiveContains(search)
}
}
if let tags = query.tags, !tags.isEmpty {
instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) }
}
return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
public func get(_ id: UUID) async throws -> Instance? {
try await run { $0.instances.first { $0.id == id } }
}
public func create(_ input: InstanceInput) async throws -> Instance {
try await run { store in
guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RepositoryError.invalidOrigin
}
let endpoint = try Self.normalized(input.endpoint)
guard !store.instances.contains(where: { $0.endpoint.dedupKey == endpoint.dedupKey }) else {
throw RepositoryError.duplicateOrigin
}
let now = Date()
let instance = Instance(
id: UUID(),
name: input.name.trimmingCharacters(in: .whitespacesAndNewlines),
endpoint: endpoint,
tags: Self.normalizedTags(input.tags),
credentialConfigured: !(input.password ?? "").isEmpty,
createdAt: now,
updatedAt: now
)
store.instances.append(instance)
return instance
}
}
public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
try await run { store in
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard store.instances[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
var instance = store.instances[index]
if let name = patch.name {
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin }
instance.name = normalized
}
if let endpoint = patch.endpoint {
let normalized = try Self.normalized(endpoint)
guard !store.instances.contains(where: {
$0.id != id && $0.endpoint.dedupKey == normalized.dedupKey
}) else {
throw RepositoryError.duplicateOrigin
}
instance.endpoint = normalized
}
if let tags = patch.tags {
instance.tags = Self.normalizedTags(tags)
}
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}
instance.updatedAt = Date()
instance.configRevision += 1
store.instances[index] = instance
return instance
}
}
public func delete(_ id: UUID, expectedRevision: Int) async throws {
try await run { store in
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard store.instances[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
store.instances.remove(at: index)
}
}
private func run<T: Sendable>(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var store = try self.readStore()
let output = try transform(&store)
try self.writeStore(store)
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func readStore() throws -> Store {
guard FileManager.default.fileExists(atPath: location.path) else {
return Store()
}
do {
return try decoder.decode(Store.self, from: Data(contentsOf: location))
} catch {
throw RepositoryError.databaseFailed
}
}
private func writeStore(_ store: Store) throws {
do {
let directory = location.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try encoder.encode(store).write(to: location, options: [.atomic])
} catch {
throw RepositoryError.databaseFailed
}
}
private static func normalized(_ endpoint: InstanceEndpoint) throws -> InstanceEndpoint {
switch endpoint {
case .direct(let url):
return .direct(try normalizedOrigin(url))
case .relay(let relay):
// A relay endpoint is validated on its own terms: https, a parseable node id, and the
// exact key sizes the envelope format requires. A half-configured relay entry that
// reached the transport would fail as an unreadable response rather than as "this
// pairing is incomplete".
guard relay.relayBaseURL.scheme == "https",
relay.relayBaseURL.host?.isEmpty == false,
relay.relayBaseURL.path.isEmpty || relay.relayBaseURL.path == "/"
else {
throw RepositoryError.invalidOrigin
}
guard relay.nodeID.hasPrefix("nd_"), relay.nodeID.count >= 11 else {
throw RepositoryError.invalidOrigin
}
guard relay.agentSigningKey.count == RelayWire.keyBytes,
relay.agentKxKey.count == RelayWire.keyBytes,
relay.deviceToken.count >= 16,
relay.deviceID.hasPrefix("ap_")
else {
throw RepositoryError.invalidOrigin
}
var normalized = relay
normalized.relayBaseURL = URL(string: relay.relayBaseURL.absoluteString.trimmingCharacters(
in: CharacterSet(charactersIn: "/")
)) ?? relay.relayBaseURL
return .relay(normalized)
}
}
private static func normalizedOrigin(_ url: URL) throws -> URL {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme == "http" || components.scheme == "https",
let host = components.host, !host.isEmpty
else {
throw RepositoryError.invalidOrigin
}
guard components.path.isEmpty || components.path == "/" else {
throw RepositoryError.invalidOrigin
}
var normalized = components
normalized.path = ""
normalized.query = nil
normalized.fragment = nil
guard let result = normalized.url else {
throw RepositoryError.invalidOrigin
}
return result
}
private static func normalizedTags(_ tags: [String]) -> [String] {
var seen = Set<String>()
return tags
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
}
}
@@ -0,0 +1,112 @@
import Foundation
public struct FileLogStore: LogStore {
private struct Store: Codable {
var runtime: [RuntimeLogEntry] = []
var connections: [ConnectionLogEntry] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.log-store")
public init(location: URL) {
self.location = location
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}
public func runtimeLogs() async throws -> [RuntimeLogEntry] {
try await run { $0.runtime.sorted { $0.occurredAt > $1.occurredAt } }
}
public func connectionLogs() async throws -> [ConnectionLogEntry] {
try await run { $0.connections.sorted { $0.observedAt > $1.observedAt } }
}
public func append(
runtime: [RuntimeLogEntry],
connections: [ConnectionLogEntry]
) async throws {
try await run { store in
var mergedRuntime = store.runtime
for entry in runtime where !mergedRuntime.contains(where: { $0.id == entry.id }) {
mergedRuntime.append(entry)
}
mergedRuntime.sort { $0.occurredAt > $1.occurredAt }
store.runtime = Array(mergedRuntime.prefix(2_000))
var mergedConnections = store.connections
for entry in connections
where !mergedConnections.contains(where: { $0.id == entry.id }) {
mergedConnections.append(entry)
}
mergedConnections.sort { $0.observedAt > $1.observedAt }
store.connections = Array(mergedConnections.prefix(20_000))
}
}
public func pruneConnections(before cutoff: Date) async throws -> Int {
try await run { store in
let retained = store.connections.filter { $0.observedAt >= cutoff }
let removed = store.connections.count - retained.count
store.connections = retained
return removed
}
}
public func clearRuntimeLogs() async throws -> Int {
try await run { store in
let removed = store.runtime.count
store.runtime = []
return removed
}
}
private func run<T: Sendable>(
_ transform: @escaping @Sendable (inout Store) throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var store = try self.readStore()
let result = try transform(&store)
try self.writeStore(store)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func readStore() throws -> Store {
guard FileManager.default.fileExists(atPath: location.path) else {
return Store()
}
do {
return try decoder.decode(Store.self, from: Data(contentsOf: location))
} catch {
return Store()
}
}
private func writeStore(_ store: Store) throws {
do {
try FileManager.default.createDirectory(
at: location.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try encoder.encode(store).write(to: location, options: [.atomic])
} catch {
throw RepositoryError.databaseFailed
}
}
}
@@ -0,0 +1,348 @@
import Foundation
/// The canonical JSON form this protocol hashes.
///
/// This is a hand-written parser rather than `JSONSerialization`, and the reason is not preference:
/// the canonical form is defined over the *spelling* of the input, and every Foundation decoder
/// erases spelling before the caller sees anything. `1.0` and `1` both arrive as `NSNumber(1)`;
/// a lone surrogate is either rejected outright or silently repaired depending on the framework
/// version. Trying to recover the original text from a decoded tree is how two languages end up
/// hashing different bytes for the same body, which surfaces as an intent mismatch the phone
/// reports as "confirmation failed" with no way to tell which side was wrong.
///
/// The rules, which the Go agent and the Node reference implementation implement identically, are
/// documented in `agent/internal/wire/json.go`. The short version: keys sorted by UTF-8 bytes,
/// no insignificant whitespace, integers only in canonical spelling, strings escaped as `\" \\ \n
/// \r \t \b \f`, `\u00XX` for other control characters, literal UTF-8 for everything else, and a
/// lone surrogate escape is an error rather than a replacement character.
public enum CanonicalJSON {
public enum Failure: Error, Equatable {
case invalidJSON
case trailingData
case nonCanonicalNumber(String)
case loneSurrogate(UInt32)
case unsupportedValue
}
/// Canonicalises a JSON document. An empty or whitespace-only body canonicalises to no bytes at
/// all, not to the four characters `null`, so "no body" and "empty body" cannot be confused.
public static func canonicalize(_ text: String) throws -> Data {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Data() }
var parser = Parser(Array(trimmed.unicodeScalars))
let value = try parser.parseValue()
parser.skipWhitespace()
guard parser.isAtEnd else { throw Failure.trailingData }
var out = Data()
try serialize(value, into: &out)
return out
}
/// Canonicalises raw body bytes, which is what the intent digest needs.
public static func canonicalize(_ data: Data) throws -> Data {
guard let text = String(data: data, encoding: .utf8) else { throw Failure.invalidJSON }
return try canonicalize(text)
}
// MARK: - Value tree
indirect enum Value {
case null
case bool(Bool)
case integer(String)
case string(String)
case array([Value])
case object([(key: String, value: Value)])
}
// MARK: - Serialisation
static func serialize(_ value: Value, into out: inout Data) throws {
switch value {
case .null:
out.append(contentsOf: Array("null".utf8))
case .bool(let flag):
out.append(contentsOf: Array((flag ? "true" : "false").utf8))
case .integer(let literal):
out.append(contentsOf: Array(literal.utf8))
case .string(let text):
escape(text, into: &out)
case .array(let items):
out.append(UInt8(ascii: "["))
for (index, item) in items.enumerated() {
if index > 0 { out.append(UInt8(ascii: ",")) }
try serialize(item, into: &out)
}
out.append(UInt8(ascii: "]"))
case .object(let members):
// UTF-8 byte order, which is what the Go agent's sort.Strings produces. Two shortcuts
// are wrong here and both look right on ASCII: Swift's default String `<` uses Unicode
// collation (so "é" sorts before "z"), and Foundation's `.sortedKeys` does the same.
let sorted = members.sorted { Array($0.key.utf8).lexicographicallyPrecedes(Array($1.key.utf8)) }
out.append(UInt8(ascii: "{"))
for (index, member) in sorted.enumerated() {
if index > 0 { out.append(UInt8(ascii: ",")) }
escape(member.key, into: &out)
out.append(UInt8(ascii: ":"))
try serialize(member.value, into: &out)
}
out.append(UInt8(ascii: "}"))
}
}
/// Writes a JSON string literal in the canonical spelling.
///
/// The two rules worth stating: control characters below 0x20 use the short forms JSON names
/// where they exist and `\u00XX` otherwise (matching Go's encoder with HTML escaping off), and
/// everything else — including U+2028, U+2029, DEL and astral characters — is emitted as
/// literal UTF-8. Go's encoder escapes U+2028/U+2029 even with HTML escaping disabled, which is
/// why the Go side had to grow its own writer too; the vectors pin the literal form.
static func escape(_ text: String, into out: inout Data) {
out.append(UInt8(ascii: "\""))
for scalar in text.unicodeScalars {
switch scalar {
case "\"":
out.append(contentsOf: Array("\\\"".utf8))
case "\\":
out.append(contentsOf: Array("\\\\".utf8))
case "\n":
out.append(contentsOf: Array("\\n".utf8))
case "\r":
out.append(contentsOf: Array("\\r".utf8))
case "\t":
out.append(contentsOf: Array("\\t".utf8))
case Unicode.Scalar(0x08):
out.append(contentsOf: Array("\\b".utf8))
case Unicode.Scalar(0x0C):
out.append(contentsOf: Array("\\f".utf8))
default:
if scalar.value < 0x20 {
let hex = String(format: "%04x", scalar.value)
out.append(contentsOf: Array("\\u\(hex)".utf8))
} else {
out.append(contentsOf: Array(String(scalar).utf8))
}
}
}
out.append(UInt8(ascii: "\""))
}
// MARK: - Parser
struct Parser {
private let scalars: [Unicode.Scalar]
private var index = 0
init(_ scalars: [Unicode.Scalar]) {
self.scalars = scalars
}
var isAtEnd: Bool { index >= scalars.count }
mutating func skipWhitespace() {
while index < scalars.count, isSpace(scalars[index]) { index += 1 }
}
private func isSpace(_ scalar: Unicode.Scalar) -> Bool {
scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
}
private func peek() -> Unicode.Scalar? {
index < scalars.count ? scalars[index] : nil
}
private mutating func expect(_ scalar: Unicode.Scalar) throws {
guard peek() == scalar else { throw Failure.invalidJSON }
index += 1
}
mutating func parseValue() throws -> Value {
skipWhitespace()
guard let scalar = peek() else { throw Failure.invalidJSON }
switch scalar {
case "{": return try parseObject()
case "[": return try parseArray()
case "\"": return .string(try parseString())
case "t":
try expectWord("true")
return .bool(true)
case "f":
try expectWord("false")
return .bool(false)
case "n":
try expectWord("null")
return .null
default:
return .integer(try parseNumber())
}
}
private mutating func expectWord(_ word: String) throws {
for scalar in word.unicodeScalars {
try expect(scalar)
}
}
private mutating func parseObject() throws -> Value {
try expect("{")
var members: [(key: String, value: Value)] = []
skipWhitespace()
if peek() == "}" {
index += 1
return .object(members)
}
while true {
skipWhitespace()
let key = try parseString()
skipWhitespace()
try expect(":")
let value = try parseValue()
// A repeated key takes the last occurrence, which is what Go's decoder and
// JSON.parse both do. Refusing here would make the phone stricter than the agent
// that recomputes the intent digest, and the two disagreeing about a body is the
// failure this whole file exists to prevent.
if let existing = members.firstIndex(where: { $0.key == key }) {
members[existing] = (key, value)
} else {
members.append((key, value))
}
skipWhitespace()
if peek() == "," {
index += 1
continue
}
try expect("}")
return .object(members)
}
}
private mutating func parseArray() throws -> Value {
try expect("[")
var items: [Value] = []
skipWhitespace()
if peek() == "]" {
index += 1
return .array(items)
}
while true {
items.append(try parseValue())
skipWhitespace()
if peek() == "," {
index += 1
continue
}
try expect("]")
return .array(items)
}
}
/// Parses a string, resolving escapes.
///
/// A `\uXXXX` escape in the surrogate range must be correctly paired. A lone surrogate is
/// an error here for the same reason it is in Go and Node: the three frameworks disagree
/// about what to do with one, so the only rule all three can share is to accept none.
private mutating func parseString() throws -> String {
try expect("\"")
var output = String.UnicodeScalarView()
while true {
guard let scalar = peek() else { throw Failure.invalidJSON }
index += 1
switch scalar {
case "\"":
return String(output)
case "\\":
output.append(try parseEscape())
default:
if scalar.value < 0x20 { throw Failure.invalidJSON }
output.append(scalar)
}
}
}
private mutating func parseEscape() throws -> Unicode.Scalar {
guard let scalar = peek() else { throw Failure.invalidJSON }
index += 1
switch scalar {
case "\"": return "\""
case "\\": return "\\"
case "/": return "/"
case "b": return Unicode.Scalar(0x08)
case "f": return Unicode.Scalar(0x0C)
case "n": return "\n"
case "r": return "\r"
case "t": return "\t"
case "u":
let high = try parseHex4()
if high >= 0xD800, high <= 0xDBFF {
guard peek() == "\\" else { throw Failure.loneSurrogate(high) }
index += 1
guard peek() == "u" else { throw Failure.loneSurrogate(high) }
index += 1
let low = try parseHex4()
guard low >= 0xDC00, low <= 0xDFFF else { throw Failure.loneSurrogate(high) }
let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
guard let scalar = Unicode.Scalar(combined) else { throw Failure.loneSurrogate(high) }
return scalar
}
guard high < 0xDC00 || high > 0xDFFF else { throw Failure.loneSurrogate(high) }
guard let scalar = Unicode.Scalar(high) else { throw Failure.loneSurrogate(high) }
return scalar
default:
throw Failure.invalidJSON
}
}
private mutating func parseHex4() throws -> UInt32 {
var value: UInt32 = 0
for _ in 0..<4 {
guard let scalar = peek(), let digit = hexDigit(scalar) else { throw Failure.invalidJSON }
index += 1
value = value << 4 | digit
}
return value
}
private func hexDigit(_ scalar: Unicode.Scalar) -> UInt32? {
switch scalar {
case "0"..."9": return scalar.value - 0x30
case "a"..."f": return scalar.value - 0x61 + 10
case "A"..."F": return scalar.value - 0x41 + 10
default: return nil
}
}
/// Parses an integer in the one spelling this protocol hashes.
///
/// The literal is kept as text rather than converted to a number, so what gets hashed is
/// what the phone sent. `1.0`, `1e3`, `+1`, `.5`, `01` and `-0` are all rejected: each of
/// them is the same number as some accepted spelling, and "which spelling did the phone
/// hash" is not a question this protocol leaves open.
private mutating func parseNumber() throws -> String {
var literal = ""
if peek() == "-" {
literal.append("-")
index += 1
}
guard let first = peek(), first >= "0", first <= "9" else {
throw Failure.invalidJSON
}
if first == "0" {
literal.append("0")
index += 1
if let next = peek(), next >= "0", next <= "9" {
throw Failure.nonCanonicalNumber(literal + String(next))
}
} else {
while let next = peek(), next >= "0", next <= "9" {
literal.append(Character(next))
index += 1
}
}
if let next = peek(), next == "." || next == "e" || next == "E" || next == "+" {
throw Failure.nonCanonicalNumber(literal + String(next))
}
if literal == "-0" { throw Failure.nonCanonicalNumber(literal) }
return literal
}
}
}
@@ -0,0 +1,146 @@
import CryptoKit
import Foundation
/// The phone's long-term relay identity: one Ed25519 signing key and one X25519 key-agreement key,
/// generated once and stored in the app sandbox.
///
/// The sandbox file rather than the keychain is a deliberate, recorded decision (design §6.4): the
/// established convention in this app is that node credentials live in a sandbox file, and the
/// Secure Enclave alternative conflicts with the TrollStore signing flow this build uses. The cost
/// is real and stated plainly: on a jailbroken device the file can be read, which yields the ability
/// to sign requests as this phone. It does not yield a node password, because the phone never holds
/// one in relay mode.
///
/// The device id is minted by the *node* at pairing time, not here: the node's config is the
/// authority for which device ids exist, and a phone that chose its own id could collide with an
/// existing pairing.
public struct DeviceIdentity: Sendable {
public let deviceID: String
public let signingKey: Curve25519.Signing.PrivateKey
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
public var signingPublicKey: Data { signingKey.publicKey.rawRepresentation }
public var agreementPublicKey: Data { agreementKey.publicKey.rawRepresentation }
public init(
deviceID: String,
signingKey: Curve25519.Signing.PrivateKey,
agreementKey: Curve25519.KeyAgreement.PrivateKey
) {
self.deviceID = deviceID
self.signingKey = signingKey
self.agreementKey = agreementKey
}
}
/// Generates and persists the device identity.
///
/// The file is written atomically with `completeFileProtection`, the same treatment the credential
/// store uses, so a locked device's backup does not carry the private key in the clear.
public struct FileDeviceIdentityStore: Sendable {
private struct FileModel: Codable {
var deviceID: String
var signingKey: Data
var agreementKey: Data
/// Bumped when the storage shape changes, so a future migration can tell old files apart
/// rather than guessing from missing fields.
var schemaVersion: Int
}
private let location: URL
private let lock = NSLock()
public init(location: URL) {
self.location = location
}
/// Loads the identity, creating one on first use.
///
/// The `deviceID` is a placeholder until the node assigns a real one at pairing: the phone needs
/// keys before it can pair, but it cannot know its id before the node decides. A pairing request
/// sends the public halves and receives the id, which is then stored here.
public func loadOrCreate() throws -> DeviceIdentity {
try lock.withLock {
if let model = read(), let identity = try? identity(from: model) {
return identity
}
let identity = DeviceIdentity(
deviceID: "",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
try write(identity)
return identity
}
}
/// Records the id the node assigned. The keys are unchanged: the node verified the public halves
/// it was given, and regenerating them would invalidate the pairing it just wrote.
public func assignDeviceID(_ deviceID: String) throws -> DeviceIdentity {
try lock.withLock {
let current = try loadOrCreateLocked()
let updated = DeviceIdentity(
deviceID: deviceID,
signingKey: current.signingKey,
agreementKey: current.agreementKey
)
try write(updated)
return updated
}
}
public func reset() throws {
try lock.withLock {
try? FileManager.default.removeItem(at: location)
}
}
// MARK: - Internals
/// `loadOrCreate` is not reentrant with the lock, so the locked body lives here.
private func loadOrCreateLocked() throws -> DeviceIdentity {
if let model = read(), let identity = try? identity(from: model) {
return identity
}
let identity = DeviceIdentity(
deviceID: "",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
try write(identity)
return identity
}
private func identity(from model: FileModel) throws -> DeviceIdentity {
DeviceIdentity(
deviceID: model.deviceID,
signingKey: try Curve25519.Signing.PrivateKey(rawRepresentation: model.signingKey),
agreementKey: try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: model.agreementKey)
)
}
private func read() -> FileModel? {
guard let data = try? Data(contentsOf: location) else { return nil }
return try? JSONDecoder().decode(FileModel.self, from: data)
}
private func write(_ identity: DeviceIdentity) throws {
let model = FileModel(
deviceID: identity.deviceID,
signingKey: identity.signingKey.rawRepresentation,
agreementKey: identity.agreementKey.rawRepresentation,
schemaVersion: 1
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
try FileManager.default.createDirectory(
at: location.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try encoder.encode(model).write(to: location, options: [.atomic, .completeFileProtection])
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: location.path
)
}
}
@@ -0,0 +1,279 @@
import Foundation
/// How a node is reached, as a value the list can badge.
public enum InstanceChannel: String, CaseIterable, Sendable {
case direct
case relay
public var label: String {
switch self {
case .direct: return "直通"
case .relay: return "中继"
}
}
}
public extension InstanceEndpoint {
var channel: InstanceChannel {
switch self {
case .direct: return .direct
case .relay: return .relay
}
}
}
/// How the app reaches a node.
///
/// This exists rather than an optional `relayNodeID` field on `Instance` because `origin` currently
/// carries three separate responsibilities — the request target, the duplicate-detection key, and
/// the cookie binding domain — and in relay mode all three have to be re-homed. An optional field
/// would leave two of them quietly pointing at an address that does not exist on a relay node.
public enum InstanceEndpoint: Equatable, Sendable {
/// The node is reachable directly on the local network. This is the original behaviour and its
/// code path is unchanged.
case direct(URL)
/// The node is reached through a relay, with the agent on the node holding the session against
/// the local SimAdmin. No node password is stored on the phone in this mode.
case relay(RelayEndpoint)
}
public struct RelayEndpoint: Equatable, Sendable {
/// The relay's base URL, https. The only outbound destination in relay mode.
public var relayBaseURL: URL
/// The node this endpoint reaches, as the relay and the agent both name it.
public var nodeID: String
/// `K_agent_sign` public half, Ed25519, 32 bytes. Every response is verified against it.
public var agentSigningKey: Data
/// `K_agent_kx` public half, X25519, 32 bytes. Requests are encrypted to it.
public var agentKxKey: Data
/// The bearer the relay expects from this phone. It authorises nothing on the node: the node's
/// authorisation comes from the signature the phone produces with its own private key.
///
/// A `String` rather than `Data`, which the design sketch drew: the relay compares the token as
/// an opaque bearer value, so what has to survive the round trip is the exact text the node
/// minted. Holding it as bytes invites a hex-encode on the way out, which sends a different
/// string than the one the agent registered and fails as "unknown device" with nothing to see.
public var deviceToken: String
/// The device id the node knows this phone by, and which the phone signs into every request.
public var deviceID: String
/// The node's display name at pairing time, used only when the user has not renamed it.
public var nodeName: String?
public init(
relayBaseURL: URL,
nodeID: String,
agentSigningKey: Data,
agentKxKey: Data,
deviceToken: String,
deviceID: String,
nodeName: String? = nil
) {
self.relayBaseURL = relayBaseURL
self.nodeID = nodeID
self.agentSigningKey = agentSigningKey
self.agentKxKey = agentKxKey
self.deviceToken = deviceToken
self.deviceID = deviceID
self.nodeName = nodeName
}
}
public extension InstanceEndpoint {
/// The direct origin, or nil in relay mode.
///
/// Callers that need "the address to dial directly" get nil rather than a substituted value, so
/// a relay instance cannot accidentally be handed to a code path that would dial a LAN address
/// that is not there.
var directOrigin: URL? {
if case .direct(let url) = self { return url }
return nil
}
var relay: RelayEndpoint? {
if case .relay(let endpoint) = self { return endpoint }
return nil
}
var isRelay: Bool { relay != nil }
/// The identity used for duplicate detection.
///
/// Direct endpoints keep using the normalised origin so an existing node list upgrades without
/// suddenly reporting duplicates; relay endpoints use the node id, because two phones paired to
/// the same node are two different instances and one phone paired to two nodes is not.
var dedupKey: String {
switch self {
case .direct(let url):
return url.absoluteString
case .relay(let endpoint):
return "relay:\(endpoint.nodeID)"
}
}
/// What the user sees as "where this node is".
var displayAddress: String {
switch self {
case .direct(let url):
return url.absoluteString
case .relay(let endpoint):
return "\(endpoint.relayBaseURL.host ?? "relay") · \(endpoint.nodeID)"
}
}
/// Whether the phone holds a node password for this instance.
///
/// The two modes mean different things and must not share a string: directly it means "this
/// phone has the node's password", through a relay it means "the node's agent has its own
/// credential". Showing the first wording in relay mode would tell the user their password is
/// on the phone when it deliberately is not.
var credentialDescription: String {
switch self {
case .direct:
return "已保存本机密码"
case .relay:
return "由节点 agent 保存凭据"
}
}
}
// MARK: - Codable
extension InstanceEndpoint: Codable {
private enum CodingKeys: String, CodingKey { case kind, url, relay }
private enum Kind: String, Codable { case direct, relay }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(Kind.self, forKey: .kind) {
case .direct:
self = .direct(try container.decode(URL.self, forKey: .url))
case .relay:
self = .relay(try container.decode(RelayEndpoint.self, forKey: .relay))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .direct(let url):
try container.encode(Kind.direct, forKey: .kind)
try container.encode(url, forKey: .url)
case .relay(let endpoint):
try container.encode(Kind.relay, forKey: .kind)
try container.encode(endpoint, forKey: .relay)
}
}
}
extension RelayEndpoint: Codable {
private enum CodingKeys: String, CodingKey {
case relayBaseURL, nodeID, agentSigningKey, agentKxKey, deviceToken, deviceID, nodeName
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
relayBaseURL = try container.decode(URL.self, forKey: .relayBaseURL)
nodeID = try container.decode(String.self, forKey: .nodeID)
agentSigningKey = try container.decode(Data.self, forKey: .agentSigningKey)
agentKxKey = try container.decode(Data.self, forKey: .agentKxKey)
deviceToken = try container.decode(String.self, forKey: .deviceToken)
deviceID = try container.decode(String.self, forKey: .deviceID)
nodeName = try container.decodeIfPresent(String.self, forKey: .nodeName)
guard agentSigningKey.count == RelayWire.keyBytes, agentKxKey.count == RelayWire.keyBytes else {
throw DecodingError.dataCorruptedError(
forKey: .agentSigningKey,
in: container,
debugDescription: "agent key material must be 32 raw bytes"
)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(relayBaseURL, forKey: .relayBaseURL)
try container.encode(nodeID, forKey: .nodeID)
try container.encode(agentSigningKey, forKey: .agentSigningKey)
try container.encode(agentKxKey, forKey: .agentKxKey)
try container.encode(deviceToken, forKey: .deviceToken)
try container.encode(deviceID, forKey: .deviceID)
try container.encodeIfPresent(nodeName, forKey: .nodeName)
}
}
// MARK: - Relay error vocabulary
/// Every code the relay can return, mirroring `relay/src/errors.js`.
///
/// The console's `ProblemDetails.code` is an unconstrained string in the OpenAPI document, so this
/// is a new vocabulary rather than a reuse of an existing one. It has to be enumerated in one place
/// in the first version, because three implementations writing their own strings is exactly how
/// cross-tier debugging stops working: the phone shows a code, the relay logs a code, and nobody can
/// match them.
public enum RelayErrorCode: String, CaseIterable, Sendable {
case unauthorized = "relay_unauthorized"
case nodeUnknown = "relay_node_unknown"
case agentOffline = "relay_agent_offline"
case mailboxFull = "relay_mailbox_full"
case agentTimeout = "relay_agent_timeout"
case badEnvelope = "relay_bad_envelope"
case payloadTooLarge = "relay_payload_too_large"
case rateLimited = "relay_rate_limited"
case internalError = "relay_internal"
case sessionExpired = "relay_session_expired"
case badChallenge = "relay_bad_challenge"
}
/// The agent's own classification, carried inside the ciphertext as `InnerResponse.outcome`.
///
/// Separate from `RelayErrorCode` because the two arrive through different channels and mean
/// different things: a relay code is "the relay could not deliver this", an agent outcome is "the
/// node received it and refused it". Collapsing them into one enum would lose the distinction the
/// user needs, which is whether retrying can help.
public enum AgentOutcome: String, CaseIterable, Sendable {
case ok
case badEnvelope = "bad_envelope"
case badSignature = "bad_signature"
case unknownDevice = "unknown_device"
case expired
case replay
case policyDenied = "policy_denied"
case confirmationMissing = "confirmation_missing"
case confirmationMismatch = "confirmation_mismatch"
case rateLimited = "rate_limited"
case payloadTooLarge = "payload_too_large"
case upstreamError = "upstream_error"
case nodeAuthFailed = "node_auth_failed"
}
/// Freshness of a snapshot, mirroring `FRESHNESS_STATUSES` in `packages/contracts/src/instances.ts`.
///
/// The four values are the existing vocabulary rather than new names. Without it a relay instance
/// cannot express that its "online" reading is forty seconds old, which in a two-hop architecture is
/// a substantive misstatement rather than a cosmetic one.
public enum SnapshotFreshness: String, CaseIterable, Sendable {
case fresh
case stale
case expired
case unknown
/// Thresholds, chosen to line up with what the UI already implies: a direct probe is fresh for
/// as long as a user would call the screen current, and anything past the relay's own request
/// deadline is expired rather than merely old.
public static func classify(age: TimeInterval?) -> SnapshotFreshness {
guard let age, age >= 0 else { return .unknown }
switch age {
case ..<15: return .fresh
case ..<90: return .stale
default: return .expired
}
}
public var label: String {
switch self {
case .fresh: return "刚刚"
case .stale: return "稍旧"
case .expired: return "已过期"
case .unknown: return "未知"
}
}
}
@@ -0,0 +1,125 @@
import CryptoKit
import Foundation
/// The phone's half of the pairing flow (design §7).
///
/// It works over the LAN, because at pairing time the phone is necessarily already inside the
/// network — that is the fact the whole flow leans on, and it is what lets the shared secret be the
/// node's own password instead of a QR code or a second credential to manage.
///
/// The node's agent serves this on a temporary management port that closes itself after one
/// successful pairing, so a phone that cannot reach the node has to be paired again from the node
/// rather than from anywhere else.
public struct RelayPairing: Sendable {
/// What the node returns on success.
public struct Result: Equatable, Sendable {
public let endpoint: RelayEndpoint
/// The device id the node assigned. It is the node's choice, not the phone's: the node's
/// config is the authority for which device ids exist.
public let deviceID: String
}
public enum Failure: Error, Equatable {
/// The shared secret did not match. Distinct from a transport failure because the remedy is
/// different: retype the password rather than check the network.
case rejected
/// The pairing window is closed — already used, or too many failed attempts.
case windowClosed
case unreachable
case malformedResponse
}
private let session: URLSession
private let port: Int
private let maximumBytes: Int
public init(session: URLSession = .shared, port: Int = 3001, maximumBytes: Int = 16 << 10) {
self.session = session
self.port = port
self.maximumBytes = maximumBytes
}
/// Pairs this phone with the node at `host`.
///
/// `host` is the node's LAN address as the user already typed it for direct mode, which is why
/// the port is a separate parameter: the pairing window is not on the SimAdmin port.
public func pair(
host: String,
password: String,
deviceName: String,
identity: DeviceIdentity
) async throws -> Result {
guard let url = URL(string: "http://\(host):\(port)/provision") else {
throw Failure.unreachable
}
// The device name is informational on the node's side, and the two public keys are the whole
// point: the node stores the signing key and returns its own, which becomes the phone's
// trust anchor for every later response.
let body: [String: String] = [
"password": password,
"appSignPub": identity.signingPublicKey.base64EncodedString(),
"appKxPub": identity.agreementPublicKey.base64EncodedString(),
"deviceName": deviceName,
]
var request = URLRequest(url: url, timeoutInterval: 15)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.setValue("application/json", forHTTPHeaderField: "accept")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw Failure.unreachable
}
guard let http = response as? HTTPURLResponse else { throw Failure.malformedResponse }
switch http.statusCode {
case 200:
break
case 401:
throw Failure.rejected
case 410:
throw Failure.windowClosed
default:
throw Failure.malformedResponse
}
guard data.count <= maximumBytes,
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let nodeID = root["nodeID"] as? String,
let appID = root["appID"] as? String,
let relayURLText = root["relayURL"] as? String,
let relayURL = URL(string: relayURLText),
relayURL.scheme == "https",
let signKey = Self.key(root["agentSignPub"] as? String),
let kxKey = Self.key(root["agentKxPub"] as? String),
let token = root["deviceToken"] as? String,
!token.isEmpty
else {
throw Failure.malformedResponse
}
return Result(
endpoint: RelayEndpoint(
relayBaseURL: relayURL,
nodeID: nodeID,
agentSigningKey: signKey,
agentKxKey: kxKey,
deviceToken: token,
deviceID: appID,
nodeName: deviceName
),
deviceID: appID
)
}
/// A key the node published must be exactly 32 bytes. A short one would be accepted by the
/// decoder and then fail as an unreadable response, which reads to the user as "the node is
/// broken" rather than "this pairing is wrong".
private static func key(_ text: String?) -> Data? {
guard let text, let data = Data(base64Encoded: text), data.count == RelayWire.keyBytes else {
return nil
}
return data
}
}
@@ -0,0 +1,366 @@
import CryptoKit
import Foundation
/// What the app needs in order to reach a node, in either mode.
///
/// The abstraction exists so services stop taking a `baseURL` and a `cookie`: in relay mode there is
/// no base URL to dial and no cookie, and a service that kept asking for one would be asking a
/// question with no answer.
///
/// `instanceID` is part of the signature even though the design sketch showed only the endpoint,
/// because the direct path scopes its cookie by `(instanceID, origin)` and losing that would let two
/// instances pointed at the same node share a session. It is the one piece of the sketch that could
/// not survive contact with the existing session store.
public protocol NodeTransport: Sendable {
func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse
}
/// Picks the transport for an endpoint.
///
/// Services depend on this rather than on either concrete transport, so adding a mode later is a
/// change in one place instead of in every feature that talks to a node.
public struct NodeTransportRouter: NodeTransport {
private let direct: any NodeTransport
private let relay: any NodeTransport
public init(direct: any NodeTransport, relay: any NodeTransport) {
self.direct = direct
self.relay = relay
}
public func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
switch endpoint {
case .direct:
return try await direct.send(route, instanceID: instanceID, endpoint: endpoint)
case .relay:
return try await relay.send(route, instanceID: instanceID, endpoint: endpoint)
}
}
}
/// The existing direct path, unchanged.
///
/// It is a thin adapter rather than a rewrite: the code path that reaches a node on the local network
/// today is the one users already depend on, and a refactor that changed its behaviour would be a
/// regression disguised as tidying.
public struct DirectTransport: NodeTransport {
private let client: any SimAdminClientProtocol
private let sessions: InstanceSessionStore
private let credentials: (any CredentialStore)?
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.client = client
self.sessions = sessions
self.credentials = credentials
}
public func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
guard let origin = endpoint.directOrigin else {
// Reaching here means a relay instance was handed to the direct path, which is a wiring
// bug rather than a network condition: fail loudly instead of dialing nothing.
throw UpstreamError.invalidRequest
}
var cookie = await sessions.cookie(for: instanceID, origin: origin)
do {
return try await client.send(route, baseURL: origin, cookie: cookie)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
// A node whose login endpoints are unreachable (an old build, or one with protection
// off) must not turn every read into a failed login attempt; the retry below is what
// makes the second call happen only when credentials actually exist.
guard let credentials, let password = try credentials.get(instanceID: instanceID),
!password.isEmpty
else {
throw UpstreamError.unauthorized
}
let service = InstanceSessionService(store: sessions, client: client)
let instance = Instance(
id: instanceID,
name: "",
endpoint: endpoint,
createdAt: Date(),
updatedAt: Date()
)
let result = try await service.login(instance, password: password)
guard result.authenticated else { throw UpstreamError.unauthorized }
cookie = await sessions.cookie(for: instanceID, origin: origin)
return try await client.send(route, baseURL: origin, cookie: cookie)
}
}
}
/// The relay path: build an envelope, sign it, encrypt it, post it, then verify and decrypt the
/// answer.
///
/// It never touches a cookie and never sees a node password. The node's agent holds that credential
/// and logs in to its own loopback SimAdmin, which is what makes "the phone has no node password"
/// true rather than merely stated.
public struct RelayTransport: NodeTransport {
/// Resolved per send rather than held, so a transport built before pairing still uses the keys
/// the node actually paired with. Holding a copy would mean a pairing done after the transport
/// was constructed signs with keys the node has never seen.
private let identity: @Sendable () throws -> DeviceIdentity
private let session: URLSession
private let maximumBytes: Int
private let now: @Sendable () -> Date
public init(
identity: @escaping @Sendable () throws -> DeviceIdentity,
session: URLSession = .shared,
maximumBytes: Int = 262_144,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.identity = identity
self.session = session
self.maximumBytes = maximumBytes
self.now = now
}
public init(
identity: DeviceIdentity,
session: URLSession = .shared,
maximumBytes: Int = 262_144,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.init(identity: { identity }, session: session, maximumBytes: maximumBytes, now: now)
}
public func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
guard let relay = endpoint.relay else { throw UpstreamError.invalidRequest }
guard relay.relayBaseURL.scheme == "https" else {
// Plain HTTP to a relay would make the endpoint-to-endpoint encryption decorative: a
// third party could substitute a different ciphertext, and nothing above would notice.
throw UpstreamError.invalidRequest
}
guard let requestBody = try? Self.encode(route: route) else {
throw UpstreamError.invalidRequest
}
_ = instanceID
guard let identity = try? identity() else {
throw UpstreamError.invalidRequest
}
// The ephemeral key pair is per request and its private half is kept only until the answer
// is decrypted. The response is encrypted to this key, so the two directions never share a
// secret and the phone's long-term X25519 key is not needed to read an answer.
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let requestID = UUID().uuidString.lowercased()
let timestamp = UInt64(now().timeIntervalSince1970)
let shared = try RelayWire.sharedSecret(
privateKey: ephemeral,
peerPublic: relay.agentKxKey
)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: relay.nodeID,
requestID: requestID,
response: false
)
let sealed = try RelayWire.seal(requestBody, key: key, iv: iv)
var envelope = RelayWire.Envelope(
node: relay.nodeID,
req: requestID,
app: relay.deviceID,
ts: timestamp,
ek: ephemeral.publicKey.rawRepresentation,
p: sealed
)
try RelayWire.signRequest(&envelope, privateKey: identity.signingKey)
let answer = try await post(envelope, relay: relay)
return try decrypt(answer, relay: relay, requestID: requestID, ephemeral: ephemeral)
}
// MARK: - Inner request
/// Builds the inner payload. The path is the route's own path, query string included, so the
/// agent never assembles one from parts and there is no traversal surface from the relay.
///
/// A dangerous write carries an intent computed here, from the exact bytes about to be sent. The
/// agent recomputes it and refuses a mismatch, so a relay cannot edit the recipient of an SMS in
/// flight and keep the confirmation valid. The intent is produced at the moment the user's
/// gesture is already behind us — this method is only called for a request the UI has decided to
/// make — which is what gives it any meaning at all.
static func encode(route: UpstreamRoute) throws -> Data {
let body = route.body
var inner = RelayWire.InnerRequest(m: route.method, p: route.path, body: body)
if route.requiresConfirmation {
inner.intent = try RelayWire.intentDigest(
method: route.method,
path: route.path,
body: body
)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return try encoder.encode(inner)
}
// MARK: - HTTP
private func post(_ envelope: RelayWire.Envelope, relay: RelayEndpoint) async throws -> RelayWire.Envelope {
guard let url = URL(string: relay.relayBaseURL.absoluteString + "/v1/request") else {
throw UpstreamError.invalidRequest
}
var request = URLRequest(url: url, timeoutInterval: 45)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.setValue("application/json", forHTTPHeaderField: "accept")
request.setValue("Bearer \(relay.deviceToken)", forHTTPHeaderField: "authorization")
request.httpBody = try JSONEncoder().encode(envelope)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch let error as URLError where error.code == .timedOut {
throw UpstreamError.relayTimeout
} catch {
throw UpstreamError.relayUnreachable
}
guard let http = response as? HTTPURLResponse, data.count <= maximumBytes else {
throw UpstreamError.invalidResponse
}
if http.statusCode != 200 {
throw Self.problem(from: data, status: http.statusCode)
}
guard let envelope = try? JSONDecoder().decode(RelayWire.Envelope.self, from: data) else {
throw UpstreamError.invalidResponse
}
return envelope
}
/// Maps a relay ProblemDetails document to a typed error.
///
/// The four classes the design calls for are kept apart on purpose: "the relay is unreachable",
/// "the agent is offline", "the envelope was refused" and "the node returned an error" lead to
/// four different user actions, and folding them into one "request failed" is what makes relay
/// problems unactionable.
static func problem(from data: Data, status: Int) -> UpstreamError {
let code = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])
.flatMap { $0?["code"] as? String }
.flatMap(RelayErrorCode.init(rawValue:))
switch code {
case .unauthorized:
// The relay rejected this phone's bearer. Nothing the node can do about it, and retrying
// with the same token will fail the same way.
return .relayRejectedDevice
case .nodeUnknown:
return .relayNodeUnknown
case .agentOffline:
return .agentOffline
case .mailboxFull:
return .nodeBusy
case .agentTimeout:
return .agentTimeout
case .badEnvelope:
return .relayBadEnvelope
case .payloadTooLarge:
return .payloadTooLarge
case .rateLimited:
return .relayRateLimited
case .sessionExpired, .badChallenge:
return .agentOffline
case .internalError, .none:
return status >= 500 ? .relayUnreachable : .invalidResponse
}
}
// MARK: - Response
private func decrypt(
_ envelope: RelayWire.Envelope,
relay: RelayEndpoint,
requestID: String,
ephemeral: Curve25519.KeyAgreement.PrivateKey
) throws -> UpstreamResponse {
guard envelope.req == requestID, envelope.node == relay.nodeID else {
// The relay answered a different question. Since the signature covers both fields, a
// mismatch here means the answer was not produced for this request at all.
throw UpstreamError.relayResponseMismatch
}
let signingKey: Curve25519.Signing.PublicKey
do {
signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: relay.agentSigningKey)
} catch {
throw UpstreamError.relayResponseUnverifiable
}
do {
try RelayWire.verifyResponse(envelope, publicKey: signingKey)
} catch {
// An answer the node did not sign is not an answer. This is the check that makes the
// relay's inability to forge responses a fact rather than a claim.
throw UpstreamError.relayResponseUnverifiable
}
let shared = try RelayWire.sharedSecret(privateKey: ephemeral, peerPublic: envelope.ek)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: relay.nodeID,
requestID: requestID,
response: true
)
let plaintext: Data
do {
plaintext = try RelayWire.open(envelope.p, key: key, iv: iv)
} catch {
throw UpstreamError.relayResponseUnverifiable
}
guard let inner = try? JSONDecoder().decode(RelayWire.InnerResponse.self, from: plaintext) else {
throw UpstreamError.invalidResponse
}
let outcome = inner.outcome.flatMap(AgentOutcome.init(rawValue:))
switch outcome {
case .ok, .none:
return UpstreamResponse(status: inner.status, data: inner.body ?? Data())
case .nodeAuthFailed:
// Nothing the phone can do: the node's agent is misconfigured. Distinguished from an
// upstream error because the remedy is on the node, not in the app.
throw UpstreamError.nodeAuthFailed
case .policyDenied, .unknownDevice:
throw UpstreamError.relayPolicyDenied
case .confirmationMissing, .confirmationMismatch:
throw UpstreamError.confirmationRejected
case .expired, .replay:
// The envelope was stale or already used. Both are clock or retry problems, and both
// resolve by re-issuing the request with a fresh id and timestamp.
throw UpstreamError.envelopeExpired
case .rateLimited:
throw UpstreamError.nodeRateLimited
case .payloadTooLarge:
throw UpstreamError.payloadTooLarge
case .badEnvelope, .badSignature:
throw UpstreamError.envelopeRejected
case .upstreamError:
// The node answered, but its local SimAdmin did not. The status is still useful: it is
// the only branch where a 4xx/5xx from the node reaches the caller.
return UpstreamResponse(status: inner.status, data: inner.body ?? Data())
}
}
}
extension Data {
/// Lowercase hex, which is the spelling the relay's bearer-token comparison expects.
var hexString: String { map { String(format: "%02x", $0) }.joined() }
}
@@ -0,0 +1,380 @@
import CryptoKit
import Foundation
/// The relay envelope: what is signed, what is encrypted, and in what order.
///
/// This is the third implementation of a format that Go and Node already implement, and the three
/// share no code on purpose. `contracts/test-vectors.json` is recomputed here by `RelayWireTests`,
/// so a byte-level disagreement shows up as a failing test rather than as a signature failure in
/// the field — where the only symptom is "the node refused the request" with no way to tell which
/// of the three encoders drifted.
///
/// The relay is not trusted, so signing and encryption are separate concerns: Ed25519 covers a
/// canonical byte encoding of the routing fields plus the ciphertext, which stops a compromised
/// relay from retargeting a request at another node or swapping a response between two in-flight
/// requests. AES-256-GCM then carries the payload under keys from an ephemeral X25519 exchange.
public enum RelayWire {
public static let version: UInt64 = 1
public static let suite = "X25519-ED25519-AES256GCM-HKDFSHA256"
static let domainRequest = "msa-relay-v1-req"
static let domainResponse = "msa-relay-v1-resp"
private static let label = "msa-relay-v1"
public static let keyBytes = 32
public static let ivBytes = 12
public static let tagBytes = 16
public enum Failure: Error, Equatable {
case unsupportedVersion(UInt64)
case unsupportedSuite(String)
case badKeyLength(field: String, got: Int)
case missingIdentityField
case signatureDoesNotVerify
case ciphertextFailedToAuthenticate
case peerKeyUnusable
case invalidResponseBody
}
// MARK: - Envelope
/// The outer, relay-visible structure. The relay routes on `node` and correlates on `req`, and
/// can read nothing else: `ek` is a public key, `p` is ciphertext, and `s` covers everything.
public struct Envelope: Codable, Equatable, Sendable {
public var v: UInt64
public var node: String
public var req: String
/// The paired device id. Present on requests only: it is signed, so the relay cannot
/// relabel one phone's traffic as another's, and the agent uses it to pick the key to
/// verify against.
public var app: String?
public var ts: UInt64
/// X25519 public key: the phone's per-request ephemeral on a request, the agent's
/// per-response ephemeral on a response.
public var ek: Data
public var alg: String
/// AES-256-GCM ciphertext with the 16-byte tag appended.
public var p: Data
public var s: Data?
public init(
v: UInt64 = RelayWire.version,
node: String,
req: String,
app: String? = nil,
ts: UInt64,
ek: Data,
alg: String = RelayWire.suite,
p: Data,
s: Data? = nil
) {
self.v = v
self.node = node
self.req = req
self.app = app
self.ts = ts
self.ek = ek
self.alg = alg
self.p = p
self.s = s
}
/// The wire form is base64 for the byte fields, and the key set is fixed: the relay parses
/// these same names, and the Go agent's struct tags are the authority for them.
private enum CodingKeys: String, CodingKey {
case v, node, req, app, ts, ek, alg, p, s
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
v = try container.decode(UInt64.self, forKey: .v)
node = try container.decode(String.self, forKey: .node)
req = try container.decode(String.self, forKey: .req)
app = try container.decodeIfPresent(String.self, forKey: .app)
ts = try container.decode(UInt64.self, forKey: .ts)
alg = try container.decode(String.self, forKey: .alg)
ek = try Self.decodeBase64(container.decode(String.self, forKey: .ek), field: "ek")
p = try Self.decodeBase64(container.decode(String.self, forKey: .p), field: "p")
let signature = try container.decodeIfPresent(String.self, forKey: .s) ?? ""
s = signature.isEmpty ? nil : try Self.decodeBase64(signature, field: "s")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(v, forKey: .v)
try container.encode(node, forKey: .node)
try container.encode(req, forKey: .req)
try container.encodeIfPresent(app, forKey: .app)
try container.encode(ts, forKey: .ts)
try container.encode(ek.base64EncodedString(), forKey: .ek)
try container.encode(alg, forKey: .alg)
try container.encode(p.base64EncodedString(), forKey: .p)
if let s {
try container.encode(s.base64EncodedString(), forKey: .s)
}
}
/// Strict base64: Go's decoder silently accepts some non-canonical forms, and a signature
/// must be computed over exactly the bytes the sender used, so a lenient decode here would
/// turn a malformed envelope into a valid-looking one.
private static func decodeBase64(_ value: String, field: String) throws -> Data {
guard let data = Data(base64Encoded: value, options: []) else {
throw Failure.invalidResponseBody
}
return data
}
}
/// The decrypted request payload.
public struct InnerRequest: Codable, Equatable, Sendable {
/// Upstream method. The agent matches it against its policy before dialing anything.
public var m: String
/// Upstream path, already carrying any query string. The agent forwards it verbatim and
/// never assembles one from parts, so there is no path-traversal surface from the relay.
public var p: String
/// sha256 over the method, path and canonical body, produced by an explicit user gesture.
/// Mandatory for every entry in the agent's `dangerous` list and absent for reads.
public var intent: Data?
/// Raw upstream body, forwarded unchanged. Kept as bytes so the agent never re-serialises
/// JSON and thereby never changes what the intent was computed over.
public var body: Data?
public init(m: String, p: String, intent: Data? = nil, body: Data? = nil) {
self.m = m
self.p = p
self.intent = intent
self.body = body
}
private enum CodingKeys: String, CodingKey { case m, p, intent, body }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
m = try container.decode(String.self, forKey: .m)
p = try container.decode(String.self, forKey: .p)
intent = try container.decodeIfPresent(Data.self, forKey: .intent)
body = try container.decodeIfPresent(Data.self, forKey: .body)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(m, forKey: .m)
try container.encode(p, forKey: .p)
if let intent { try container.encode(intent, forKey: .intent) }
if let body { try container.encode(body, forKey: .body) }
}
}
/// The decrypted response payload. Status lives inside the ciphertext so the relay cannot
/// rewrite a failure into a success.
public struct InnerResponse: Codable, Equatable, Sendable {
public var status: Int
/// The agent's own classification of what happened. Inside the ciphertext for the same
/// reason as `status`: the relay is not entitled to know why a command failed, and the
/// phone needs to tell the user what to do about it.
public var outcome: String?
public var body: Data?
public init(status: Int, outcome: String? = nil, body: Data? = nil) {
self.status = status
self.outcome = outcome
self.body = body
}
}
// MARK: - Canonical bytes
/// The signing input, which is not JSON.
///
/// Fixed field order, each field prefixed with its length as uint32be. The two integers carry
/// an explicit `00000008` length too — Go's `writeUint64` delegates to `writeBytes`, so a
/// version that emitted them bare would produce a byte string that looks identical in a hex
/// dump's payload region and still fails to verify.
///
/// `ek` and `p` contribute their raw decoded bytes, never their base64 text, so a relay cannot
/// re-encode them differently.
public static func canonical(_ envelope: Envelope, response: Bool) throws -> Data {
guard envelope.v == version else { throw Failure.unsupportedVersion(envelope.v) }
guard envelope.alg == suite else { throw Failure.unsupportedSuite(envelope.alg) }
guard !envelope.node.trimmed.isEmpty, !envelope.req.trimmed.isEmpty else {
throw Failure.missingIdentityField
}
guard envelope.ek.count == keyBytes else {
throw Failure.badKeyLength(field: "ek", got: envelope.ek.count)
}
if !response {
guard let app = envelope.app, !app.trimmed.isEmpty else { throw Failure.missingIdentityField }
}
var out = Data()
out.append(contentsOf: Array((response ? domainResponse : domainRequest).utf8))
out.append(0)
appendLengthPrefixed(&out, uint64be(envelope.v))
appendLengthPrefixed(&out, Data(envelope.node.utf8))
appendLengthPrefixed(&out, Data(envelope.req.utf8))
if !response, let app = envelope.app {
appendLengthPrefixed(&out, Data(app.utf8))
}
appendLengthPrefixed(&out, uint64be(envelope.ts))
appendLengthPrefixed(&out, envelope.ek)
appendLengthPrefixed(&out, envelope.p)
return out
}
private static func appendLengthPrefixed(_ out: inout Data, _ value: Data) {
var length = UInt32(value.count).bigEndian
withUnsafeBytes(of: &length) { out.append(contentsOf: $0) }
out.append(value)
}
private static func uint64be(_ value: UInt64) -> Data {
var big = value.bigEndian
return withUnsafeBytes(of: &big) { Data($0) }
}
// MARK: - Signing
public static func signRequest(_ envelope: inout Envelope, privateKey: Curve25519.Signing.PrivateKey) throws {
envelope.s = try privateKey.signature(for: canonical(envelope, response: false))
}
public static func verifyRequest(_ envelope: Envelope, publicKey: Curve25519.Signing.PublicKey) throws {
try verify(envelope, publicKey: publicKey, response: false)
}
public static func verifyResponse(_ envelope: Envelope, publicKey: Curve25519.Signing.PublicKey) throws {
try verify(envelope, publicKey: publicKey, response: true)
}
private static func verify(
_ envelope: Envelope,
publicKey: Curve25519.Signing.PublicKey,
response: Bool
) throws {
let canonicalBytes = try canonical(envelope, response: response)
guard let signature = envelope.s, signature.count == 64 else {
throw Failure.signatureDoesNotVerify
}
guard publicKey.isValidSignature(signature, for: canonicalBytes) else {
throw Failure.signatureDoesNotVerify
}
}
// MARK: - Key agreement and AEAD
/// HKDF-SHA256, extract then expand.
public static func hkdf(secret: Data, salt: Data, info: Data, length: Int) throws -> Data {
guard length > 0, length <= 255 * SHA256.byteCount else {
throw Failure.badKeyLength(field: "hkdf", got: length)
}
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: secret),
salt: salt,
info: info,
outputByteCount: length
)
return key.withUnsafeBytes { Data($0) }
}
/// X25519, rejecting the all-zero output that a peer key chosen to force a known shared secret
/// would produce.
public static func sharedSecret(
privateKey: Curve25519.KeyAgreement.PrivateKey,
peerPublic: Data
) throws -> Data {
guard peerPublic.count == keyBytes else {
throw Failure.badKeyLength(field: "peerPublic", got: peerPublic.count)
}
guard let publicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerPublic) else {
throw Failure.peerKeyUnusable
}
do {
let secret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
return secret.withUnsafeBytes { Data($0) }
} catch {
throw Failure.peerKeyUnusable
}
}
/// The AEAD key and IV for one direction of one request.
///
/// The salt binds the node id, so a shared secret that somehow repeats across nodes still
/// yields different keys. The IV is derived from the request id and the direction rather than
/// being a fixed zero block: a fixed IV is only safe while every message uses a fresh key, and
/// that invariant is exactly what a later "reuse the session key for a batch" optimisation
/// would quietly break.
public static func deriveKey(
shared: Data,
nodeID: String,
requestID: String,
response: Bool
) throws -> (key: SymmetricKey, iv: Data) {
guard shared.count == keyBytes else {
throw Failure.badKeyLength(field: "shared", got: shared.count)
}
let salt = Data("\(label):\(nodeID):\(requestID)".utf8)
let keyBytes = try hkdf(
secret: shared,
salt: salt,
info: Data((response ? "\(label)-resp" : "\(label)-req").utf8),
length: self.keyBytes
)
let iv = try hkdf(
secret: shared,
salt: Data(requestID.utf8),
info: Data((response ? "iv-resp" : "iv-req").utf8),
length: ivBytes
)
return (SymmetricKey(data: keyBytes), iv)
}
/// Encrypts with AES-256-GCM. The tag is appended to the ciphertext, matching Node's
/// `cipher.getAuthTag()` concatenation and Go's `gcm.Seal`.
public static func seal(_ plaintext: Data, key: SymmetricKey, iv: Data) throws -> Data {
guard iv.count == ivBytes else { throw Failure.badKeyLength(field: "iv", got: iv.count) }
let nonce = try AES.GCM.Nonce(data: iv)
let box = try AES.GCM.seal(plaintext, using: key, nonce: nonce)
return box.ciphertext + box.tag
}
/// Decrypts and authenticates. A failure is terminal for the request, not a retry: a wrong
/// key, a torn payload and a forged payload are indistinguishable here, and all three mean do
/// not act.
public static func open(_ ciphertext: Data, key: SymmetricKey, iv: Data) throws -> Data {
guard iv.count == ivBytes else { throw Failure.badKeyLength(field: "iv", got: iv.count) }
guard ciphertext.count >= tagBytes else { throw Failure.ciphertextFailedToAuthenticate }
let nonce = try AES.GCM.Nonce(data: iv)
let body = ciphertext.prefix(ciphertext.count - tagBytes)
let tag = ciphertext.suffix(tagBytes)
let box = try AES.GCM.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
do {
return try AES.GCM.open(box, using: key)
} catch {
throw Failure.ciphertextFailedToAuthenticate
}
}
// MARK: - Confirmation intent
/// sha256(method | path | canonical body): the value a phone sends to prove a human confirmed
/// this exact write.
///
/// The agent recomputes it from the request it is about to forward, so a relay cannot edit the
/// recipient of an SMS in flight and keep the confirmation valid. The body is canonicalised
/// the same way the web console does it, which is why `CanonicalJSON` exists as a shared,
/// test-vectored implementation rather than a call to `JSONSerialization`.
public static func intentDigest(method: String, path: String, body: Data?) throws -> Data {
let canonicalBody = try CanonicalJSON.canonicalize(body ?? Data())
var input = Data()
input.append(contentsOf: Array(method.trimmingCharacters(in: .whitespaces).uppercased().utf8))
input.append(UInt8(ascii: "|"))
input.append(contentsOf: Array(path.utf8))
input.append(UInt8(ascii: "|"))
input.append(canonicalBody)
return Data(SHA256.hash(data: input))
}
}
private extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>zh_CN</string>
<key>CFBundleDisplayName</key><string>MultiSimAdmin</string>
<key>CFBundleExecutable</key><string>MultiSimAdmin</string>
<key>CFBundleIdentifier</key><string>fun.chick.multi-simadmin</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.2</string>
<key>CFBundleVersion</key><string>10</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/>
</dict>
<key>NSPrincipalClass</key><string>UIApplication</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array><string>arm64</string></array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,138 @@
import Foundation
public protocol SimAdminClientProtocol: Sendable {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse
}
extension SimAdminClient: SimAdminClientProtocol {}
public enum ConnectionStatus: String, Equatable, Sendable {
case online
case offline
case authRequired
case degraded
case unknown
public var label: String {
switch self {
case .online: return "在线"
case .offline: return "离线"
case .authRequired: return "需登录"
case .degraded: return "降级"
case .unknown: return "未知"
}
}
}
public struct ConnectionResult: Equatable, Sendable {
public let status: ConnectionStatus
public let requestLatencyMilliseconds: Int?
public let checkedAt: Date
/// Why the probe ended where it did.
///
/// Kept alongside the status rather than folded into it because the status vocabulary is five
/// values and the failure vocabulary is much larger: in relay mode "the relay is unreachable",
/// "the agent is offline", "the envelope was refused" and "the node returned an error" are four
/// different user actions, and a single `.offline` cannot say which one happened.
public let error: UpstreamError?
public init(
status: ConnectionStatus,
requestLatencyMilliseconds: Int? = nil,
checkedAt: Date,
error: UpstreamError? = nil
) {
self.status = status
self.requestLatencyMilliseconds = requestLatencyMilliseconds
self.checkedAt = checkedAt
self.error = error
}
/// How old this reading is, for the freshness vocabulary. A relay reading is inherently older
/// than a direct one, and saying so is the difference between "online" meaning now and meaning
/// forty seconds ago.
public var freshness: SnapshotFreshness {
SnapshotFreshness.classify(age: Date().timeIntervalSince(checkedAt))
}
}
public struct ConnectionProbe: Sendable {
private let transport: any NodeTransport
public init(transport: any NodeTransport) {
self.transport = transport
}
/// Direct-mode construction, kept for the existing call sites and tests.
public init(client: any SimAdminClientProtocol) {
self.init(transport: DirectTransport(client: client, sessions: InstanceSessionStore()))
}
public func test(_ instance: Instance) async throws -> ConnectionResult {
let startedAt = DispatchTime.now()
do {
let response = try await transport.send(
.health,
instanceID: instance.id,
endpoint: instance.endpoint
)
let latency = Self.elapsedMilliseconds(since: startedAt)
if response.status == 401 || response.status == 403 {
return ConnectionResult(
status: .authRequired,
requestLatencyMilliseconds: latency,
checkedAt: Date()
)
}
return ConnectionResult(
status: .online,
requestLatencyMilliseconds: latency,
checkedAt: Date()
)
} catch {
let latency = Self.elapsedMilliseconds(since: startedAt)
let upstream = error as? UpstreamError
return ConnectionResult(
status: Self.status(for: upstream, error: error),
requestLatencyMilliseconds: latency,
checkedAt: Date(),
error: upstream
)
}
}
/// Maps a failure onto the five-value status vocabulary the list already uses, while the typed
/// error travels alongside it for the detail view.
///
/// The relay-mode cases are mapped rather than collapsed: an offline agent is a node problem the
/// user waits out, a rejected envelope is a pairing problem that will never fix itself, and an
/// unreachable relay is neither.
static func status(for upstream: UpstreamError?, error: Error) -> ConnectionStatus {
switch upstream {
case .unauthorized:
return .authRequired
case .timeout, .relayTimeout, .relayUnreachable, .agentOffline, .agentTimeout, .nodeBusy:
return .offline
case .relayRejectedDevice, .relayNodeUnknown:
// The pairing is gone: retrying cannot help, and the user has to pair again.
return .offline
case .relayPolicyDenied, .confirmationRejected, .envelopeRejected,
.envelopeExpired, .relayResponseUnverifiable, .relayResponseMismatch,
.relayBadEnvelope, .nodeAuthFailed, .payloadTooLarge:
return .degraded
case .relayRateLimited, .nodeRateLimited:
return .degraded
case .invalidRequest, .notSupported, .transportFailed, .invalidResponse, .forbidden:
return .degraded
case .none:
if error is URLError { return .offline }
return .degraded
}
}
private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? {
let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds
let milliseconds = Double(nanoseconds) / 1_000_000
return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil
}
}
@@ -0,0 +1,7 @@
import Foundation
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}

Some files were not shown because too many files have changed in this diff Show More