Files
inkwatch/shared/protocol.js
T
PMon Dev b3db58df06 rename: 项目对外名定为「墨观 InkWatch」
<user> 2026-09-16 定名。本次只改**用户看得见的那一半**,
内部标识(包名 / FFI 符号 / applicationId / compose project name / 卷名 / 数据库名)
一律保持 `pmon` 不动 —— 理由与"若要彻底改名该怎么做"见 docs/rename-inkwatch.md。

已改:
  · 界面标题:采集页「墨观 采集端」、查看页「墨观 查看端」、MaterialApp.title「墨观」
  · 桌面图标名 android:label="墨观"(刻意短,避免被系统截断)
  · 前台服务通知「墨观 正在采集」
  · 日志前缀 [PMon] → [inkwatch](**刻意用 ASCII**:中文在 Dart AOT 里按 UTF-16LE
    存,拿 grep -F 核验恒为 0 —— 这个坑踩过,日志前缀别用中文)
  · pubspec description 换成正式描述(顺手清掉 Flutter 模板残留 "A new Flutter project.")
  · README / docs 抬头与标题;方案文档改名
    docs/pmon-v4-architecture.md → docs/inkwatch-v4-architecture.md(引用同步)
  · 命名约束落地:对外文案不得出现"监视/监听/追踪/窥"类词
    (方案 2.1:产品不提供任何绕过对方操作的隐蔽监控能力,这是过审与杀软信誉的前提)

刻意没改(改了会出事):
  · Dart 包名 pmon_client —— 全部 import 失效,纯风险无收益
  · applicationId com.pmon.pmon_client —— 已装机变成"另一个 App",无法覆盖升级、
    令牌与设备归属断链。越接近上架越不该改
  · Rust 39 个 _pmon_* FFI 符号 —— 要与 .so 严格配对
  · ★ docker compose 的 name: pmon —— **它是卷名锚点**(pmon_pg_data),
    改成 inkwatch 会让 compose 去找一套新空卷 = 生产清库。
    故在其上方加了防呆注释,防止后来者顺手改掉

新增 docs/rename-inkwatch.md:完整记录改了什么、没改什么、为什么,
以及"若要连内部标识一起改"的正确顺序与风险评估(结论:不建议)。

验证:客户端 282/282、dart analyze 干净、codec/protocol 离线契约通过。
2026-09-16 12:09:53 +08:00

