PMon Dev a825fc366e feat(setup): 实例初始化配置与 Web 安装向导
新装一台实例"归谁管"以前只能靠 .env 里手敲 ADMIN_TOKEN:运维要记住随机串、
没有管理员概念、改任何业务配置都得改文件再重启。本轮把这件事收敛成
「浏览器里走一遍向导 + 配置落库 + 管理端在线可改」。

数据库(deploy/migrate.sql,两条路径共用)
- instance_config 单行表(CHECK id = 1):实例名、对外地址、自助注册、
  强制登录、设备令牌、配额、中继上限、邮件、审计保留期
- users.is_admin 列 + 部分索引
- bootstrap_state 三态 pending / adopted / ready,由「库里有没有用户」裁决 ——
  存量库升级不能断服,故不是布尔

向导(server/business/setup.js + setup/index.html)
四道护栏是前置条件而非加固项:
  1. INSTANCE_SETUP_TOKEN 未设或 <32 字符 → 404(不是 401,不对外宣布它存在)
  2. 只认 Authorization: Bearer;失败闸门在令牌校验**之前**(否则猜对就绕过限流)
  3. 只能成功一次,由数据库裁决:UPDATE ... WHERE bootstrap_state <> 'ready'
  4. 完成后永久 404,判据在库里(内存变量会让重启后复活)

鉴权(server/business/admin.js + admin-queries)
- 管理端双通道:ADMIN_TOKEN(应急/自动化)或库里 is_admin 的会话;
  审计记 operator,能区分"用管理员账号改的"和"用令牌改的"
- 新增 /api/admin/admins、账号提权/降权(不许取消最后一个可用管理员 → 409)
- 新增 /api/admin/instance-config:被 env 锁定的字段拒绝修改(409 E_ENV_LOCKED),
  不静默成功

配置层次
- 库是真相源,env 是显式覆盖层;GET 返回每个字段的来源(db/env/default)
- 密钥类配置一律不入库(库会被备份/导出/管理端读出)
- 改完同进程立刻生效(onChanged),不等 3 秒轮询

网关(server/gateway)
- **修复半个开关**:业务端的 REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 搬进库后,
  网关若仍只读 env,管理端点开"强制登录"只会关住业务端,订阅通道照样放行匿名。
  改由 index.js 每 3 秒把「库 + env 覆盖」推入 gateway.setSwitches(),
  网关自己不碰数据库(热路径只读内存字段);/health 报生效值而非 env 字面值
- 单测用假 this 直调原型方法时 this.sw 为空 → 用冻结单例兜底,避免
  TypeError 被误报成"会话索引坏了"

测试
- 新增 tests/setup-test.js(97 项):四道闸、参数矩阵、成功路径、
  并发(用行锁把两个请求压进临界区,让"输家 409"成为必然而非运气)、
  存量库升级(只提权不改口令)
- 它自带 4 个临时库并跑真实 init.sql/migrate.sql —— 顺带把"全新库首次部署"
  这条历史上出过建表顺序坑的路径也覆盖了
- dockerfile-coverage:开关扫描补上声明式 ENV_MAP(动态下标扫不到,
  覆盖曾从 5 条悄悄掉到 2 条),并按字段名判定真正的消费方

验证:bash tests/run-all.sh → 36 个套件全部通过;六组变异测试逐一证明
关键护栏"删掉即红"。README 增「首次部署:先过一遍初始化向导」。
2026-10-30 14:58:00 +08:00

墨观 InkWatch — 全平台设备性能监控系统(V4.0 骨架实现)

按《全平台设备性能监控系统商业化实施方案 V4.0》落地的可运行骨架。目标不是功能完备,而是把 V4.0 的五条架构主线用代码固定下来:

  1. 职责下沉 —— 采集/保活/传输收敛为单一 Rust 核心(rust-core/),经 FFI 供五端复用
  2. 网关无状态 —— 连接态、订阅关系、在线态全部外置 Redis(server/gateway/)
  3. 存储精简 —— PostgreSQL + TimescaleDB 承载元数据与时序(deploy/init.sql)
  4. 有界优先 —— 任何缓存/队列/集合都有明确上限(store.js、buffer.rs、transport.rs、gateway.js)
  5. 可靠性内置 —— 服务器时间权威、seq 去重补传、指数退避+抖动、优雅关闭、跨进程缓存失效

主线完成度:第 1 条已把 Rust 核心的采集 / 传输 / 保活三块补齐 (collector.rs / transport.rs / keepalive.rs),共 39 个 C ABI 导出符号 (nm -gU target/release/libpmon_core.dylib | grep -c _pmon_ = 39)、 59 项 Rust 单测、79 项真实加载产物的 FFI 冒烟。剩余未接入项集中列在文末「已知边界」。


目录结构

inkwatch/
├── README.md                   # 本文件
├── LICENSE                     # MIT
├── .env.example                # 环境变量示例(真实 .env 不入库)
├── shared/
│   ├── metrics-schema.js       # ★ 指标字段单一真相源(协议/Rust/表列/仓储全由它派生)
│   ├── protocol.js             # 协议 v1:信封校验 / 白名单 / 版本兼容 / 编码协商
│   ├── codec.js                # JSON / MessagePack 双模(含数值量化 + 结构指纹)
│   ├── accounts.js             # 账号口径 / 令牌前缀 / 限流常量 / 口令强度
│   └── permissions.js          # ★ 能力矩阵与档位表单一真相源(13 能力 × 3 角色 + ownerOnly)
├── server/
│   ├── gateway/                # 无状态 Socket.io 网关
│   │   ├── index.js            #   入口:健康检查 / 优雅关闭 / 授权失效订阅
│   │   ├── gateway.js          #   事件处理:hello / metrics / heartbeat / subscribe
│   │   └── store.js            #   Redis 访问层(定长缓冲 / 在线态 / 订阅 / 配对)
│   ├── business/
│   │   ├── index.js            #   业务服务:账号 / 配对 / 授权 / 成员 / 配额 / 审计
│   │   ├── admin.js            #   ★ 管理端:ADMIN_TOKEN 鉴权 + /api/admin/* + CSV 导出
│   │   └── admin/              #   ★ 管理台静态页(5 页 + assets,零依赖、无 CDN)
│   └── shared/
│       ├── repository.js       # PostgreSQL 仓储(参数化 SQL / 授权缓存 / 跨进程失效)
│       ├── admin-queries.js    # ★ 运营者视角查询(全站分页/统计/存储/降采样;绝不 SELECT 凭据列)
│       ├── presence.js         # ★ 在线态判定唯一入口(业务端与管理端共用,失败如实返回 null)
│       ├── mailer.js           # 发信通道(outbox / console / smtp;令牌**绝不进 HTTP 响应**)
│       └── device-identity.js  # "是不是同一台机"的唯一判据(hardware_id 优先 → 后缀 → 歧义不动)
├── rust-core/                  # Rust 核心(采集 + 传输 + 保活,五端复用)
│   ├── src/collector.rs        #   指标采集(CPU/内存/磁盘/网络/运行时长)+ 单位换算
│   ├── src/buffer.rs           #   定长环形缓冲 + 指数退避(含单测)
│   ├── src/transport.rs        # ★ 协议信封 / 连接状态机 / 心跳半开检测 / 断线补传
│   ├── src/keepalive.rs        # ★ 保活三层模型 / 守护调度 / 五端自启产物生成
│   ├── src/ffi.rs              #   C ABI 导出(37 个符号,panic 兜底 / 空指针安全)
│   └── src/main.rs             #   本地演示程序(8 段)
├── client/                     # Flutter 客户端(采集端 + 查看端 + 水墨屏看板)
│   ├── lib/host/               #   宿主层:会话/配置/账号/角色/单位/采样
│   ├── lib/ui/                 #   界面层(样式只许来自 ui/theme.dart,有护栏测试)
│   └── test/                   #   Dart 测试(含视觉令牌护栏与跨语言角色目录校验)
├── deploy/
│   ├── docker-compose.yml      # 编排:nginx + gateway + business + redis + pg
│   ├── Dockerfile.gateway      # 多阶段构建(ADR-001)
│   ├── Dockerfile.business
│   ├── nginx.conf              # WSS 反代 / 长连接调优 / /admin/ 路由
│   ├── nginx-all.conf          # 单容器统一入口(3500)
│   └── init.sql                # TimescaleDB 超表 / 保留策略 / 降采样(含增量迁移段)
└── tests/
    ├── schema-consistency-test.js   # ★ 指标字段三层一致性 + 协议常量/Dart 目录/能力矩阵跨语言 + init↔migrate 列类型(116 项)
    ├── uplink-msgpack-test.js       # ★ 上行 msgpack 解包顺序契约(52 项)
    ├── dockerfile-coverage-test.js  # ★ 镜像 COPY 覆盖性(require 图 + **静态资源目录**,49 项)
    ├── permissions-test.js          # ★ 能力矩阵 13×3 全枚举 + ownerOnly + 失败关闭(29 项)
    ├── user-lifecycle-test.js       # ★★ 用户机制端到端(66 项,自起临时实例)
    ├── admin-api-test.js            # ★★ 管理端鉴权与数据(80 项,9 个隔离实例)
    ├── ffi-smoke.py                 # ★ 真实加载 dylib 调 C ABI + 跨语言契约(79 项)
    ├── pairing-security-test.js     # ★★ 配对授权绕过回归网(离线契约 23 / 全量 84 项)
    ├── rust-frame-pipeline-test.js  # ★★ Rust 真实帧 → 白名单 → 时序表列(24 项,需起服务)
    ├── dump-rust-frame.py           #   取一帧真实采集数据的辅助脚本(供上者调用)
    ├── protocol-test.js             # 协议契约(34 项)
    ├── codec-test.js                # 编解码正确性 + 体积收益(64 项)
    ├── e2e-smoke.js                 # 端到端冒烟(35 项)
    ├── viewer-e2e-test.js           # ★★ 查看端端到端:真实采集端+配对+真实 Dart CLI(21 项)
    ├── account-test.js              # ★★ 账号端到端:账号/归属/**免配对**/历史回溯/兼容模式(74 项)
    ├── auth-mode-test.js            # ★★ 第二步开关:强制登录/配额(402)/设备令牌防冒充(39 项)
    ├── persistence-restart.js       # 持久化与重启恢复(24 项)
    ├── race-residue.js              # 断连竞态残留(3 项)
    └── leak-check.js                # 泄漏专项(重连 500 次 / 内存红线,8 项)

★★ pairing-security-test.js 是唯一以"No"型断言为主体的套件: 错误码必须被拒、重放必须被拒、串设备必须被拒、次数用尽必须失效、存储挂掉必须 503。 原因见下文「授权绕过」一节 —— 旧 e2e 只测成功路径,导致"完全不校验"与 "校验正确"在测试眼里毫无区别。

★★ rust-frame-pipeline-test.js 是本仓库最重要的一道防线:其余套件用的都是手写样本载荷, 测不出"采集层字段名与协议白名单漂移"——这正是当年 P0 能全绿通过的原因。 它用 ctypes 调 pmon_collect_frame() 取真实帧,原样上报,再查库逐项比对。

★ 标记为本轮新增。server/shared/keepalive.js 已移除 —— 保活按方案 3.4 下沉至 rust-core/src/keepalive.rs,原 JS 骨架是无人引用的重复真相源(五端方法体全是 console.log)。如需恢复:git checkout HEAD -- server/shared/keepalive.js。


快速开始

方式 A:Docker 一键起(推荐)

cd deploy
docker compose up -d --build

docker compose ps              # 5 个容器应全部 healthy
curl http://localhost/healthz  # ok

