From 723852e1da213436beaeb4b56f49f344aa553f6c Mon Sep 17 00:00:00 2001 From: PMon Dev Date: Fri, 18 Sep 2026 22:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(lan):=20=E5=86=85=E7=BD=91=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E4=BA=A4=E6=8D=A2=20+=20=E5=8F=AF=E8=BE=BE=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=EF=BC=88=E7=AC=AC=E4=BA=8C=E6=AD=A5=EF=BC=8C=E4=BB=8D?= =?UTF-8?q?=E6=9C=AA=E6=94=B9=E6=95=B0=E6=8D=AE=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一步做出了只绑内网的小服务,这一步把"对端能否走内网"变成**可见的事实**。 ## 服务端:新增内网端点校验(`server/shared/lan-endpoint.js`) ⚠️ **这是本步最关键的一处,不是"再算一遍"**:`lan_endpoints` 是 **客户端上报的不可信输入**,而它会被下发给**查看端**,查看端据此去连接。 不校验就原样下发,等于给了一个**"让别人的手机去连任意地址"的 SSRF 面** —— 上报 `169.254.169.254:80` 或某个公网地址,查看端就成了跳板。 故服务端只收 **IPv4 私有网段**(10/8、172.16/12、192.168/16), 端口 1..65535,最多 4 条,超长/主机名/IPv6 一律丢弃, 且**整条丢弃而不是"截断成合法"**(猜用户意图比丢掉更危险)。 > 客户端的 `pickLanAddress` 也在挑地址,但那是**选"发哪个"**(可用性), > 这里是**验"收的是否合法"**(安全性)—— 两者在**信任边界两侧**, > 不是同一份判定的两处拷贝。这条写进了文件头,免得后人当重复删掉。 网关侧:端点存**进程内 Map(刻意不落库** —— 内网地址是瞬态的, 换 Wi-Fi 就变,落库只会给查看端一个过期地址、让它白等一次超时), 随 `server:snapshot` 下发;**设备断连即回收**(同一设备重连时保留, 与 `_viewerEncoding` 的回收同一处理)。 ## 客户端 - 采集端:`agent:hello` 带 `lan_endpoints`(**没有内网地址时不带该字段**, 而不是带空值 —— "没有"是合法状态,不该让查看端去猜空列表的含义) - 查看端:收端点 → **只在列表变化时**探测一次(快照会反复到达,每次都探是浪费) → 记录"可达的是哪个" - 探测超时**刻意短(800ms)**:探测只决定走哪条路,而**云端本来就通** —— 为了内网快而让用户每次先卡几秒,是把优化的代价转嫁给体验。 探不通立刻回云端,用户感知不到这一步 - 探测**要求 body 里 `ok:true`**,光有 200 不算 —— 同网段里别的服务也会返回 200 ## 诚实边界 ⚠️ **本步只做「探测 + 让用户看得见」,还没有改数据面** —— 指标仍走云端。 先让"走内网/走公网"成为**可验证的事实**再动数据面, 否则改完也说不清到底有没有生效(本项目吃过"静默失败"的亏)。 界面显示留在下一步(看板自检页)。 ## 验证 - 服务端 27 条用例(**重点在不合法值必须被拦**:公网地址、云元数据地址、 回环、端口越界、主机名、IPv6、超长、超条数)→ 已接进 run-all - 客户端探测 3 条(真起服务判可达、真起一个"只返回 200 内容不对"的假服务 判不可达、多端点取第一个可达)→ 客户端 **301/301** - 服务端离线套件 **7/7 PASS** --- client/lib/host/lan_service.dart | 48 +++++++++++++++++ client/lib/host/link.dart | 8 +++ client/lib/host/viewer.dart | 49 ++++++++++++++++++ client/test/lan_service_test.dart | 50 ++++++++++++++++++ server/gateway/gateway.js | 31 +++++++++++ server/shared/lan-endpoint.js | 86 +++++++++++++++++++++++++++++++ tests/lan-endpoint-test.js | 68 ++++++++++++++++++++++++ tests/run-all.sh | 3 ++ 8 files changed, 343 insertions(+) create mode 100644 server/shared/lan-endpoint.js create mode 100644 tests/lan-endpoint-test.js diff --git a/client/lib/host/lan_service.dart b/client/lib/host/lan_service.dart index 16895cc..b96e37d 100644 --- a/client/lib/host/lan_service.dart +++ b/client/lib/host/lan_service.dart @@ -183,3 +183,51 @@ class LanService { } } } + +/// 探测**一个**内网端点是否真的可达(查看端用)。 +/// +/// ── 超时为什么刻意短(800ms)────────────────────────────────────── +/// 探测只决定"走哪条路",而**云端通道本来就是通的**。 +/// 若为了等内网而把超时放到好几秒,就等于把"局域网更快"这个优化 +/// 变成了"每次连接都先卡几秒" —— 优化的代价转嫁给了体验。 +/// 探不通就立刻走云端,用户感知不到这一步的存在。 +/// +/// 只认 `/lan/ping` 且**要求 body 里 `ok:true`** —— +/// 光看状态码不够:同一网段里别的服务也可能返回 200。 +Future probeLanEndpoint( + String endpoint, { + Duration timeout = const Duration(milliseconds: 800), +}) async { + final client = HttpClient()..connectionTimeout = timeout; + try { + final req = await client + .getUrl(Uri.parse('http://$endpoint/lan/ping')) + .timeout(timeout); + final resp = await req.close().timeout(timeout); + if (resp.statusCode != 200) return false; + final body = await resp + .transform(const Utf8Decoder(allowMalformed: true)) + .join() + .timeout(timeout); + return body.contains('"ok":true'); + } catch (_) { + // 连不上、超时、DNS 失败…… 一律当作"不可达"。 + // 探测失败不是错误,只是"这条更快但非必需的路不可用"。 + return false; + } finally { + client.close(force: true); + } +} + +/// 依次探测,返回**第一个可达**的端点;都不可达返回 null。 +/// +/// 顺序即优先级:服务端下发的顺序就是采集端上报的顺序。 +Future pickReachableLanEndpoint( + List endpoints, { + Duration timeout = const Duration(milliseconds: 800), +}) async { + for (final e in endpoints) { + if (await probeLanEndpoint(e, timeout: timeout)) return e; + } + return null; +} diff --git a/client/lib/host/link.dart b/client/lib/host/link.dart index a6ba8a9..64a3dfa 100644 --- a/client/lib/host/link.dart +++ b/client/lib/host/link.dart @@ -38,6 +38,7 @@ import 'package:socket_io_client/socket_io_client.dart' as io; import 'pmon_core.dart'; import 'config_migration.dart'; +import 'lan_service.dart'; /// 上报的指标载荷(字段由 Rust 决定,此处不硬编码字段名) typedef MetricsFrame = Map; @@ -471,6 +472,13 @@ class ClientAgent { // ⚠️ 它**不是凭据**:可猜、不参与鉴权,只用于归并候选匹配。 if (hardwareId.isNotEmpty) 'hardware_id': hardwareId, 'capabilities': core.capabilities(), + // 内网端点(可选):查看端据此**先试内网、失败立刻回落云端**。 + // 没有可用内网地址时**不带这个字段**(不是带空值)—— + // "这台机没有内网地址"是合法状态,不该让查看端去猜空列表的含义。 + // ⚠️ 服务端会再校验一遍(只收私有网段 IPv4):那是**信任边界**, + // 与这里"挑哪个地址上报"是两回事,别当成重复而删掉。 + if (LanService.instance.endpoint != null) + 'lan_endpoints': [LanService.instance.endpoint], 'keepalive': core.keepaliveState(), 'reconnect_policy': { 'heartbeat_interval_ms': constants['heartbeat_interval_ms'], diff --git a/client/lib/host/viewer.dart b/client/lib/host/viewer.dart index 3391ad1..aa165ab 100644 --- a/client/lib/host/viewer.dart +++ b/client/lib/host/viewer.dart @@ -41,6 +41,7 @@ import 'package:socket_io_client/socket_io_client.dart' as io; // account_api 已转出 pairing_api 的类型(HttpReply / PairingApi / PairingOutcome …) import 'account_api.dart'; import 'app_config.dart'; +import 'lan_service.dart'; import 'pmon_core.dart'; import 'timing.dart'; @@ -572,6 +573,16 @@ class ViewerSession { /// 会话令牌(登录后由界面注入;为空表示兼容模式:只认别人授权给我的设备) String? _token; + // ── 局域网直连(方案:LAN 优先)────────────────────────────────── + // 采集端上报的内网端点(服务端已校验只收私有网段)与本次探测结果。 + // + // ⚠️ 本步只做「**探测 + 让用户看得见**」,**还没改数据面** —— 指标仍走云端。 + // 先让"走内网 / 走公网"变成**可见、可验证的事实**,再去动数据面; + // 否则改完也说不清到底有没有生效(本项目吃过"静默失败"的亏)。 + List _lanEndpoints = const []; + String? _lanReachable; + bool _lanProbing = false; + /// 诊断日志出口。 /// /// 为什么查看端必须有它:采集端有日志面板(`link.dart` 的 `_logLine`), @@ -638,6 +649,15 @@ class ViewerSession { Timer? _reconnectTimer; bool _running = false; + /// 采集端上报的内网端点(空 = 该设备没有可用内网地址,属正常状态) + List get lanEndpoints => _lanEndpoints; + + /// 本次探测**可达**的那个端点;null = 都不可达或没有端点 + String? get lanReachable => _lanReachable; + + /// 正在探测 —— 界面据此显示「检测中」而不是直接说「不可达」 + bool get lanProbing => _lanProbing; + ViewerStatus get status => _status; ViewerSeries get series => _series; bool get running => _running; @@ -882,6 +902,33 @@ class ViewerSession { }); } + /// 快照里带了内网端点 → 探测一次并记录结果。 + /// + /// 只在**端点列表变化时**才重新探测:快照会在订阅、补发等时机反复到达, + /// 每次都探一遍纯属浪费(还会给对端造无谓的连接)。 + void _considerLan(dynamic raw) { + final list = raw is List + ? raw.whereType().take(4).toList(growable: false) + : const []; + if (list.length == _lanEndpoints.length && + List.generate(list.length, (i) => list[i] == _lanEndpoints[i]) + .every((x) => x)) { + return; // 没变化 + } + _lanEndpoints = list; + _lanReachable = null; + if (list.isEmpty) { + _lanProbing = false; + return; + } + _lanProbing = true; + unawaited(pickReachableLanEndpoint(list).then((hit) { + if (!_running) return; + _lanProbing = false; + _lanReachable = hit; + })); + } + void _onSnapshot(dynamic data) { final payload = _payloadOf(data); if (payload == null) return; @@ -899,6 +946,8 @@ class ViewerSession { } } + _considerLan(payload['lan_endpoints']); + final online = payload['online'] is bool ? payload['online'] as bool : null; final caps = payload['capabilities']; final capsMap = caps is Map diff --git a/client/test/lan_service_test.dart b/client/test/lan_service_test.dart index 6f87f8a..e2bd263 100644 --- a/client/test/lan_service_test.dart +++ b/client/test/lan_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -134,6 +135,55 @@ void main() { expect(s.port, p1); }); + test('★ 探测:对面真起着服务 → 可达;不存在 → 不可达且不抛异常', () async { + // 探测的目的是"决定走哪条路",所以两种结果都必须**安静地**返回 + // —— 连不上不是错误,只是"这条更快但非必需的路不可用"。 + final s = LanService.instance; + await s.start(); + if (!s.running) return; // 环境没有内网地址时不强求 + + expect(await probeLanEndpoint('${s.address}:${s.port}'), isTrue, + reason: '对面真的在跑,必须判为可达'); + + // 127.0.0.1:1 上不会有服务 → 必须"不可达"而不是抛异常 + expect(await probeLanEndpoint('127.0.0.1:1', + timeout: const Duration(milliseconds: 300)), + isFalse); + }); + + test('★ 探测要求 body 里 ok:true —— 光有 200 不算(同网段别的服务也会 200)', + () async { + final s = LanService.instance; + await s.start(); + if (!s.running) return; + // 起一个"只返回 200 但内容不对"的服务,探测必须判为**不可达** + final fake = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + unawaited(() async { + await for (final r in fake) { + r.response + ..statusCode = 200 + ..write('{"hello":"world"}'); + await r.response.close(); + } + }()); + addTearDown(() => fake.close(force: true)); + expect(await probeLanEndpoint('127.0.0.1:${fake.port}'), isFalse, + reason: '状态码 200 不足以证明"这是我们自己的内网服务"'); + }); + + test('多个端点 → 返回第一个可达的(顺序即优先级)', () async { + final s = LanService.instance; + await s.start(); + if (!s.running) return; + final hit = await pickReachableLanEndpoint([ + '127.0.0.1:1', // 不可达 + '${s.address}:${s.port}', // 可达 + ]); + expect(hit, '${s.address}:${s.port}'); + expect(await pickReachableLanEndpoint(['127.0.0.1:1']), isNull); + expect(await pickReachableLanEndpoint(const []), isNull); + }); + test('stop 之后 running 为假、endpoint 为 null', () async { final s = LanService.instance; await s.start(); diff --git a/server/gateway/gateway.js b/server/gateway/gateway.js index 41e5221..532c252 100644 --- a/server/gateway/gateway.js +++ b/server/gateway/gateway.js @@ -13,6 +13,7 @@ 'use strict'; const P = require('../../shared/protocol'); +const { normalizeEndpoints } = require('../shared/lan-endpoint'); // 身份口径与权限判定规则(与业务服务同一处真相源) const { resolveIdentity, hashToken, looksLikeToken, IDENTITY } = require('../../shared/accounts'); @@ -52,6 +53,13 @@ class Gateway { this._viewerEncoding = new Map(); /** @type {Set} `${accountId}|${deviceId}` 已补发首帧 */ this._primedViewers = new Set(); + + // 设备 → 内网端点(`ip:port`)。**刻意不落库**: + // 内网地址是**瞬态**的(换 Wi-Fi 就变),落库只会留下过期数据, + // 而"下发给查看端一个过期内网地址"会让它白等一次超时才回落云端。 + // 有界性:大小 = 在线采集端数量,且断连即回收(见 onDisconnect)。 + /** @type {Map} deviceId -> 已校验的内网端点 */ + this._lanEndpoints = new Map(); this._primedViewersMax = options.primedViewersMax || 10000; // 断线补传去重窗口:deviceId -> Set(两级有界) @@ -318,6 +326,18 @@ class Gateway { // 能力集:声明支持哪些指标 → 查看端据此隐藏卡片(优雅降级) const capabilities = msg.payload.capabilities || {}; + // 内网端点:查看端据此**先试内网、失败回落云端**(方案:局域网直连)。 + // ⚠️ 这是**不可信输入**,必须过服务端校验(只收私有网段 IPv4)—— + // 不校验就下发,等于给了"让查看端去连任意地址"的 SSRF 面。 + // 校验规则与理由见 shared/lan-endpoint.js 的文件头。 + const lanEndpoints = normalizeEndpoints(msg.payload.lan_endpoints); + if (lanEndpoints.length) { + this._lanEndpoints.set(msg.device_id, lanEndpoints); + } else { + // 本次没报(或全部非法)→ 清掉旧的,避免拿着上一轮的地址去连 + this._lanEndpoints.delete(msg.device_id); + } + // 设备落库(有 PG 时持久化,重启不丢) if (this.pgEnabled) { try { @@ -641,10 +661,15 @@ class Gateway { } } + // 内网端点随快照下发(**取不到就不带该字段**,查看端据此退回云端, + // 与 capabilities 的"取不到就 null"同一处理哲学:不要让缺失变成报错) + const lanEndpoints = this._lanEndpoints.get(target) || []; + socket.emit('server:snapshot', P.makeEnvelope('server:snapshot', { device_id: target, online, frames: snapshot, + ...(lanEndpoints.length ? { lan_endpoints: lanEndpoints } : {}), ...(meta || {}), meta_available: !!meta, })); @@ -710,6 +735,12 @@ class Gateway { // 采集端断开 → 标记离线并广播 await this.store.markOffline(session.deviceId); await this.broadcastDeviceStatus(session.deviceId, 'offline'); + // 内网端点随连接失效 —— 必须回收,否则会一直被当成"还有内网可走"。 + // 同一台设备可能正重连(新连接已写入),故确认无其它活动 agent 连接后才删; + // 与上面 viewer 的 _viewerEncoding 回收同一处理。 + const stillConnected = Array.from(this.sessions.values()) + .some((s) => s.role === 'agent' && s.deviceId === session.deviceId); + if (!stillConnected) this._lanEndpoints.delete(session.deviceId); // 注意:不立即回收 seq 窗口 —— 客户端重连后会补传断线期间的帧, // 此时仍需去重。窗口由有界策略自然滚动淘汰,避免长期驻留。 } diff --git a/server/shared/lan-endpoint.js b/server/shared/lan-endpoint.js new file mode 100644 index 0000000..e98d165 --- /dev/null +++ b/server/shared/lan-endpoint.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * 内网端点(`ip:port`)的**服务端侧**校验。 + * + * ── 为什么必须校验(这是信任边界,不是"再算一遍")──────────────── + * `lan_endpoints` 是**客户端上报的不可信输入**,而它会被下发给**查看端**, + * 查看端据此去连接。若不校验就原样下发,等于给了一个 + * **"让别人的手机去连任意地址"的 SSRF 面** —— 上报 `169.254.169.254:80` + * 或某个公网地址,就成了借查看端发起连接的跳板。 + * + * 客户端的 `pickLanAddress` 也在挑地址,但那是**选"发哪个"**(可用性), + * 这里是**验"收的是否合法"**(安全性)。两者处在信任边界两侧, + * 不是同一份判定的两处拷贝 —— 这条要写清楚,免得后人以为是重复而删掉。 + * + * 规则(刻意窄): + * · 只接受 **IPv4 私有网段**(10/8、172.16/12、192.168/16); + * · `port` 必须在 1..65535; + * · 单次最多 [MAX_ENDPOINTS] 条、每条长度受限。 + * 任何一条不满足 → **整条丢弃**(不是"截断成合法" —— 猜用户的意图比丢掉更危险)。 + */ + +/** 单次最多接受几条内网端点 */ +const MAX_ENDPOINTS = 4; +/** 单条 `ip:port` 的长度上限(255.255.255.255:65535 = 21) */ +const MAX_LEN = 24; + +/** + * 判断是否为私有网段 IPv4。 + * @param {string} ip + * @returns {boolean} + */ +function isPrivateIpv4(ip) { + const parts = ip.split('.'); + if (parts.length !== 4) return false; + const n = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN)); + if (n.some((x) => Number.isNaN(x) || x > 255)) return false; + const [a, b] = n; + if (a === 10) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + return false; +} + +/** + * 校验并规范化一条 `ip:port`。 + * @param {unknown} v + * @returns {string|null} 规范化后的端点;不合法返回 null + */ +function normalizeEndpoint(v) { + if (typeof v !== 'string') return null; + const s = v.trim(); + if (s.length === 0 || s.length > MAX_LEN) return null; + const m = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/.exec(s); + if (!m) return null; + const ip = m[1]; + const port = Number(m[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + if (!isPrivateIpv4(ip)) return null; // ★ 非私有网段一律丢弃(SSRF 面就堵在这里) + return `${ip}:${port}`; +} + +/** + * 校验客户端上报的端点数组。 + * @param {unknown} raw `agent:hello.payload.lan_endpoints` + * @returns {string[]} 合法端点(可能是空数组) + */ +function normalizeEndpoints(raw) { + if (!Array.isArray(raw)) return []; + const out = []; + for (const v of raw) { + if (out.length >= MAX_ENDPOINTS) break; + const e = normalizeEndpoint(v); + // 去重:同一地址重复上报没有意义,还会白白占用名额 + if (e && !out.includes(e)) out.push(e); + } + return out; +} + +module.exports = { + MAX_ENDPOINTS, + MAX_LEN, + isPrivateIpv4, + normalizeEndpoint, + normalizeEndpoints, +}; diff --git a/tests/lan-endpoint-test.js b/tests/lan-endpoint-test.js new file mode 100644 index 0000000..031f076 --- /dev/null +++ b/tests/lan-endpoint-test.js @@ -0,0 +1,68 @@ +'use strict'; + +/** + * 内网端点校验的服务端侧测试。 + * + * 重点不在"合法值能过",而在**不合法值必须被拦** —— 这段代码的职责是 + * 堵住"客户端上报任意地址 → 查看端去连"的 SSRF 面,所以每条拦截规则 + * 都要有用例,且要写明**为什么这条该拦**。 + */ + +const assert = require('assert'); +const { + MAX_ENDPOINTS, + isPrivateIpv4, + normalizeEndpoint, + normalizeEndpoints, +} = require('../server/shared/lan-endpoint'); + +let pass = 0; +let fail = 0; +function check(name, cond) { + if (cond) { + pass++; + } else { + fail++; + console.log(' ✗ ' + name); + } +} + +// ── 私有网段判定 ── +check('10/8 是私有', isPrivateIpv4('10.0.0.1')); +check('192.168/16 是私有', isPrivateIpv4('192.168.1.5')); +check('172.16/16 下界是私有', isPrivateIpv4('172.16.0.1')); +check('172.31/16 上界是私有', isPrivateIpv4('172.31.255.254')); +check('★ 172.32 不是私有(边界不能错)', !isPrivateIpv4('172.32.0.1')); +check('172.15 不是私有', !isPrivateIpv4('172.15.0.1')); +check('★ 公网地址不是私有', !isPrivateIpv4('203.0.113.9')); +check('段数不对不认', !isPrivateIpv4('192.168.1')); +check('超范围不认', !isPrivateIpv4('192.168.1.256')); + +// ── 单条规范化 ── +check('正常 ip:port 通过', normalizeEndpoint('192.168.1.5:49504') === '192.168.1.5:49504'); +check('前后空白被裁掉', normalizeEndpoint(' 10.0.0.7:80 ') === '10.0.0.7:80'); +check('★ 公网地址被拦(否则查看端成了跳板)', normalizeEndpoint('203.0.113.9:80') === null); +check('★ 云元数据地址 169.254.169.254 被拦', normalizeEndpoint('169.254.169.254:80') === null); +check('★ 回环 127.0.0.1 被拦(对面连不上,且语义不对)', normalizeEndpoint('127.0.0.1:8080') === null); +check('端口 0 被拦', normalizeEndpoint('192.168.1.5:0') === null); +check('端口超上限被拦', normalizeEndpoint('192.168.1.5:65536') === null); +check('非数字端口被拦', normalizeEndpoint('192.168.1.5:http') === null); +check('缺端口被拦', normalizeEndpoint('192.168.1.5') === null); +check('主机名被拦(只认点分 IPv4)', normalizeEndpoint('myhost.local:80') === null); +check('超长被拦', normalizeEndpoint('192.168.1.5:' + '9'.repeat(60)) === null); +check('非字符串被拦', normalizeEndpoint(12345) === null && normalizeEndpoint(null) === null); +check('IPv6 被拦(目前只走 v4)', normalizeEndpoint('[fe80::1]:80') === null); + +// ── 数组规范化 ── +check('非数组 → 空', normalizeEndpoints(null).length === 0 && normalizeEndpoints('x').length === 0); +check('混合时只留合法的', JSON.stringify(normalizeEndpoints( + ['192.168.1.5:1', 'evil.example.com:1', '10.0.0.7:2'])) === JSON.stringify(['192.168.1.5:1', '10.0.0.7:2'])); +check('去重', normalizeEndpoints(['192.168.1.5:1', '192.168.1.5:1']).length === 1); +check(`★ 条数上限 ${MAX_ENDPOINTS}(多报不留)`, + normalizeEndpoints(['10.0.0.1:1', '10.0.0.2:1', '10.0.0.3:1', '10.0.0.4:1', '10.0.0.5:1']).length === MAX_ENDPOINTS); +check('空数组 → 空(表示"没有内网地址",是合法状态)', normalizeEndpoints([]).length === 0); + +console.log(`\n────────────────────────────────────────────────`); +console.log(`内网端点校验测试 — 通过: ${pass} 失败: ${fail}`); +console.log(`────────────────────────────────────────────────`); +process.exit(fail === 0 ? 0 : 1); diff --git a/tests/run-all.sh b/tests/run-all.sh index 341cd47..641999e 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -77,6 +77,9 @@ run "协议契约(34 项)" node protocol-test.js run "编解码与体积(64 项)" node codec-test.js # 配对安全的离线契约段:不上任何服务也能拦住"把码比对删掉但留着测试"的退化改动 run "配对安全·源码契约(23 项)" node pairing-security-test.js --offline-only +# 内网端点校验:堵的是「客户端上报任意地址 → 查看端去连」的 SSRF 面。 +# 故用例重点在**不合法值必须被拦**,而不在合法值能过。 +run "内网端点校验(27 项)" node lan-endpoint-test.js # 隐私扫描:提交/推送前拦一道。工作树 + **全部提交**都扫 —— # 只改工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。 run "隐私扫描(工作树+全历史)" node privacy-scan.js