268 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 墨观 协议规范 v1 —— 客户端与服务端的共同契约
*
* 设计约束(见 V4.0 方案第 5 章):
* 1. 所有消息携带版本号 v,服务端兼容 N-1 版本
* 2. payload 字段白名单制,禁止透传未声明字段
* 3. 上行 ts 由服务端接收时改写(服务器时间权威)
* 4. 高频指标帧默认 JSON,可通过 hello 协商 MessagePack
* 5. 客户端对未知字段一律忽略(向后兼容)
*/
'use strict';
const {
METRIC_WHITELIST,
METRIC_KEYS,
METRICS_SCHEMA,
bytesPerSecToMbps,
} = require('./metrics-schema');
/** 协议当前版本 */
const PROTOCOL_VERSION = 1;
/** 服务端支持兼容的最低版本(N-1 策略) */
const MIN_SUPPORTED_VERSION = 1;
/**
* 指标白名单不再在本文件硬编码 —— 改由 `shared/metrics-schema.js` 派生。
*
* 历史教训(已修复):本文件曾自持一份手写白名单,与 rust-core 采集字段、
* 时序表列名三处命名互不一致,交集仅 3 个字段,导致真实采集帧被
* `sanitizePayload()` 静默过滤掉 CPU/内存/磁盘/网络全部数值。
* 一致性现由 `tests/schema-consistency-test.js` 强制锁定。
*/
/** 上行事件名 */
const UPSTREAM_EVENTS = new Set([
'agent:hello',
'agent:metrics',
'agent:heartbeat',
'viewer:subscribe',
]);
/** 下行事件名 */
const DOWNSTREAM_EVENTS = new Set([
'server:config',
'server:snapshot',
'server:metrics',
'server:device_status',
// 心跳应答。服务端必须回一声:采集端连接的常态是"只上行、无下行",
// 若心跳只去不回,客户端的半开检测会把**完全健康**的连接判成"疑似离线"
// (因为能写不能证明对端活着 —— TCP 会把数据静默缓冲进死连接)。
'server:pong',
'error',
]);
/** 统一错误码 */
const ERROR_CODES = {
BAD_ENVELOPE: 'E_BAD_ENVELOPE',
BAD_VERSION: 'E_BAD_VERSION',
BAD_TYPE: 'E_BAD_TYPE',
UNAUTHORIZED: 'E_UNAUTHORIZED',
NOT_PAIRED: 'E_NOT_PAIRED',
RATE_LIMITED: 'E_RATE_LIMITED',
RATE_LIMIT_PAIRING: 'E_PAIRING_LOCKED',
INTERNAL: 'E_INTERNAL',
/**
* MessagePack 量化路径指纹未知(服务端重启导致指纹表清空)。
* 客户端应带完整路径清单重发一帧,或本轮退回 JSON —— 不要静默丢弃整帧。
* 仅下行(服务端 → 客户端)。
*/
UNKNOWN_PATH_FP: 'E_UNKNOWN_PATH_FP',
};
/**
* 校验并规范化上行消息信封
* @returns {{ok:true, msg:object} | {ok:false, code:string, reason:string}}
*/
function validateEnvelope(raw, now) {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return { ok: false, code: ERROR_CODES.BAD_ENVELOPE, reason: '消息必须为 JSON 对象' };
}
const v = raw.v;
if (typeof v !== 'number' || !Number.isInteger(v) || v < MIN_SUPPORTED_VERSION) {
return { ok: false, code: ERROR_CODES.BAD_VERSION, reason: `不支持的协议版本: ${v}` };
}
if (v > PROTOCOL_VERSION) {
return { ok: false, code: ERROR_CODES.BAD_VERSION, reason: `客户端版本过新: ${v}` };
}
const type = raw.type;
if (typeof type !== 'string' || !UPSTREAM_EVENTS.has(type)) {
return { ok: false, code: ERROR_CODES.BAD_TYPE, reason: `未知上行事件: ${type}` };
}
// seq 仅对指标/心跳类要求;hello 阶段设备尚无 seq 概念时允许缺省
const seq = Number.isInteger(raw.seq) ? raw.seq : 0;
if (seq < 0) {
return { ok: false, code: ERROR_CODES.BAD_ENVELOPE, reason: 'seq 必须为非负整数' };
}
const deviceId = typeof raw.device_id === 'string' ? raw.device_id : '';
// 服务器时间权威:上行 ts 一律由服务端改写
return {
ok: true,
msg: {
v: Math.min(v, PROTOCOL_VERSION),
type,
ts: now,
seq,
device_id: deviceId,
client_ts: Number.isInteger(raw.ts) ? raw.ts : null, // 仅用于诊断
payload: sanitizePayload(raw.payload, type),
},
};
}
/** 单层键数上限(数组元素同样受此限) */
const MAX_PAYLOAD_KEYS = 64;
/** 允许的嵌套深度:1 = 顶层键的值可以是对象,但其子值不能再是对象 */
const MAX_NEST_DEPTH = 1;
/** 字符串长度上限 */
const MAX_STRING_LEN = 512;
/**
* 递归净化单个值 —— 数据最小化与"控制字段要能通过"之间的折中。
*
* ⚠️ 历史缺陷(已修):早期实现对非指标事件只放行 string/number/boolean/数组,
* **对象一律丢弃**。后果是 `agent:hello` 里的 `capabilities` / `keepalive` /
* `reconnect_policy` 三个嵌套对象全部被剥掉 —— 客户端老老实实上报了能力集,
* 服务端 `msg.payload.capabilities` 恒为 `{}`,device 表里 capabilities 是空对象,
* 查看端因此无法按能力隐藏卡片(方案 7.1 优雅降级直接失效),且**不报任何错**。
* 与指标字段命名分裂、msgpack 未解包同属一类:白名单过严导致静默丢数据。
*
* 现在允许**一层**嵌套对象(够表达能力集/保活状态这类结构化控制字段),
* 但仍有界:深度 1、单层 ≤64 键、字符串 ≤512、仅标量或标量数组作叶子。
* 更深的结构、函数、Symbol 等一律丢弃。
*/
function sanitizeValue(val, depth) {
if (val === null) return null;
switch (typeof val) {
case 'string':
return val.length > MAX_STRING_LEN ? val.slice(0, MAX_STRING_LEN) : val;
case 'number':
return Number.isFinite(val) ? val : null; // NaN/Infinity 归 null
case 'boolean':
return val;
default:
break;
}
if (Array.isArray(val)) {
return val
.filter((x) => ['string', 'number', 'boolean'].includes(typeof x))
.slice(0, MAX_PAYLOAD_KEYS)
.map((x) => (typeof x === 'string' && x.length > MAX_STRING_LEN ? x.slice(0, MAX_STRING_LEN) : x));
}
if (typeof val === 'object' && depth < MAX_NEST_DEPTH) {
const out = {};
let n = 0;
for (const [k, v] of Object.entries(val)) {
if (n >= MAX_PAYLOAD_KEYS) break;
if (typeof k !== 'string' || k.length > 64) continue;
const cleaned = sanitizeValue(v, depth + 1);
if (cleaned === undefined) continue;
out[k] = cleaned;
n++;
}
return out;
}
return undefined; // 深度超限 / 不可序列化类型 → 丢弃
}
/**
* payload 白名单过滤 —— 数据最小化原则(见 V4.0 方案 9.6)
*/
function sanitizePayload(payload, type) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
return {};
}
const out = {};
if (type === 'agent:metrics') {
for (const [k, val] of Object.entries(payload)) {
if (!METRIC_WHITELIST.has(k)) continue; // 未声明字段一律丢弃
out[k] = normalizeMetricValue(k, val);
}
return out;
}
// 其他事件:限制键数与嵌套深度,防夹带;但保留一层对象以承载结构化控制字段
let n = 0;
for (const [k, val] of Object.entries(payload)) {
if (n >= MAX_PAYLOAD_KEYS) break;
if (typeof k !== 'string' || k.length > 64) continue;
const cleaned = sanitizeValue(val, 0);
if (cleaned === undefined) continue;
out[k] = cleaned;
n++;
}
return out;
}
/** 指标值规整:数值或 null,NaN/Infinity 一律归 null */
function normalizeMetricValue(key, val) {
if (val === null || val === undefined) return null;
if (Array.isArray(val)) {
return val
.filter((x) => typeof x === 'number' && Number.isFinite(x))
.slice(0, 64)
.map(round2);
}
if (typeof val === 'number' && Number.isFinite(val)) return round2(val);
return null;
}
function round2(n) {
return Math.round(n * 100) / 100;
}
/**
* 构造下行消息信封
*/
function makeEnvelope(type, payload, extra = {}) {
return {
v: PROTOCOL_VERSION,
type,
ts: Date.now(),
...extra,
payload: payload || {},
};
}
/** 构造错误消息 */
function makeError(code, reason) {
return { v: PROTOCOL_VERSION, type: 'error', ts: Date.now(), payload: { code, reason } };
}
/** 协商编码:客户端声明列表与服务端支持列表取交集,缺省回退 json */
function negotiateEncoding(clientEncodings) {
const supported = ['json', 'msgpack'];
if (!Array.isArray(clientEncodings)) return 'json';
for (const enc of clientEncodings) {
if (supported.includes(enc)) return enc;
}
return 'json';
}
module.exports = {
PROTOCOL_VERSION,
MIN_SUPPORTED_VERSION,
// 指标白名单与清单:由 metrics-schema 单一真相源派生后原样透出,
// 调用方既可只取白名单,也可拿到完整 schema 做展示/校验。
METRIC_WHITELIST,
METRIC_KEYS,
METRICS_SCHEMA,
bytesPerSecToMbps,
UPSTREAM_EVENTS,
DOWNSTREAM_EVENTS,
ERROR_CODES,
validateEnvelope,
sanitizePayload,
makeEnvelope,
makeError,
negotiateEncoding,
};