默认仅 HTTP,开箱可用。启用 HTTPS 见 deploy/nginx.ssl.conf.example。

无 TimescaleDB 镜像时(国内拉取 1GB 级镜像可能极慢):init.sql 已做成 扩展可缺省,把 PG 换成普通的即可,表结构与指标列完全一致:

POSTGRES_IMAGE=postgres:15-alpine docker compose up -d --build

此时 init.sql 会打印 NOTICE 并跳过三项存储优化(不影响任何功能测试):

跳过项 影响 生产是否需要
create_hypertable 超表分区 无按时间自动分区/drop chunk 需要(数据量大后)
add_retention_policy 自动保留 需自行写清理任务 需要
连续聚合自动刷新 device_metrics_10s 降级为普通物化视图,需手动 REFRESH 需要

对外端口(Redis / PG 仅绑回环,供本机测试直连):

服务 端口 用途
nginx 80 接入层(/socket.io/ → 网关,/api/ → 业务)
gateway 3000 Socket.io 网关(调试直连用)
business 4000 业务 REST(调试直连用)
postgres / redis 5432 / 6379 仅 127.0.0.1 绑定

凭据经 .env 注入,默认值为本地开发用。生产务必覆盖 POSTGRES_PASSWORD。

想一个容器跑全部? 单容器路径(postgres + redis + gateway + business 四进程一个容器,supervisord 各自守护;数据卷同名,与四容器随时互切、 数据不动):

cd deploy
docker compose -f docker-compose.all.yml up -d --build
docker exec pmon-app-1 supervisorctl status   # 四进程 RUNNING

取舍(单容器是单机运维形态):进程隔离弱于四容器、容器重启四进程齐动、 升级要重建整镜像;好处是常驻容器从四个变成一个,数据卷同名可随时互切。

首次部署:先过一遍初始化向导

全新库初始化之前,除 /health 与 /setup 外所有接口一律 503。这是刻意 的顺序约束:还没确定"这台实例归谁管"就先对外服务,最坏结果是客户端先接进来、 数据先落一半、配置还是代码默认值 —— 一个事后无从复现的中间态。

cd deploy
# 一次性引导令牌,≥32 字符;不设 = 向导根本不存在(/setup 与 /api/setup/* 全 404)
echo "INSTANCE_SETUP_TOKEN=$(openssl rand -hex 24)" >> .env
docker compose up -d --build

浏览器打开 https://<你的域名>/setup,三步:给这台实例起个名字 → 填管理员 邮箱与口令 → 粘引导令牌。完成后自动跳到 /admin/,向导从此永久 404。

三条要记住的:

事项 说明
令牌只认 Authorization: Bearer 不接受 ?t=:query 会进 nginx access log、浏览器历史、Referer
只能成功一次 由数据库裁决(UPDATE ... WHERE bootstrap_state <> 'ready')。两个请求同时到达时,第二个拿 409
完成后重启也不复活 判据是库里的状态,不是内存变量

存量库升级不会看到这一步:migrate.sql 按"users 表里有没有数据"把状态落成 adopted,直接按现有配置继续服务(升级一台跑着几十台设备的实例,先 503 五分钟 等运维点向导就是事故)。此时打开 /setup 可以补一个管理员,已存在的邮箱只提升 权限、不改口令 —— 自动改口令会造成"填错成别人邮箱 = 改掉了别人的口令"。

配置存在哪:instance_config(单行表,CHECK (id = 1))。管理端 「实例设置」页改完立刻生效,不必重启。INSTANCE_NAME / REQUIRE_AUTH / ALLOW_OPEN_REGISTER / ENFORCE_DEVICE_QUOTA / FREE_DEVICE_LIMIT / RELAY_MAX_BYTES / MAIL_* 这些环境变量仍然有效,且优先级高于库(界面上会 标成"被环境变量锁定")—— 这样配置文件与界面两个入口不会互相打架。

⚠️ 密钥类配置一律不入库:POSTGRES_PASSWORD、ADMIN_TOKEN、 INSTANCE_SETUP_TOKEN、SMTP 口令只留在 .env。库会被备份、导出、被管理端 读出,塞进去等于把"要 shell 权限才拿得到"降级成"能读库就拿得到"。

⚠️ REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 这两个开关跨网关与业务两个进程, 两边都从同一份库配置取(env 覆盖),网关每 3 秒同步一次。只改一半的后果是 "业务端拒绝匿名请求、订阅通道照样放行" —— 半个开关比没有开关更危险。

方式 B:本地开发(不走容器)

docker run -d --name pmon-redis-dev -p 6379:6379 redis:7.4-alpine \
  redis-server --save "" --appendonly no

docker run -d --name pmon-pg-dev -p 5432:5432 \
  -e POSTGRES_USER=pmon -e POSTGRES_PASSWORD=pmon_dev_only -e POSTGRES_DB=pmon \
  timescale/timescaledb:2.17.2-pg16

# 初始化表结构
docker exec -i pmon-pg-dev psql -U pmon -d pmon < deploy/init.sql

启动服务

REDIS_URL=redis://127.0.0.1:6379
PG_URL=postgres://pmon:pmon_dev_only@127.0.0.1:5432/pmon

# 网关(端口 3000)—— 必须带 --expose-gc 才能周期回收堆
cd server/gateway && npm install && \
  PORT=3000 REDIS_URL=$REDIS_URL DATABASE_URL=$PG_URL \
  node --expose-gc --max-old-space-size=256 index.js

# 业务服务(端口 4000)—— 需 REDIS_URL 才能发配对码与广播授权失效
# 缺 REDIS_URL 时配对接口会失败关闭(/health 报 degraded、配对返回 503),
# 这是有意为之:配对码是一次性凭据,不接受"无共享存储也能跑"的降级
cd server/business && npm install && \
  PORT=4000 REDIS_URL=$REDIS_URL DATABASE_URL=$PG_URL node index.js

验证

cd rust-core && cargo test --release      # Rust 核心单测(59 项)

cd tests && npm install
bash run-all.sh                # 一把梭:25 个套件(离线即可跑 + 需起服务)
# 离线套件(无需任何外部依赖)
node dockerfile-coverage-test.js  # ★ 镜像 COPY 覆盖性(按 require 图)
node uplink-msgpack-test.js       # ★ 上行 msgpack 解包顺序契约
node schema-consistency-test.js   # ★ 指标字段三层一致性 + 协议常量跨语言校验
node protocol-test.js             # 协议契约
node codec-test.js                # 编解码 + 体积
node pairing-security-test.js --offline-only   # ★ 配对契约与判定顺序(源码级)
node permissions-test.js          # ★ 能力矩阵 13×3 全枚举 + ownerOnly + 三条失败关闭
node user-lifecycle-test.js       # ★★ 用户机制端到端(自起临时实例,只需 PG + Redis)
node admin-api-test.js            # ★★ 管理端鉴权与数据(自起 9 个隔离实例)
node setup-test.js                # ★★ 安装向导:四道闸 + 并发 + 存量库升级(建 4 个临时库)
python3 ffi-smoke.py              # ★ 真实加载 dylib 调 C ABI
# 客户端(Dart):查看端逻辑 48 + 查看端界面 17 + 配对卡界面 6 + 自适应单位 24 + FFI 冒烟 28
cd ../client && bash scripts/test.sh   # 入口脚本已负责清代理变量(否则本地 WS 会假失败)

# 需起服务(Redis 6379 + PG 5432 + 网关 3000 + 业务 4000)
node pairing-security-test.js     # ★★ 配对授权绕过回归网(含 D 段 HTTP 端到端)
node viewer-e2e-test.js           # ★★ 查看端端到端(真实 Dart CLI 订阅真实采集端)
node account-test.js              # ★★ 账号体系(注册/登录/归属/免配对/向后兼容)
node auth-mode-test.js            # ★★ 安全开关(就地拉起严格模式临时实例,跑完即停)
# 用户机制(user-lifecycle-test.js)已在上面的离线区:它自带实例,不依赖这两个服务
# 查看端 CLI 单独跑(真人可读输出):
#   cd ../client && dart run bin/pmon_viewer.dart --account acct_demo --device <id> --seconds 8
node rust-frame-pipeline-test.js  # ★★ Rust 真实帧全链路落库断言
node e2e-smoke.js                 # 端到端冒烟
node persistence-restart.js       # 持久化与重启恢复(自带启停网关)
node race-residue.js              # 断连竞态残留
ROUNDS=500 node leak-check.js     # 泄漏专项(约 80s)
node perf-bench.js                # ★ 性能基准(动网关热路径前后各跑一次,数字本地存档)

⚠️ 改过 shared/metrics-schema.js 后必须重启网关:白名单在进程启动时载入并缓存, 只改文件不重启会让"新代码 + 旧白名单"并存 —— 这正是全链路测试会报红的场景。

ffi-smoke.py 需要能 import ctypes 的 Python 3;产物缺失时它会自动尝试 cargo build --release。指定解释器:PMON_PYTHON=/path/to/python3 bash run-all.sh。


已验证结果

验证项 结果
指标字段一致性(三层命名 + 协议常量 + Dart 显示目录跨语言 + init.sql 建表顺序 + 能力矩阵跨语言 + init/migrate 列类型) 116 / 116 通过
上行 msgpack 链路(解包顺序 / 两形态 / 错误契约) 52 / 52 通过
镜像构建覆盖性(按 require 图 × 2 镜像 + 静态资源目录 + 安全开关通路 + 单容器端口回环) 49 / 49 通过
FFI 冒烟(真实加载 dylib + 跨语言契约 + 空指针 + 反复建销) 79 / 79 通过
Rust 核心单测(采集/缓冲/传输/保活/FFI 安全) 61 / 61 通过
协议契约(白名单/版本/数值规整/协商) 34 / 34 通过
编解码(正确性 + 边界 + 体积) 64 / 64 通过
配对码安全(错误码/重放/串设备/次数用尽/限流/失败关闭/审计) 84 / 84 通过(含离线契约 23 项)
真实帧全链路(Rust 采集 → 白名单 → 时序表列) 24 / 24 通过
端到端冒烟(闭环/授权/降级/清理) 35 / 35 通过
账号体系(注册/登录/登出/归属/认领/免配对订阅/历史回溯/兼容模式) 75 / 75 通过
权限矩阵(13 能力 × 3 角色全枚举 / ownerOnly 授权≠夺权 / 三条失败关闭 / 旧新入口一致) 29 / 29 通过
用户机制端到端(注册验证 / 会话踢人 / 改密 / 找回防枚举 / 邀请与角色 / 转移归属 / 配额真拦 / 注销冷静期 / 审计) 66 / 66 通过
安全开关(强制登录 / 配额上限(402)/ 邮箱验证后按档位放开 / 配额关掉后真的回退 / 令牌轮换与防冒充 / 全新设备首次接入 / 上行通道不得绕过令牌) 41 / 41 通过
云端管理端(口令四形态失败关闭 / 短口令=未设置 / 口令不进 query / 不回显任何凭据 / CSV 公式注入 / 写动作审计 / IP 白名单 / 暴力上限 429) 80 / 80 通过
客户端 Dart 测试(查看端逻辑+界面 / 配对卡 / 账号与角色目录 / 单位自适应 / 格式化唯一出口 / 配置变更 / 视觉令牌护栏 / 机型采集 / 连接域 / 中继 / 采样调度 / 历史 / 成员卡) 598 / 598 通过
查看端端到端(真实采集端 + 配对 + 真实 CLI 查看端) 21 / 21 通过
持久化与重启恢复 24 / 24 通过
断连竞态残留(100 轮零等待重连) 3 / 3 通过
泄漏专项(重连 500 次,GC 后稳态) 8 / 8 通过;稳态 heap_used 0.0% / −12.5%,会话与订阅零残留
镜像构建 + 容器内模块解析 两镜像均构建成功并在容器内 require() 成功
Docker 全栈实跑(nginx+gateway+business+redis+postgres 全容器化) 35 套件 / 0 失败,容器日志零 error

