产品负责人要"设备间传文件"免去手工粘贴领取码。这需要一条信令通道。
本提交只做**服务端侧**(协议事件 + 网关转发 + 端到端验证),
客户端接线留作下一步 —— 一次改太多、又验不透,是本项目最贵的一类错误的温床。
## 协议:新增 3 个上行 + 2 个下行事件
| 事件 | 方向 | 作用 |
|---|---|---|
| `agent:offer` | 上行 | 采集端发布"有个文件待领" → 广播给该设备的订阅者 |
| `viewer:offer` | 上行 | 查看端发布 → 定向送到目标设备的采集端 |
| `viewer:unsubscribe` | 上行 | **退订** —— 局域网直连时改走内网,必须能退掉云端订阅 |
| `server:offer` | 下行 | "有人要给你一个文件,凭这些信息去取" |
| `server:unsubscribed` | 下行 | 退订回执 |
## ★ 三条设计取舍
**① 服务端刻意不解析"领取码"的格式。**
载荷里把 `lan_endpoints`(数组)与 `token` **分开传**:端点走
`normalizeEndpoints` 校验(只收私有网段 —— 与 hello 里**同一条防线**,不因
"这次是 offer"而放宽),而那个码是客户端之间的约定,服务端多懂一层
就多一处要跟着改的地方。
**② `viewer:offer` 必须先过授权。**
`isAuthorized(target, accountId)` 复用同一套判据。少了这一步,
"发文件"就成了**绕过授权往别人设备推东西**的口子。
**③ 退订只摘 Redis 订阅,不动 socket 房间。**
房间是**账号级**(`viewer:<accountId>`,一个账号可能多端在线),
而订阅是**设备级**。把房间退掉会连带断掉该账号看别的设备的连接 ——
那是另一回事。转发路径本就按 `getSubscribers(deviceId)` 查,摘订阅即生效。
## 验证:端到端穿透(`tests/tools/lan-signal-e2e.js`)
真起 socket 走完整链路,**8/8 通过**:
· `agent:offer` → 订阅者收到,端点/文件名/大小原样到达
· **公网端点在 offer 这条路上同样被拦**(没绕过同一条防线)
· `viewer:offer` → 目标设备的采集端收到
· `viewer:unsubscribe` → 收到回执,且**此后不再收到该设备的帧**
(这一条是"省流量"能不能成立的前提 —— 退不干净就是空话)
另:跑 `lan-signal-e2e` 时发现订阅本身有授权校验(匿名订阅被 `NOT_PAIRED` 拒),
这在设计之内,脚本里如实写明。
283 lines
9.7 KiB
JavaScript
283 lines
9.7 KiB
JavaScript
/**
|
||
* 墨观 协议规范 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',
|
||
// 退订:局域网直连时查看端改走内网,必须能**主动退掉云端订阅**,
|
||
// 否则"省流量"只是句空话 —— 云端仍会把每一帧推过来。
|
||
'viewer:unsubscribe',
|
||
// 设备间传文件的"发布":告诉对端"我这儿有个文件,凭这些信息来取"。
|
||
// 采集端发的广播给该设备的订阅者;查看端发的定向给目标设备的采集端。
|
||
// ⚠️ 载荷里**分开发** `lan_endpoints`(数组,服务端照旧校验只收私有网段)
|
||
// 与 `token`;**服务端刻意不解析那个"领取码"的格式** ——
|
||
// 码是客户端之间的约定,服务端多懂一层就多一处要跟着改的地方。
|
||
'agent:offer',
|
||
'viewer:offer',
|
||
]);
|
||
|
||
/** 下行事件名 */
|
||
const DOWNSTREAM_EVENTS = new Set([
|
||
'server:config',
|
||
'server:snapshot',
|
||
'server:metrics',
|
||
'server:device_status',
|
||
// 心跳应答。服务端必须回一声:采集端连接的常态是"只上行、无下行",
|
||
// 若心跳只去不回,客户端的半开检测会把**完全健康**的连接判成"疑似离线"
|
||
// (因为能写不能证明对端活着 —— TCP 会把数据静默缓冲进死连接)。
|
||
'server:pong',
|
||
// "有人要给你一个文件,凭这些信息去取"。与 server:metrics 同构:
|
||
// 都是"某个设备的订阅者会收到的东西",复用同一套订阅与转发。
|
||
'server:offer',
|
||
// 退订回执 —— 查看端据此确认"云端确实不再推了",才敢切到内网。
|
||
'server:unsubscribed',
|
||
'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,
|
||
};
|