全量口径:bash tests/run-all.sh → 35 套件 / 1965 项断言全通过 / 0 失败 / 0 跳过 (1965 = 34 个带计数的套件自报合计;隐私扫描那套不报项数,只报结论。 含安装器 e2e 38 项与全历史扫描。配对安全套件的离线契约段与其全量段有 23 项重叠, 去重后为 1942 项唯一断言)。

顺带校了两处标签漂移:run-all.sh 里结构性守卫写 48、实跑 50, 镜像覆盖性写 45、实跑 49。这类漂移本身不致命,但它让"总断言数"变成 一个谁也复核不了的数字 —— 而本项目恰恰要求数字可验证。 该文件头部早就写了这条教训("标签写 416 而实跑已 598"),仍在复发, 故这次连头部注释一起校正。 上表的服务端套件已在 Docker 全栈(5 容器 healthy、经 nginx :80 入口)上复跑通过, 非仅本机进程直跑。

真实帧实测值(rust-frame-pipeline-test.js 打印,本机 M3 Max):

cpu_usage=30.73  mem_usage=36.41  mem_used_mb=47721.27
disk_free_gb=3251.79  net_rx_mbps=4.55  net_tx_mbps=1.71  uptime_s=106001

关键不变量(已被测试锁定):

  • 环形缓冲任何时刻 len <= capacity(Redis 定长 60,Rust 侧默认 120)
  • 退避延迟严格封顶于 max_ms(抖动只向下取,不突破上限)
  • 未声明字段一律丢弃(screen_content 等敏感键无法穿透)
  • 连接断开后订阅集合与在线态零残留,含 await 期间断连的竞态场景
  • 上行 ts 一律由服务端改写(服务器时间权威)
  • 授权关系与指标重启后不丢(PG 持久化)
  • 断线补传同一 seq 不产生重复行(有界滚动窗口去重)
  • ★ 采集帧全部 17 个权威指标字段一个都不被白名单丢弃
  • ★ Rust MetricsFrame 字段集合 ≡ JS 权威清单 ≡ 时序表指标列(三处集合相等)
  • ★ Rust 与 JS 的协议版本、事件集合、时间常量逐项相等(由产物实测比对)
  • ★ 重连退避跨 12 次连续失败单调不减且次次 ≤ 60s;连上即归零
  • ★ 半开检测:静默 30s 转黄、90s 转灰、任意入站消息即复活
  • ★ 补传窗口溢出必须可观测(dropped 计数),不允许静默吞掉
  • ★ 用户主动退出后守护永不重启;连续崩溃达上限即放弃(防崩溃循环)
  • ★ iOS 平台不生成任何自启伪产物(返回明确错误)
  • ★ 网关入站必须先解包后校验(源码契约 + 真实形态数据穿透白名单,双重锁定)
  • ★ 解包失败一律显式报错,绝不退化成空对象(未知指纹 / 损坏载荷各有专属错误码)
  • ★ 两种 msgpack 包裹形态(整帧 / 仅 payload)都能还原,且 JSON 默认路径不受影响
  • ★ 镜像内每个被 require 到的文件都必须被某条 COPY 规则覆盖(按 require 图强制)
  • ★ 每条 COPY 规则必须指向真实存在的文件(防止删文件后漏改 Dockerfile)
  • ★★ Rust 产出的真实帧整帧 17 字段都能穿过白名单并落进对应时序列,数值逐项一致
  • ★★ 帧中为 null 的指标(平台不可得的增强项)落库必须为 NULL,不得编造 0
  • ★ 能力矩阵 JS ↔ Dart 双向相等(13 能力 × 3 角色 + ownerOnly 集合 + 档位表)
  • ★ 授权 ≠ 夺权:被授予 owner 角色的成员仍不能转移归属 / 删除设备
  • ★ 一次性凭据只存哈希、原子消费(UPDATE ... WHERE used_at IS NULL RETURNING); 重放、跨账号使用、换人代接受邀请一律拒绝
  • ★ 找回口令对存在与不存在的邮箱返回完全相同的键(防账号枚举)
  • ★ 任何响应体都不得出现令牌本体(pm_rst_ / pm_vfy_ / pm_inv_ 前缀一律不出现)
  • ★ 配额用尽返回 402 而非 403,且 body 带 used / quota / plan
  • ★ 邮箱未验证只能绑 1 台设备;验证后按档位放开(验证本身零成本)
  • ★ 注销有 30 天冷静期:期内登录即撤销注销,期满才由清理逻辑真正删除
  • ★ 管理端口令短于 32 字符视为未设置(404,不是"勉强能用"); 绝不接受 ?token=(它进 nginx 日志、浏览器历史、Referer)
  • ★ 管理端任何响应都不得出现 secret_hash / secret_salt / 完整 token_hash (会话与设备令牌只给哈希前 12 位)
  • ★ 管理端写动作全部落审计(admin_*),CSV 导出也记审计(带走的是全站邮箱)
  • ★ CSV 里以 = + - @ 开头的用户字段必须加前导单引号 —— 否则 Excel 打开就执行
  • ★ 管理台的静态资源目录必须单独 COPY(*.js 通配不跨目录,漏了只有 /admin/ 404)

三条实测工程结论

① MessagePack 的收益前提是「先量化」

原文预估 msgpack 可省 40-60% 带宽,实测证伪:性能指标几乎全是浮点数, JSON "37.5" 占 4-5 字节,而 msgpack float64 固定 9 字节 —— 裸 msgpack 反而膨胀, 单帧实测 −1.5% ~ +0.6%。

修正方案:浮点先量化为定点整数(×100,两位小数)再走 msgpack。 实测收益(codec-test.js 可复现):

场景 JSON 裸 msgpack 量化载荷 稳态上行
单帧 405B — −1.5% 24.2% 15.6%
10 帧 — −0.2% 24.7% 23.9%
60 帧 — −0.3% 24.6% 24.4%

另:量化把 78.125 舍成 78.13 —— 性能指标约定两位小数,这是设计约束而非缺陷。

② 量化标记必须「按位置」而非「按数值类型」

早期实现靠"是否为浮点"决定是否 ×100,触发三类静默误缩放: 混类型数组 [78.13, 91] 中整数被乘、纯整数数组 [1,2,3] 膨胀成 [100,200,300]、 同名字段先浮点后整数时后者被多除一次。

正确做法:路径一旦登记,该路径下所有数值一律量化/还原,与数值类型完全解耦。

③ 泄漏判定必须用 GC 后稳态 heap_used,绝不可用 RSS

高频连接建立/销毁会让 heap_used 短暂冲高(实测 +46%),但待 GC 周期触发后回落至基线以下。 V8 的回收是延迟惰性的,RSS 中含大量"已分配但空置"的预留空间。 本项目早期版本正因在中间态取 RSS 而误判为泄漏(当时得 20.5%/53.4%)。 落地要求:网关以 --expose-gc --max-old-space-size=256 启动并每 30s 周期回收。详见方案第 12.6 节。


分布式一致性与并发正确性的三个坑(均已修复并锁定)

坑 1:授权缓存无法跨进程失效

网关与业务服务是两个进程,各自持有进程内授权缓存。 业务服务 grantAccess 只能清自己的缓存,网关在 TTL 内仍返回旧结果 —— 用户"刚授权却看不到"。

修法:授权变更后经 Redis pub/sub 广播失效(pmon:auth:invalidate), 网关订阅该频道并清本地缓存。TTL 从 5s 降到 2s 仅作兜底。 e2e-smoke.js 步骤 [3] 专门验证「授权后立即订阅即成功,未等 TTL」。

坑 2:msgpack 结构指纹表是「每进程独立」的

编码端首次下发完整路径清单、之后只发 8 字节指纹。但每个订阅端都是独立进程, 各自指纹表初始为空 —— 编码端一旦进入稳态只发指纹,新订阅端永远解不开。

修法:新订阅端接入时,服务端为该订阅端单独补发一帧带完整清单的首帧(forcePaths), 此后并入稳态流;解码端遇到未知指纹返回 UNKNOWN_PATH_FP 错误对象(不抛异常、不断连)。

坑 3(严重):await 期间的断连竞态导致 Redis 状态永久残留

onHello 在 touchPresence 之前有 await registerDevice()(PG 往返)。 若客户端在此期间断开,onDisconnect 会先执行完毕 —— 而那一刻 session.deviceId 尚未赋值(或 subscribed 还是空),于是清理逻辑被跳过;随后 hello 处理恢复执行, 把在线态写了进去,再也没人清理。

实测影响:50 轮「hello 后立即断开」残留 47 个在线态条目(94% 泄漏率)。 真实场景下,移动网络抖动、客户端进程重启都会触发高频短连接 —— 这会持续污染 Redis。

修法:session 增加 gone 标记,onDisconnect 置位;onHello / onSubscribe 在每个 await 之后复检 session.gone || socket.disconnected,已断开则跳过状态写入。 race-residue.js 以 100 轮零等待重连锁定该不变量。


容器化部署的两个坑(均已修复)

坑 A:network_mode: host 会让服务名解析全部失效

网关若用 network_mode: host,就脱离了 compose 桥接网络, REDIS_URL=redis://redis:6379 里的 redis 无人解析,日志刷屏:

[redis] error: getaddrinfo ENOTFOUND redis

宿主机视角下没有名为 redis 的 DNS 记录,只有 compose 内网才有。

修法:用默认桥接网络 + ports 映射;容器间用服务名通信, nginx.conf 的 upstream 也从 127.0.0.1:3000 改为 gateway:3000。 (host 网络确实能省一层 NAT,但与 compose 服务发现互斥,单机也要二选一。)

坑 B:nginx 强依赖证书导致「clone 下来必崩」

certs/ 被 .gitignore 排除(私钥不能入库),但 nginx.conf 硬引用 /etc/nginx/certs/fullchain.pem —— 容器进入 Restarting 死循环:

[emerg] cannot load certificate "/etc/nginx/certs/fullchain.pem"

修法:默认配置改为 HTTP-only 开箱可用,HTTPS 独立为 deploy/nginx.ssl.conf.example;.gitignore 改为 certs/* + !certs/.gitkeep, 保留挂载点目录而不放行内容。

职责下沉补完:传输与保活(本轮落地)

方案 3.2 表 3 把「采集 / 保活 / 传输」三件事划给 Rust 核心,但骨架期只落了两件半: 采集已下沉,传输与保活仍是空的(server/shared/keepalive.js 五端方法体全是 console.log, 且无人引用)。本轮按方案 3.4 修订项补齐。

ADR-002:Rust 核心不做 socket I/O

transport.rs 只实现状态机,不持有任何 socket。理由是方案 3.2 要求的四件事 (协议编解码、重连退避、心跳、断线缓存补传)没有一件需要 socket:

若在 Rust 内置网络 reactor 只做状态机
每个端被塞进一套异步运行时 产物仍是纯静态库,无运行时绑定
与「核心常驻 < 30 MB」硬指标冲突 内存占用可预测
socket 生命周期归 Rust,无法与 UI 生命周期绑定 宿主(Dart)持 socket,随 UI 启停
状态机难以单测(要起真实连接) 纯函数式,57 项单测离线可跑

接口形态:宿主把事实喂进来(on_connected / on_disconnected / on_rx / on_ack / on_network_available),状态机吐出该做什么(next_action() → 发心跳 / 立即重连 / N 毫秒后重连 / 补传这些帧)。

保活下沉的落地形态

keepalive.rs 生成真实的平台产物内容,而不是占位注释:

平台 生成物 关键语义
macOS LaunchAgent .plist + launchctl 装卸命令 KeepAlive.SuccessfulExit=false —— 系统层面只对崩溃重启,用户主动退出不重启
Linux systemd user unit + systemctl 装卸命令 Restart=on-failure + StartLimitIntervalSec/Burst —— 崩溃循环有界;含 MemoryMax=5M 与 enable-linger 提示
Windows 注册表 Run 项 + 计划任务兜底 注册表项不提供崩溃重启能力,故必须另建计划任务
Android AndroidManifest.xml 片段 + 电池优化引导 自启是构建期声明,运行期无法动态注册;附各厂商引导路径
iOS 明确返回错误,不生成任何产物 系统策略硬限制,虚构自启配置只会让客户端误判能力

守护层(方案 8.3)落在 Rust 而非各端:检查间隔 3000ms / 验收期限 5000ms(方案 8.5 要求强杀后 5 秒内拉起)、崩溃退避 1s→2s→…→封顶 60s、连续失败达上限即放弃、 用户主动退出置位后永不重启。


静默丢数据:指标字段三层命名分裂(已修复并锁定)

本轮在动传输模块之前做了一轮核对,发现一处不报错、不打日志、但链路实际断掉的缺陷。

现象

同一批指标在四处各写了一套命名:

位置 CPU 内存占用 网络
rust-core/src/collector.rs(采集) cpu_total mem_used_pct net_tx_bps(字节/秒)
shared/protocol.js(白名单) cpu_usage mem_usage net_tx_mbps(Mbps)
deploy/init.sql(表列) cpu_usage mem_usage net_tx_mbps
server/shared/repository.js(写入) cpu_usage mem_usage net_rx_mbps

三处交集只有 cpu_cores / mem_used_mb / mem_total_mb 三个字段。

后果

真实采集帧进入 sanitizePayload() 后,CPU 使用率、内存占用、磁盘空间、网络速率 全部被判为"未声明字段"静默丢弃;device_metrics.cpu_usage 等列恒为 NULL。 协议测试、编解码测试、端到端测试全是绿的 —— 因为它们各自用自己那套字段名的样本, 没有一处校验过"采集端的真实字段能否穿透白名单"。

单位也在漂移:Rust 给的是字节/秒,表列口径是 Mbps,中间少一次换算(差 8 个数量级)。

修法

  1. 建立 shared/metrics-schema.js 作为单一真相源,四处全部由它派生: 协议白名单、Rust 结构体字段名、表列、仓储 INSERT 列清单。
  2. 网络速率统一按 Mbps 口径,Rust 侧补 * 8 / 10^6 换算。
  3. 补齐表结构缺列(mem_used_mb / mem_total_mb / disk_free_gb / disk_total_gb / disk_write_mbps / battery_temp)与降采样视图,并附可重复执行的增量迁移段。
  4. 新增 tests/schema-consistency-test.js(48 项):解析 Rust 源码、SQL 原文与 JS 清单做集合相等校验;再用一个"17 字段全有值"的样本帧过一遍白名单, 断言一个字段都不丢 —— 把这类缺陷从"线上静默"变成"提交时红灯"。
  5. 新增 tests/ffi-smoke.py(79 项):真实加载编译产物,调 pmon_metric_keys_json() 与 pmon_protocol_constants_json() 与 JS 侧事实比对,锁住跨语言契约。
  6. 新增 tests/rust-frame-pipeline-test.js(24 项):用 Rust 核心真产出的那一帧 跑完整链路并查库逐项比对 —— 手写样本永远测不出这类漂移,这是唯一能兜住的防线。

教训:协议测试全绿 ≠ 链路通。凡是"多层各自持有一份清单"的地方,都必须有一个 用真实形态数据端到端穿透的测试,否则漂移是静默的。


协议声明与实现不一致:msgpack 上行从未解码(已修复并锁定)

现象

方案 5.5 规定 agent:metrics 高频上行走 MessagePack,网关也在 server:config 里 回告客户端 encoding: "msgpack"。但网关从来没有解码过上行:

socket.on('agent:metrics', (raw) => this.safe(socket, () => this.onMetrics(socket, raw)));
// onMetrics 第一行就是 P.validateEnvelope(raw, now) —— 没有任何解码步骤

于是两种包裹形态各有各的坏法:

客户端写法 后果
整帧包裹 codec.encode(envelope) 缺 v 字段 → 被 E_BAD_VERSION 拒(有声音,但链路完全不可用)
仅载荷包裹 {...env, payload: encode(payload)} 信封完整 → 校验"通过",而 __mp/__q 不在白名单 → 指标被静默清空

第二种是最危险的:服务端不报错、返回正常,客户端自以为按指示启用了 msgpack,数据却全是空的。

修法

  1. shared/codec.js 新增 unwrapInbound() / unwrapDownlinkMetrics(),把线格式的判定 集中到一处(调用方不要自己判 __mp),并明确记录两种包裹形态。
  2. 网关的事件绑定改为统一封装 bind():先解包 → 再校验 → 才进 handler, 四个上行事件全部走同一入口,不留裸 socket.on。
  3. 解包失败必须显式报错,绝不允许退化成空对象:未知指纹 → E_UNKNOWN_PATH_FP (新增错误码,提示带完整清单重发或退回 JSON);载荷损坏 → E_BAD_ENVELOPE。
  4. tests/e2e-smoke.js 的下行取值改用 codec.unwrapDownlinkMetrics() —— 原先它自己判 payload.metrics.__mp,与网关"整帧包裹"的实际写法不一致,属于测试与实现各持一套契约。
  5. 新增 tests/uplink-msgpack-test.js(52 项):含反例断言(证明顺序颠倒真的会丢数据)、 两形态还原、错误契约、下行取值三形态、以及网关源码契约(unwrapInbound 必须先于 handler)。

授权绕过:配对码从不校验(已修复并锁定)

四类事故里唯一一个安全类,也是唯一一个"测试全绿但功能完全没实现"的事故。

现象

POST /api/pairing/verify {"code":"000000","device_id":"mac-autostart-test"} → {"ok":true,"authorized":true}。任意 6 位数字、任意 device_id,直接拿到该设备的订阅权限。

根因:业务服务的 verify 只做了 if (!/^\d{6}$/.test(code)) return 400, 从未比对配对码本身;/api/pairing/code 生成后也只 res.json({code}) 返回,从未写入存储。 而网关 server/gateway/store.js 里恰好躺着一套完整的 setPairingCode / consumePairingAttempt / checkPairingRateLimit(键名、TTL、 尝试次数、限流一应俱全)——全仓库无人调用。

于是同一份契约两处实现、交集为空:一处只生成不存储,一处能存储但没人用。 这与指标字段分裂、msgpack 线格式、Dockerfile COPY 是同一个工程缺陷的第四次复发: 多层共享的契约,被多处各写一套。

为何测试全绿

tests/e2e-smoke.js 的第 3 步硬编码 code: '000000' 去换授权 —— 它验证的是 "授权后能否订阅"(缓存失效广播),从不提交一个错误码。 在只测正向的测试眼里,"完全不校验"与"校验正确"的行为完全一致。

推论:任何鉴权/校验逻辑,若测试里没有一条"No"型断言(错误凭据必须被拒), 这条逻辑就等于没有测试覆盖。

修法

  1. 新建 shared/pairing.js 作为唯一真相源:键名(pairing:{code} / pairing:device:{id} / ratelimit:pairing:acct|ip:*)、TTL 300 秒、 失败上限 5 次、账号限流 10 次/小时、IP 限流 30 次/小时全部在此定义, 网关与业务服务一律派生,禁止再硬编码。
  2. 授权判定链收敛为一处,顺序即安全语义: 参数 → 格式 → 存储可用性(失败关闭) → IP 限流 → 账号限流 → 比对配对码(一次性消费) → 才 grantAccess。 授权调用排在拒绝分支之后,并由源码结构断言锁死(删掉比对就会红)。
  3. 一次性消费用 Redis MULTI(GET+DEL) 原子取走:并发提交同一码时恰好一次成功, 不留"先查后删"之间的重放窗口(C 段断言)。
  4. 失败也计数:旧限流统计的是"审计日志里成功记录数",而成功才写审计 → 计数恒 0, 10 次/小时的约束形同虚设。现改为 Redis 计数进入即计,失败尝试同样消耗额度。
  5. 删除 store.js 里的重复实现,只留注释说明为何删除(保留事故记录)。
  6. 存储不可用时失败关闭返回 503:宁可配对整体不可用,也不出现"谁都能授权"的窗口。 /health 的 checks.pairing 如实反映,避免编排层以为健康而继续放流量。

一个被方案原文掩盖的语义细节

「单个配对码验证失败 5 次即作废」的扣次对象必须是目标设备当前展示的那个码。 最初实现扣的是"提交上来的那个码",但攻击者猜的码在 Redis 里根本不存在 (每个猜测值都是一条独立记录)→ 扣空气 → 5 次作废完全失效 (本套件 D19 当场报红才发现)。代价是攻击者可连猜 5 次作废用户屏幕上的码 (轻量 DoS,重新生成即可),真正的护栏是账号级与 IP 级限流。

失败关闭有三种形态,都会静默放行(逐一实测并堵住)

「Redis 挂了就 503」这句话掩盖了三种完全不同的故障形态,只堵第一种是不够的:

形态 现象 只做基础判断的后果 处置
① 未配置 / 连接失败 pairingStore === null 曾经直接跳过校验 → 任意码通过 启动即判空 → 503
② 连接被断(socket 关闭) node-redis 默认把命令塞进离线队列等重连 请求挂起,既不拒也不通过 → 接口假死;并发一上来连接被占满 客户端加 disableOfflineQueue: true,断连即失败 → 503
③ 连接在但对面不应答(容器 pause / 网络黑洞) TCP 仍是 ESTABLISHED,离线队列机制完全不适用,命令无限期挂起 同上,且永远不会自愈 存储层自带 1.5 秒操作上限 → 503

第③种是最隐蔽的:它既不是"连接失败"也不是"连接断开",写惯了 try { redis.get() } catch { 503 } 的人会以为已经覆盖了。 实测(docker pause pmon-redis-1):

Redis 卡住 → HTTP 503  用时 1.5156s     ← 有界失败关闭,不是挂到客户端超时
恢复后     → HTTP 400  用时 0.0071s     ← 7ms 自愈,无需重启服务

通用推论:任何安全判定链路上的外部依赖,都必须同时回答三个问题 —— 没配上时怎么办、断开时怎么办、卡住时怎么办。答案必须是同一个:明确拒绝。

实测证据

项目 结果
修复前跑新套件(变异验证) 20 项红,并当场复现 wrong code → {"ok":true,"authorized":true}
修复后 84 / 84 绿,连续两轮重复运行同样绿(限流键自清理)
端到端冒烟 由 24 项增至 27 项(新增"生成真码 / 错码被拒 / 错码未授权")
无 REDIS_URL 直起业务服务 /health → 503 degraded(checks.pairing: unavailable);提交配对码 → 503 E_PAIRING_UNAVAILABLE,/api/auth 查授权为空
Redis 容器 pause 4 秒 提交配对码 → 503,1.516s(失败关闭且等待有上界);unpause 后 → 400,7ms(自愈)
Redis 连接被 CLIENT KILL 命令不再排队等重连,立刻失败 → 503

新增 tests/pairing-security-test.js(84 项,5 段):A 源码契约 23 项(离线可跑)、 B 存储语义 30 项、C 并发双花 2 项、D HTTP 端到端 24 项、E 审计留痕 5 项。


账号与设备归属:同账号的设备免配对(方案 6.4 第一步,已落地)

为什么做这个

在此之前,"看设备"只有一条路:别人给你配对码。但自己的设备本该直接可见 —— 这正是方案 6.4「账号与计费体系」的内容,而库里其实早就躺着半套骨架:

已有 当时的状态
users(id, email UNIQUE, display_name, plan) 表建好了,业务层从未写入过
devices.owner_id → users(id) 字段与外键都在,从未写入过
device_grants.account_id 是 TEXT 存的是客户端自报的 acct_xxx

于是有两套互不相通的身份空间:设备归属走 BIGINT→users,授权走 TEXT 自报串。

改法:把身份口径统一成一处

登录身份 = String(users.id),设备归属与配对授权都用它。权限规则收敛为一条:

可看 = 设备归属我(devices.owner_id == 我)  或  存在 device_grants(device_id, 我)

前半句就是「同账号的设备直接记录」——免配对;后半句保留跨账号配对授权。 规则本体是 shared/accounts.js 的 canViewDevice()(纯函数、有单测), repository.canView() 只负责取数据,业务服务与网关共用同一份判定 —— 避免"权限规则"在两处各写一份(本仓库已因清单分裂翻车五次)。

三条失败关闭纪律(都不是可选项)

  1. 带了令牌但无效 → 明确拒绝,绝不降级成匿名。 否则任何人发一个废令牌就能把认证关掉 —— 那是"失败开放", 与配对码的失败关闭三形态同一类问题。有测试专门钉住 (假令牌订阅必须回 E_UNAUTHORIZED,不是"当成匿名")。
  2. 认领必须登录:兼容模式的"身份"是自报字符串,无法证明设备归属, 若允许它认领,知道 device_id 就能抢设备(那就是第二次授权绕过)。
  3. 账号功能必须有数据库:内存模式下账号端点直接 503, 而不是"另写一套内存实现" —— 两套实现必然漂移(本仓库的第五次教训仍记忆犹新)。

与方案原文的一处有意偏离(别当成漏做)

方案写的是「短效 JWT(15 分钟)+ 刷新令牌轮换」,同时要求设备令牌"云端可随时吊销"。 JWT 是无状态的、天然难吊销(要靠黑名单或极短 TTL 兜),而本项目当前规模 (V1 ≤10 用户、单机)并不需要无状态校验,反而更需要"点一下就失效"。故实现为:

  • opaque 令牌存库(只存 sha256 哈希)+ 服务端可吊销 + 滑动过期(7 天)
  • 吊销语义:标记一行即失效,无需等 TTL、无需黑名单;脱库也拿不到可用令牌
  • 设备令牌(pm_dev_)与查看会话(pm_sess_)分离,各自可吊销

实质要求(短效可轮换、与设备令牌分离、云端可吊销)全部满足。

限流分三档(不要合成一个)

桶 粒度 默认 为什么这样分
账号 邮箱 10 次/小时 挡针对某个账号的爆破
登录 IP 来源 IP 50 次/小时 NAT 后合法用户多,桶太紧只误伤好人;攻击者换 IP 就绕过了
注册 IP 来源 IP 20 次/小时 挡批量注册

口令用 crypto.scrypt(N=2^15, r=8)——零新依赖,且必须显式给 maxmem: Node 默认上限 32MB 而该参数需 33.5MB,不给就直接抛 ERR_CRYPTO_INVALID_SCRYPT_PARAMS (实测踩过,报错发生在登录路径上很容易被误判成网络问题)。

实测数字

项目 结果
账号端到端套件 51 / 51 通过(注册/登录/登出、归属、认领、免配对订阅、兼容模式、审计留痕)
关键反向断言 假令牌被拒(401/E_UNAUTHORIZED)、账号 B 看不到账号 A 的设备、抢他人设备 409、登出后订阅立即失效
兼容性 旧客户端(不带令牌)+ 配对码流程行为不变;pairing-security-test 84 项、e2e-smoke 35 项均未回归
客户端 账号卡(登录/注册/登出)+ 设备列表关系徽标(我的 / 他人授权)+ 采集页「认领本机」

第二步:安全开关(已落地,默认全关,改环境变量即切换)

强制认证与配额限制都是破坏性变更(现网还有旧客户端在不带令牌跑),故做成开关, 把"切换时机"交给运维 —— 改环境变量重启即可,不必改代码重新评审:

开关 作用 打开后
REQUIRE_AUTH=1 强制登录 业务服务与网关都拒收无令牌请求(明确 401,不是降级放行);同时关闭自助注册(要开另设 ALLOW_OPEN_REGISTER=1)
ENFORCE_DEVICE_QUOTA=1 档位配额 注册/认领设备超过档位上限(免费 3 / 专业 10 / 团队 30)→ 403 并说明怎么处置
REQUIRE_DEVICE_TOKEN=1 采集端必须带设备令牌 无令牌的 agent:hello 直接拒绝

两个服务的 /health 都会报 auth_mode: compat|required,运维一眼可辨当前模式。

设备令牌:堵住 device_id 冒充(这一步的真正价值)

账号一出现,device_id 就会出现在别人的设备列表里(我的设备页能看到自己的 id)。 而 agent:hello 只要报个 id 就能注册为那台设备 —— 也就是说, 任何人拿你的 device_id 发几帧 hello+metrics,就能往你的账号名下灌假数据、污染历史。

故引入设备令牌(方案 6.4 本来就要求"采集端独立设备令牌,可吊销与轮换"):

  • 签发:POST /api/devices/:id/token,只允许设备归属者本人签发 (否则拿别人 device_id 就能领令牌,机制形同虚设)
  • 校验:agent:hello 带 device_token → 必须存在、未吊销、且令牌绑定的 device_id 与本次声明一致;不一致直接拒绝
  • 轮换:再次签发会吊销该设备全部旧令牌,旧采集端随即被识别为失效
  • 客户端:认领成功后自动签发并注入采集端(立即重连生效),令牌存配置文件的 device_token

实测(auth-mode-test.js 就地拉起严格模式实例):用本机令牌冒名其它 device_id 被拒;轮换后旧令牌失效、新令牌可用。

但这把锁把钥匙也锁在了门里(全新设备死锁,2026-09-15 首装暴露)

开启 REQUIRE_DEVICE_TOKEN=1 后,全新设备永远进不来 —— 三件事互为前提:

环节 依赖
POST /api/devices/claim 要求 devices 表里已有该设备
设备行入库 只发生在网关 onHello → registerDevice
onHello 无令牌一律拒,且在入库之前就 return
设备令牌 只能由已归属设备签发(/api/devices/:id/token)

真机上表现为:点「认领到当前账号」得到「服务端没有这台设备,请确认设备已开始采集」, 而设备明明正在采集(devices 表里 0 行,users 表里有账号)。

修法:认领即注册(claim 时设备不在库就登记,owner = 当前登录用户)。 这比"网关放行未认领 hello"更安全 —— 建设备行的权限从"任何网络客户端"收紧到"已登录用户"; 归属冲突仍由 claimDevice 的 SQL 兜底,且 registerDevice 的 upsert 不碰 owner_id。

为什么没被测出来:account-test.js 里原本写着「认领不存在的设备 → 404」—— 测试不但没拦,还把这个死锁钉成了期望行为(因为本地一直是兼容模式、设备早就注册过)。 现该断言改为"认领全新设备成功",并在 auth-mode-test.js 新增 [E] 段在严格模式下 跑完整链路(未认领被拒 → 认领即注册 → 签发令牌 → 带令牌 hello 被接受)。

更严重的一处:令牌只守了握手,指标通道完全裸奔(同日修复)

上线后发现一台不在设备表、没有令牌的安卓机仍在写帧。受控验证证实: 不 hello、不带任何令牌,只发 agent:metrics 就能写任意 device_id 的时序 (进环形缓冲 → 推给正在观看的人)、把它标成在线、并落 30 天历史。 根因是校验只写在 onHello,而 onMetrics 直接用 msg.device_id。

现由 _agentUplinkGuard() 统一守卫 metrics/heartbeat:必须是已 hello 的采集会话、 开关开启时须在 hello 验过令牌、上报的 device_id 必须与会话一致 (身份以会话为准,不读消息字段——那是攻击者可任意填的)。 回归见 auth-mode-test.js [F] 段(含"合法采集端仍正常",防修过头)。

教训:任何"失败关闭"的开关都要追问一句 "它覆盖了该身份能做的全部操作吗?" —— 只守入口而放行后续动作等于没守。

残留风险:已登录者仍可抢注"别人机器的 device_id"(该 id 可读可猜)。 彻底解法是让认领具备"我确实在这台机器旁"的证明(设备端生成一次性码,与配对码同思路)。

还没做

  • 自助注册在强制登录下默认关闭;正式发行还需要"邀请制/管理员建号"的界面(目前是 REST)。
  • 第三方登录(Google/GitHub)。
  • 配额升级路径(现在超额只提示,没有自助升档)。
  • 设备令牌的吊销界面(REST 已有轮换,界面只有"认领/签发",没有"吊销这台机器"按钮)。

用户机制:角色 / 邀请 / 自救 / 配额(第 25 轮落地)

上一轮的账号体系只做到"你是谁"(注册登录、设备归属)。这一轮补的是**"你能做什么"** 与**"账号出事怎么办":在此之前,一个账号只有"能看"和"不能看"两态,忘了口令等于 账号报废,设备权限只能发配对码(拍照发过去 = 公开招租),配额只用来报数**不生效。

能力矩阵只有一个真相源

shared/permissions.js 定义 13 个能力 × 3 个角色(viewer / manager / owner), Dart 侧 client/lib/host/role_catalog.dart 是它的镜像 —— 两边双向相等由测试锁定 (schema-consistency-test.js 第 14 段逐条比对能力名、最低角色、ownerOnly 集合)。 这一条不是洁癖:界面按自己的表隐藏按钮、服务端按自己的表判权,两表一旦漂移, 用户看到的就是"按钮点了没反应"或"根本没按钮但接口允许"。

授权 ≠ 夺权(ownerOnly)

把某人提为 owner 角色,意味着他能管成员、轮换令牌、看审计 —— 但不能转移归属、不能删除设备。这两件事只认 devices.owner_id 本人, 因为"被授予的权力"不该包含"把授予者踢出去"。实现上不是特判,而是能力表里的 ownerOnly: true 标记,由 authorize() 统一裁决;测试里对 3 个角色 × 2 个能力 逐一断言(含"被授予 owner 的成员仍被拒"这条反直觉组合)。

账号自救四件事

场景 做法 关键取舍
怀疑令牌泄露 列出全部登录记录,按 12 位前缀踢掉某一条 / 一键踢其它 列表只给哈希前缀,绝不回显可用凭据
想改口令 验原口令 → 改 → 吊销其它全部会话 改密即清场:旧令牌不该继续有效
忘了口令 邮件一次性令牌,15 分钟、用一次即焚 见下"防枚举"
不要这个号了 注销 → 30 天冷静期内登录即撤销 期内设备变无主但数据保留,界面如实说明

一次性凭据(验证邮箱 / 重置口令 / 邀请)统管在 auth_tokens 一张表里, 只存 sha256 哈希,消费用 UPDATE ... WHERE used_at IS NULL RETURNING 保证原子 (并发两次只会有一个成功)。重放、跨账号使用、换人代接受 —— 全部明确拒绝。

防账号枚举

"这个邮箱注册过吗"不该被一个陌生人问出来。故 reset/request 对存在与 不存在的邮箱返回完全相同的键与文案(测试逐键比对,不是比状态码), 只有 has-more 信息的那一侧才真的发信。

令牌绝不进 HTTP 响应

验证/重置/邀请的令牌只走邮件通道。响应体里连前缀都不许出现 —— 测试直接断言响应 JSON 里匹配不到 pm_rst_ / pm_vfy_ / pm_inv_。 这不是洁癖:一旦回显,任何能看到响应的地方(代理日志、前端 console、崩溃上报) 都成了凭据泄露点。

发信通道三模式:outbox(默认,落 JSONL 供本地调试)、console、smtp (未实现 —— 选了它只会在响应里如实回报 delivered:false,绝不假装发出去了)。

配额:402 而不是 403

额度用尽不是"没权限",是"该付钱/该删设备",故用手册里少见的 402 Payment Required, body 带 used / quota / plan 三个字段让客户端能直接渲染进度。免费档在邮箱未验证时 收紧到 1 台 —— 验证本身是零成本的,这比"注册就送 3 台"更能挡住批量刷号。

2026-10-30 起 ENFORCE_DEVICE_QUOTA 默认开启(=0 可关)。这是本轮唯一的 破坏性变更:之前它关着,于是 PLAN_TABLE 那行数字只被用来报数,免费号能挂无限设备 —— 那不是"宽松",是机制不存在。只有"新增"(注册/认领)会被拦,已有设备的查看不受影响。

这个默认值一改,三套测试当场变红(account-test / device-merge-test / auth-mode-test), 失败信息清一色是 E_QUOTA_EXCEEDED —— 看起来像配额功能坏了,其实是测试的车没上: 它们造数的方式是"注册 → 认领 2~8 台设备",而未验证邮箱只能绑 1 台这条规则比档位上限更早生效。 按"谁能表达意图"分工处置,不是把默认值改回去:

套件 处置 为什么
账号体系 / 设备归并 造数前 markEmailVerified()(归并另抬到 team 档) 它们测的是归属与归并,配额只会把断言挡在更前面 —— 本该 409 的"认领别人的设备"先返回 402
安全开关 [D] 段 对照组从宿主机 :4000 换成自己拉的 :4012(ENFORCE_DEVICE_QUOTA=0) 原写法隐含":4000 一定没开配额",等于把别人的部署方式写进测试前提

教训是一条通用的:改一个默认值 = 改所有测试的前提。凡是测试里出现"另一套服务应该是什么样", 都要先问它归谁管 —— 不归测试管的,就得自己拉一个。

端到端证据(user-lifecycle-test.js,66 项)

九段全绿,其中一半是"No"型断言(必须被拒):viewer 邀请 → 403、manager 踢人 → 403、 被授予 owner 者转移归属 → 403、邀请令牌重放 → 401、代别人接受邀请 → 403、 注销后原会话 → 401、第 4 台设备 → 402。反面对照同样有:合法路径必须能过, 否则"全拒"也能让测试全绿。

这套测试自带隔离实例(随机空闲端口 + 自己的 outbox 路径 + 放宽的限流额度,跑完即停)。 原因是踩了三个坑,根因都是"被测服务的环境不归测试管": ① 写死 :4010 撞上上次遗留的旧进程 → 测的是改动前的代码,36 项假红; ② outbox 路径测试用 os.tmpdir()、服务端用 /tmp/... → 永不相等,整段假红; ③ 注册限流(每小时 20 次/IP)被反复重跑耗光 → 429 假红。 三者都不是产品缺陷,却各要花一轮排查。让测试掌握环境,这类"环境冒充缺陷"一次消掉。

还没做

  • SMTP 真的发信:现在只有 outbox/console,生产要么接 SES 要么自建; 在此之前"发一封验证邮件"只是写了个文件,不该对外宣称已支持。
  • 邀请的接受界面:REST 已有(POST /api/invites/accept),客户端只有粘贴令牌的入口。
  • 注销 30 天后的定时清理:purgeExpiredDeletedAccounts() 已实现但没有 cron 调它, 现在靠"期满后首次登录"顺手清理。
  • 第三方登录、配额自助升档、审计日志的导出。

云端管理端:运营者视角的界面(第 26 轮落地)

在此之前,这个系统没有任何运营端。38 个业务端点全部是用户视角的 —— 每一个都带 WHERE owner_id = 我 / WHERE grantee_id = 我。要看"全站有多少账号" 靠 psql,要改某人档位靠手写 UPDATE,要看谁在线靠 docker logs。 这不是"少个页面",而是运营动作没有任何留痕与边界。

一个必须先说清的前提:这里没有"用户上传的文件"

uploadToken / downloadToken 是中继传输的一次性凭证(设备 A 经 /relay/ 把帧数据传给设备 B,服务端不落盘),不是文件托管。库里 9 张表没有任何一张跟文件有关。 所以管理端没有"文件管理"这一块 —— 真正要管的是账号、设备与授权、配额、审计与 指标数据,其中 device_metrics 是唯一会无限长大的表。

身份模型:一个部署一个口令,而不是 users.is_admin

没有新增管理员角色列,理由有三条,每条都是"另一种做法会坏在哪":

做法 坏在哪
users.is_admin 列 谁来设第一个管理员?新库上要么手改 SQL,要么留一个"首次访问即成为管理员"的接口 —— 后者是经典的接管漏洞
管理员就是一个普通账号 该账号丢了 = 管理端丢了,而且只能靠改库救回
独立管理员表 自托管场景下运营者就一个人,建角色体系属于过度设计

故用部署时注入的 ADMIN_TOKEN(openssl rand -base64 48),泄露即换变量重启, 零迁移。ADMIN_NAME 只作为审计里的操作者标签存在,不参与任何鉴权。

四条失败关闭(每一条都有对应断言)

形态 响应 为什么不能是别的
没设 ADMIN_TOKEN 404 401 等于对全世界宣布"这里有个管理台,只是你没带口令"
设了但短于 32 字符 404 与其给人"已经设过了"的错觉,不如当它不存在。ADMIN_TOKEN=123456 的管理端比没有更危险
口令错 401,不回显任何片段 回显前缀等于把猜测空间从 2^256 缩到 2^48
口令放 ?token= 401 query 会进 nginx access log、浏览器历史、Referer
来源 IP 不在 ADMIN_ALLOW_IPS 404 连"口令错了"都不说

口令比较走定长摘要 + timingSafeEqual(先各自 sha256 成 32 字节再比), 否则按响应时间能逐字节试出来。失败次数有上限(默认 10 次/分钟/IP,超出 429), 且锁定期间正确口令同样被挡 —— 安全优先于便利,管理端本就是单人内网工具。

五个页面

页面 内容
总览 账号/设备/在线数、近 14 天新增趋势、各表存储占用
账号 搜索(邮箱/显示名)、按档位与状态筛选、排序、分页;详情抽屉含自有设备 / 被授权设备 / 登录记录 / 订阅 / 近期审计
设备 搜索、在线态、授权数与令牌数;详情含授权成员、采集端令牌(只给哈希前 12 位)、指标行数与时间范围
审计 按事件(支持前缀,如 admin_ 一次看全部管理动作)/账号/设备筛选、分页
指标数据 任意设备 × 任意区间 × 任意指标列,指定降采样粒度,画折线 + 看原始桶

写动作只有五个:改档位、封禁/解封、人工标记邮箱已验证、踢全部会话、解除设备归属。 每一个都落审计(admin_*),审计写不进就报错 —— 管理端动了别人的账号, 不能"做了但没留痕"。CSV 导出同样记审计:它带走的是全站邮箱。

三个在这一轮挖出来的真缺陷

① GET /api/audit 匿名可读全站审计(已删除)

它没有挂任何鉴权中间件,实测匿名请求即 HTTP 200,直接返回 account_id / device_id / detail。一个运营者接口被放在了用户接口的路由表里, 而且全仓库 grep 只有它自己在用。已改为 /api/admin/audit(带口令 + 分页筛选), 原路径现在 404 —— 不许为了"兼容"加回来,审计里能看出谁在什么时候连了哪台设备。

② 存储统计报错 99.9%(已修)

storage() 原用 pg_total_relation_size('device_metrics'),在该库上报出 32 KB,而实际数据是 2802 MB —— 因为 TimescaleDB 把数据放在 chunk 里, 父表本身几乎是空的。这个页面存在的唯一意义就是回答"空间去哪了", 报错方向比报错数字更糟。现先探 to_regclass('timescaledb_information.chunks'), 有扩展就把所有 chunk 累加(两套 SQL 分开写:那张表在普通 PG 上解析阶段就报错, 用 CASE 包起来没用)。超表的 reltuples 也不可信(2802 MB 报 0 行), 故补一次精确 count(*) —— 但它是唯一会无限长大的表,加 3 秒上限, 超时退回"未统计",宁可显示得少也不拖住页面。

③ COPY server/business/*.js 不含子目录(已修 + 加护栏)

管理台静态页在 server/business/admin/ 子目录里,globs 的 * 不跨目录。 漏了这行的表现是一组"看起来都正常"的组合:镜像构建成功、容器健康、 /api/admin/* 全通,只有 /admin/ 一片 404。而原有的 Dockerfile 覆盖性测试抓不到它 —— 它按 require 图检查,静态文件不被任何模块 require,在那张图里是零信号。现该测试新增一段「静态资源目录必须被 COPY」, 并修正了 expandGlob 对目录的处理(原先把目录名本身放进集合, 新加的那段会永远为真,变成假绿)。变异自证:删掉那行 COPY → 7 个文件全部点名报出。

渲染期才看得见的两处

前三个是接口层的,测试能抓;下面两个只有真在浏览器里打开才会露出来 —— 语法检查、node --check、接口断言全部通过,页面上照样是错的。

④ 总览「当前在线」用采样算法(已修)

原实现取 SELECT device_id FROM devices LIMIT 500 再逐台 EXISTS。这个库里有 1.3 万台设备(大量是压测留下的),LIMIT 500 拿到的是物理顺序最前的一批, 真正连着的那台很可能不在里面 —— 页面于是显示「当前在线 0」。危险的不是数字错, 而是它会被读成确定结论:运营者会去排查"为什么全掉线了"。旁边那行小字 「采样 500 台」在视觉上等于不存在。 现改为 SCAN 数 presence:* 键(网关连上 SET、断开 DEL),与设备表规模无关, 也不必逐台往返;用 SCAN 而非 KEYS 是因为网关转发链路依赖同一个 Redis 实例, 管理台刷一下总览不该让所有人掉帧。Redis 不可用时返回 null 并显示「未知」, 绝不回落成 0。

⑤ 审计「明细」列被卡片从右边硬裁(已修)

明细是 JSON.stringify(detail),一串没有空格的 JSON。white-space: normal 只在空格/连字符处断行,找不到断点 → 整串作为不可断词把表格撑宽 → 再被 .card { overflow: hidden } 从右边缘切掉,既没有省略号、也看不出被切了 (截图里 {"ip":"::ffff:172.19.0.1","reason":"not_found","attempts_left":4,"b 就断在卡片边缘)。修法是包一层带 max-width 的 div 做省略号 + title 悬停看全文: table-layout: auto 下给 td 设 max-width 并不可靠,包 div 才稳。

方法上的一条:四页分别截图,不能"总览正常"就推断其余三页正常 —— 它们是四条独立的渲染路径(pageAccounts / pageDevices / pageAudit / pageMetrics),出错也只会在自己那页出错。 另:为验证登录后的页面,曾临时加过一个同源跳板页(写 sessionStorage 再跳转, 因为无头浏览器每次都是新进程)。这一类页面用完必须删干净 —— 它把口令硬编码在页面里,留在镜像里就是一个不需要口令的登录跳板。

部署

静态页由业务服务 express.static 提供(不是 nginx 直接托管), 这样单容器与多容器两套部署的路径完全一致,不必把 admin 目录分别 COPY 进两种 nginx 镜像。nginx 只需一条路由:

location ^~ /admin/ { proxy_pass http://pmon_business; ... }

⚠️ 单容器那套(nginx-all.conf)必须显式写这条:它的 location / 会把一切 兜进网关,漏了的表现是"管理台 404,/api/admin/* 也 404",排查时极易误判成 "管理端没挂载"。

⚠️ 改完 nginx.conf 记得 docker exec pmon-nginx-1 nginx -s reload。 文件是挂载进去的,但运行中的 nginx 不会自己读 —— 实测时 up -d business 只重启了业务服务,/admin/ 一直 404,reload 后立刻变 200。这一条在容器日志里 没有任何提示,只能靠"改了配置怎么没生效"这个念头想到。

如何不把它暴露到公网(这一条是运营者的责任,代码只管路由):

  1. 安全组只放行办公网 / 家庭固定 IP,或干脆只允许 WireGuard 内网访问;
  2. 或者设 ADMIN_ALLOW_IPS=(完整 IP,不支持网段),非白名单一律 404;
  3. 两样都不做时,管理台的口令就是唯一一道门 —— 那就把 ADMIN_TOKEN 当生产密钥对待。

端到端证据(admin-api-test.js,80 项)

自带 9 个隔离实例(每个"口令形态"独占一个进程,口令是启动期环境变量, 共用一个进程测不出来)。一半是"No"型断言:没设口令 404、短口令 404、 错口令 401、口令走 query 401、IP 不在白名单 404、非法档位 400、 非法指标列 400、区间反了 400、未知导出类型 404、连错 5 次后 429。

另一类是"不许漏出来":任何响应都不得出现 secret_hash / secret_salt / 完整 token_hash(会话只给 12 位前缀);CSV 首三字节必须是 EF BB BF; 用户可控字段以 = 开头时必须加前导单引号 —— 否则 Excel / Numbers 打开导出文件 会执行这个公式。

这一条首跑时是红的,原因是我用 fetch().text() 读响应体 —— 而 WHATWG 规范 规定 UTF-8 解码默认剥掉 BOM,于是"文件里有没有 BOM"用它根本测不出来。 改用 arrayBuffer() 比前三字节才通过。又一次「量不出数据先怀疑量法本身」。


显示层:单位自适应与历史回溯(第 23 轮落地,含两处真机才暴露的缺陷)

用户真机反馈三条:网速单位切换"效果还行,但内存和磁盘没做到自适应"、"采集速度看起来太快、 做成可调"、"折线图的开始/结束时间不可调节"。前两条与第三条的性质不同 —— 第三条不是没做界面,而是数据层根本没有出口:

需求 数据层真相 落法
折线起止时间可调 实时层是定长 60 帧 Redis 环形缓冲(方案 10.2 有界优先),
窗口长度由架构决定;长时段数据一直在 device_metrics(30 天保留)但从未有查询接口 方案 10.3 时序查询:GET /api/devices/:id/history
采集速度可调 采样间隔原为服务端下发、客户端照抄 客户端 0.5/1/2/5 秒可选,人工优先(samplingIntervalManual,重连不被下发打回)
内存/磁盘单位自适应 界面硬写 MB/GB,出现 84281 MB、7992.7 GB、225024 s 1000 进制量级换算 + 滞回 + 时长人话化

历史档位(白名单,粒度由档位表推出而非客户端指定;点数上限 400):

档位 桶宽 说明
1h 60 s 与实时窗口同粒度,便于对照
24h 600 s
7d 3600 s
30d 10800 s 与 30 天保留期对齐

非法档位 400 E_BAD_WINDOW(含可选清单);聚合用 floor(epoch/bucket) 而非 TimescaleDB 的 time_bucket(普通 PG 也能跑,符合"扩展可缺省"约定);缺桶即断点(标 gapBefore 让折线断开, 不插值 —— 与实时窗口同一条纪律)。指标与 SQL 列名双白名单:列名无法参数化, 故必须映射自 METRICS_SCHEMA 的列定义,值全部参数化。

缺陷 ①:单位换算被写了三份,各缺一块(第 6 次同类复发)

真机上采集页显示 磁盘可用 239 GB(正确),查看页显示 84710 MB / 131072 MB / 3112.0 GB(原始值直出)。不是漏写换算,而是同一个逻辑写三份:

位置 速率(adaptiveRate) 量级(scaleBase) 时长(duration)
main.dart 采集页 ✅ ✅ ✅
viewer_page.dart 指标卡片 ✅ ❌ ❌
viewer_page.dart 坐标轴 ❌ ✅ ✅

三份各配自己的测试 → 三份测试全绿,用户在两个页面看到同一指标的不同单位。 这与指标字段清单(Rust/协议/表列/仓储)、配对码契约、客户端指标目录是同一个病: 多层共享的判定没有单一真相源(本仓库第 6 次)。

修法:唯一出口 client/lib/host/metric_format.dart(MetricFormatter), 三个调用点全部改调它;纯 Dart 的 adaptive_unit.dart 从 ui/ 移到 host/ 与 scaled_unit.dart 同层。两处语义也在同一轮修对:

  • 卡片读平滑值、轴与 tooltip 读真实值。速率类为了不被毛刺带着翻单位而做了 EMA 平滑, 但拿平滑值去渲染"某一时刻的采样"就是编造数据;两条路径共用同一套选档阈值, 故轴上的单位与卡片不会互相矛盾。
  • 轴内单位必须统一:formatStable 要传 axisRef(该轴最大绝对值), 否则轴上会混排 0 MB 与 3.11 TB(这两个数差 10⁶,并排看着像两个量)。
  • 速率为 0 时显示 0 KB 而非 0.00 TB 式的噪声(零单独处理)。

护栏:client/test/metric_format_test.dart 第五段是源码结构断言 —— 剥掉注释后扫描两个页面,出现 ScaledUnit( / AdaptiveRateUnit( / scaleBase / adaptiveRate / formatDuration( 即判红。(剥注释是刻意的:本项目要求把事故写进注释, 护栏若把注释也算进来,等于逼后来人删掉解释。)

缺陷 ②:折线标题与请求的指标不是同一个

真机切到「1 小时」档,标题写 CPU 使用率 · 1 小时,界面却弹 「指标 uptime_s 没有独立时序列,暂不支持回溯」。根因是默认指标被解析了两次: 图表用 _orderKeys(...).first(按显示目录排序 → cpu_usage),历史查询用 _st.availableKeys.first(能力集原始顺序 → uptime_s)。而且默认落在了 chartable: false 的 uptime_s 上(数组型与单调量画折线无意义,device_metrics 里也没有这两列)。

修法:ViewerPage._chartKey 作为图表 / 历史查询 / 卡片高亮共用的唯一解析, 默认跳过不可画的指标取第一个 chartable: true;卡片高亮也改为按生效指标 (否则会出现"图上画着 CPU,却没有任何卡片是高亮的")。

实测证据(F50 / Android 13 / arm64)

已用内存 84.7 GB(原 84710 MB)    内存总量 131 GB(原 131072 MB)
磁盘可用 3.11 TB(原 3112.0 GB)   磁盘总量 8.00 TB
运行时长 2 天 14 小时(原 225024 s) 下行 365 kb/s / 上行 50 kb/s(速率档位没被内存的 GB 带跑)
折线卡:CPU 使用率 · 最近 60 秒 ▾ / Y 轴 68.0% 66.0% 64.0%(与卡片同源)
切「1 小时」→ CPU 使用率 · 1 小时 / 来源:数据库降采样 · 每 60 秒取平均 · 覆盖 08:04:00 → 09:04:00

镜像构建:点名式 COPY 的两起事故(已修复并锁定)

事故 后果 为何本地测不出来
新增 shared/metrics-schema.js 后未加进 COPY 列表 镜像构建成功,容器一启动就 Cannot find module 本地 node 直接读源码,不经过 COPY
server/shared/keepalive.js 删除后 Dockerfile 仍 COPY 它 COPY 不存在的文件是硬错误,构建直接失败 同上

根因与指标字段分裂同源:Dockerfile 用逐个点名的方式拷贝源码,等于又多出一份 必须人工与代码同步的文件清单。

修法:两个 Dockerfile 改为通配拷贝(COPY shared/*.js ./shared/ 等), 并新增 tests/dockerfile-coverage-test.js(19 项):从入口模块出发递归解析 require() 图, 断言镜像里真正需要的每个文件都被 COPY 规则覆盖;反向断言每条 COPY 都指向真实文件; 再校验 .dockerignore 不会误排除必需文件。同时补上原本缺失的 .dockerignore (此前构建上下文会把 node_modules、rust-core/target、.git 全量上传给 daemon)。

验证:已实际 docker compose build 两个镜像并在容器内 require() 成功, 容器内读到白名单 17 字段、仓储 17 列 —— 不是靠推断。


方案章节 落地位置
3.2 / 3.4 职责下沉 rust-core/src/(采集 collector / 传输 transport / 保活 keepalive)
第 5 章 通信协议 shared/protocol.js + rust-core/src/transport.rs(信封与版本校验双端同源)
第 7 章 指标可得性 shared/metrics-schema.js(权威清单)、Collector::capabilities()
第 8 章 保活机制 rust-core/src/keepalive.rs(三层模型 + 守护 + 五端自启产物)
第 9 章 安全设计 白名单过滤、WSS 强制、FFI 边界约束(ffi-smoke.py 逐项验空指针)、参数化 SQL
第 10 章 数据存储 deploy/init.sql(含增量迁移段)、server/shared/repository.js
第 11 章 部署运维 deploy/(含 ADR-001 多阶段构建)
第 12 章 资源与内存安全 store.js 有界容器、buffer.rs 环形缓冲、transport.rs 补传窗口、leak-check.js
第 13 章 连接可靠性 transport.rs(心跳 / 半开检测 / 退避+抖动 / 网络恢复主动重连 / 补传窗口)+ 服务端去重与优雅关闭

多端客户端的测试环境选型(以 Apple Silicon 主机为例)

方案要求五端一致,但主机是 arm64 时"能不能在本机验证另一端"要按架构算。以下结论依据 Parallels Desktop 26 官方系统要求与限制说明(KB 124223 / 129497 / 128914)实测口径整理:

目标端 本机可行性 说明与陷阱
macOS ✅ 原生 + VM 原生直跑即真机;另可在 Parallels 里跑 macOS 12–26 客户机,用于验证干净环境下的自启与保活(LaunchAgent 注册)
Linux arm64 ✅ Parallels Ubuntu/Fedora/Debian/RHEL/Kali 等 arm 版均支持
Windows arm64 ✅ Parallels Windows 11 Pro/Enterprise on Arm;x86/x64 应用靠 Prism 转译运行
Linux x86_64 ⚠️ 仅 PD26 预览 x86 模拟 属预览特性,且采集到的性能数据失真
Windows x86_64 ⚠️ 仅 Prism 转译 x64 二进制可跑,但指标不可信
iOS ❌ 需 Xcode + Simulator(macOS 原生),Parallels 帮不上
Android ❌ 在 VM 内 Android 模拟器需嵌套虚拟化

一条硬限制:Apple Silicon 上的 Parallels 不支持嵌套虚拟化(官方明确列为不可用项)。 由此推出三条实操结论:

  1. VM 里跑不了 Docker / WSL2 / Hyper-V(WSL1 可作降级)—— 别指望在 Windows VM 里 再起一套容器化环境做联调。
  2. VM 里也跑不了 Android 模拟器 —— Android 端请在 macOS 宿主机上用 Android Studio(arm64 系统镜像)或真机验证。
  3. 性能类指标必须区分"功能验证"与"数据验证": 在本产品里这尤其致命 —— 跑在 Prism / 预览 x86 模拟上的客户端, 采到的 CPU/内存数据是模拟层与宿主混合后的结果,与真实 Windows 机器无对应关系。 用 VM 验证"能装、能连、能保活、能补传"(流程正确性), 但要出可对外承诺的性能数据,仍需一台真机/真云主机。

跨平台产物构建:macOS 上交叉编译需显式装 target;Linux/Windows 目标建议用 cross(cargo 子命令,借 Docker 提供链接器),避免在本机堆多套工具链:

# Linux arm64(与本机架构一致,最省事)
rustup target add aarch64-unknown-linux-gnu

# Windows arm64:需 LLVM 链接器。用 gnullvm 目标可从 macOS 交叉编译,
# msvc 目标则必须在 Windows 上构建(需要 link.exe / Windows SDK)
rustup target add aarch64-pc-windows-gnullvm

⚠️ 客户端与服务端之间的链路是 Socket.io(engine.io)。Rust 侧的 socket.io 客户端生态 很弱,这是"客户端宿主该用什么语言"的决定性约束 —— Dart(socket_io_client)或 Node 才是顺路选择,纯 Rust 宿主需要自研 engine.io 客户端,不建议。


客户端(设备端)

client/ 是 Flutter 单代码库,覆盖 macOS 与 Android,同时提供采集端与查看端两个角色 (方案 3.1:任一设备上的应用既可上报本机数据,也可订阅查看已授权的其他设备)。 采集/传输/保活/重连策略全部复用 Rust 核心,客户端只做宿主该做的三件事: socket I/O、平台差异落地、界面。

组成 说明
bin/pmon_agent.dart 无界面常驻采集进程(方案 8.3「采集核心作为独立进程」),可编成小可执行文件(构建命令见 client/scripts/build_cli.sh)
bin/pmon_viewer.dart ★ 无界面查看端 CLI:连真实网关、走真实配对、订阅并打印快照/实时帧/断点/能力集,退出码反映结果
lib/host/link.dart ★ 宿主 socket 适配层:真 socket 驱动 Rust 状态机(喂事件、执行动作)
lib/host/viewer.dart ★ 查看端宿主层:独立 socket + 配对 REST + 60 帧窗口 + 断点判定 + 能力集 + 离线明示(纯 Dart)
lib/host/metric_display.dart 指标显示目录(标签/单位/分组/是否可画/不可得原因,纯 Dart)
lib/ui/viewer_page.dart 查看端界面:设备切换器 / 仪表盘 / 折线 / 配对交互 / 空状态
lib/ui/metric_catalog.dart 显示目录的图标映射(唯一 Flutter 强耦合部分)+ 转出纯层目录
lib/ui/pairing_card.dart 采集页「谁能看我」卡:生成配对码 / 已授权账号 / 随时解除(方案 6.3 的采集端两步)
lib/host/pairing_api.dart 配对与授权 REST 客户端(采集端与查看端共用,含错误码翻译)
lib/host/account_api.dart 账号 REST 客户端(注册/登录/登出/我的设备/认领)
lib/ui/account_card.dart 账号卡(登录/注册/登出;未登录时明说只能看他人授权的设备)
lib/host/link.dart 采集端 agent:hello 携带设备令牌(认领后自动注入并重连)
lib/main.dart 双角色外壳:底部导航「采集 / 查看」;查看页懒创建(切页签不中断采集)
lib/host/ffi_bindings.dart C ABI 绑定(37 个;另有 pmon_next_backoff_ms / pmon_backoff_reset 两个仅供 Rust 自测使用,客户端未绑定)
test/viewer_test.dart 查看端逻辑 48 项(断点 / 能力集 / 错误码文案 / 快照 / 实时流 / 配对,全部离线,注入假 socket)
test/viewer_page_test.dart 查看端界面渲染 17 项(空状态 / 设备切换器 / 卡片取舍 / 断点提示 / 离线横幅 / 配对错误,真实构建 widget 树)
test/viewer_fakes.dart 两套查看端测试共用的假件(假 socket / 假策略 / 假 REST)
test/pairing_card_test.dart 采集页「谁能看我」卡 6 项(发码 / 失败不静默 / 清单 / 解除)
test/account_test.dart 账号 21 项(REST 客户端 + 账号卡界面,未登录/失效/失败文案都有断言)
test/host_ffi_test.dart 21 项 FFI 冒烟,真实加载编译产物

双角色为什么是两条连接(改动前必读)

网关的 session.role 是单值(onHello 置 agent、onSubscribe 置 viewer),断开清理 按 role 分支:role === 'agent' 才清在线态、role === 'viewer' 才清订阅关系。 若在采集连接上再发 viewer:subscribe,role 会被改写成 viewer → 断开时在线态永远清不掉, 该设备将永久显示"在线"(幽灵设备)。故查看端独占一条连接,采集端行为完全不受影响。

同理,查看端刻意不发 agent:heartbeat:那会经网关 touchPresence 把"只想看别人" 的本机标成在线设备,制造一台状态灯绿、却没有任何指标的幽灵设备。查看端连接的保活由 engine.io 自身的 ping/pong 承担。该决策已由端到端测试钉住 (断言 presence:viewer-cli 不存在)。

协议没有 unsubscribe(所以切换设备要重连)

表 5.2 的上行事件只有 viewer:subscribe,网关按 session.subscribed 累积订阅、 仅在断开时清理。若切设备时只再发一次 subscribe,旧设备的帧会一直推过来、 subs:{旧设备} 里也永远留着本账号。查看端因此在切换设备时重建连接一次, 让服务端清理干净(代价一次 RTT)。

实测结果(本机):flutter test 100/100;查看端端到端 (真实采集端 + 真实配对 + 真实 Dart CLI)21/21 —— 快照 4 帧 → 恢复后断点=1、 能力集隐藏 2 项、撤销授权后列表即刻不可见。

详细环境要求、联调注意事项(必须清代理变量,否则 localhost 会走代理导致握手失败) 与 Android 打包见 client/README.md。


已知边界

  • Android 采集端已落地并实测联调:aarch64-linux-android 交叉编译、Kotlin 前台服务 与常驻通知(PmonCollectorService.kt)、平台通道(PmonChannel.kt,TrafficStats / ANDROID_ID / filesDir)、BOOT_COMPLETED 接收器(BootReceiver.kt)均已实现, 模拟器与真机均能连上网关上报(服务端可见 android-* 设备在线)。 仍待补:正式签名与 AAB 产物、各厂商 ROM 的自启引导文案核对、Android 侧查看端 UI 验收。
  • 查看端已落地(方案 6.2 MVP 项:设备切换器 / 快照首屏 / 实时流 / 服务器时间轴 / 能力集驱动隐藏 / 离线明示 / 断点不插值 + 配对交互)。两端 UI 共用一份指标显示目录, 一致性由 tests/schema-consistency-test.js 第 11 段跨语言锁定(Dart ↔ JS 权威清单)。 采集端侧同样补齐(ui/pairing_card.dart:生成并展示配对码 / 已授权账号清单 / 随时解除), 至此方案 6.3 的四个环节(设备发码 → 查看端输码 → 建授权 → 设备解除)在 GUI 里全部可走通。 仍待补:查看端的历史回溯(方案 10.3 的时序查询接口尚未提供 REST/下行事件)。
  • TimescaleDB 分支已验证:用真实 timescale/timescaledb:2.17.2-pg16 镜像跑通, 扩展 2.17.2、超表、30 天保留策略、连续聚合(12 列)+ 30 分钟刷新策略全部建成。 国内拉取该镜像(2.74GB)实测极慢(~46 KB/s),本地开发建议用 POSTGRES_IMAGE=postgres:15-alpine(init.sql 已做成扩展可缺省)。
  • 传输状态机已接入客户端:client/lib/host/link.dart 已把真 socket 喂给 transport.rs(退避/心跳/半开/补传不再由 JS 或 Dart 实现)。服务端测试套件 仍走 Socket.io 直连,未经过 Rust 状态机。
  • 保活产物已生成,但落盘/执行未接入:plan_autostart() 产出的 plist / unit / reg 命令是真实内容,但"写文件 + 执行命令"这一步要由 Dart 侧完成, 目前只能在演示程序与 FFI 测试里打印核对。
  • 未接入采集的指标共 7 项:cpu_temp / battery_temp(方案 7.2 的平台受限路径)、 gpu_usage / gpu_temp / gpu_mem_usage、disk_read_mbps / disk_write_mbps (需平台级 IO 计数器)。这 7 项在 capabilities() 中如实声明为不可得, 不按平台理论能力虚报;协议字段已占位以冻结契约。其中 5 项有独立列,落库为 NULL (真实帧全链路测试会断言"不得编造 0")。
  • 快照新增了设备元信息(capabilities / device_name / platform / meta_available): 方案 6.1 要求查看端按能力位隐藏卡片,但此前网关只下发 frames,查看端只能从"全 null" 反推能力(区分不了"平台永久不支持"与"本次没取到")。现由快照随附,缺失时客户端 自动退回反推路径并在界面如实标注推定来源。追加字段不改变既有语义(方案 5.4)。
  • 降采样连续聚合(device_metrics_10s)已建并补齐主力指标列,但实时刷新策略 需按实际写入量调参;视图定义变更后需重建(IF NOT EXISTS 不会更新既有定义)。
  • 配对码校验已完整落地(方案 6.3/9.4 全项:一次性、300 秒、设备绑定、 5 次作废、账号 10 次/小时、IP 30 次/小时、失败全量审计、存储故障失败关闭)。 仍待补的一项:授权成功时被查看设备本地弹窗提示并留痕(方案 9.4)—— 这需要业务服务经 Redis pub/sub 通知网关、网关再向采集端推一个下行事件, 会牵动 shared/protocol.js 的下行事件白名单与 Rust 侧常量(由一致性测试锁定), 故单独作为一步实施,不与安全修复混在一起。
  • 业务服务按来源 IP 限流依赖 trust proxy 配置:默认 TRUST_PROXY_HOPS=1 (对应 deploy/nginx.conf 的一层反代)。当前 docker-compose.yml 把 4000 端口 发布到所有网卡,绕过 nginx 直连业务服务时,客户端伪造的 X-Forwarded-For 会被当作真实来源 IP。生产环境应把 business 的端口映射改为 127.0.0.1:4000:4000(与 redis/postgres 一致),或设 TRUST_PROXY_HOPS=0。
  • 五端真实保活调用(Android 前台服务、Windows 托盘、macOS 菜单栏等 UI 层)未接入, 当前完成的是"契约 + 平台产物 + 守护策略"三件,UI 载体属客户端工程。

本文档为技术骨架说明,不构成投资建议或商业承诺。排期与容量需按实测校准。

S
Description
墨观 InkWatch —— 设备性能监控与历史回溯(V4.0)
Readme MIT
2.2 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 46.7%
Dart 42.2%
Rust 5%
Shell 2.3%
CSS 1.1%
Other 2.6%