8 Commits
Author SHA1 Message Date
jkooe 6c650244bf chore(release): v1.4.2 2026-10-05 02:51:31 +08:00
jkooe a56d67c012 fix(v22): 自更新在 Windows 上必须**就地覆写**而非 rename(真机暴露的平台差异)
## 现象(真机 192.168.1.100 上拉取式更新)
`✗ E_EXECUTION_FAILED: 替换脚本失败` —— 下载与哈希校验都过了,挂在最后一步「替换」。

## 根因
Windows **不允许重命名/替换正被运行进程打开的文件**(EPERM/EBUSY),而 node.exe
正持有 agent.mjs。POSIX 可以 unlink 打开中的文件,所以本地(macOS)测不出来。
这也解释了为什么推送式 `deploy` 一直没问题:它走**就地覆写**,而覆写打开中的文件
在 Windows 上是允许的(只有删除/重命名被禁)。

## 修复
- 改为 `writeFileSync(scriptPath, buf)` 就地覆写(先备份 + 已校验的暂存内容)
- **写后校验**:重算入口文件 sha256,与刚校验过的下载比对;不一致立即从备份回滚
- 清理暂存文件;错误信息带上 backup_path / staged_path 便于人工兜底

## 顺带的真机结论(都已在 192.168.1.100 上验证)
- **被控端访问不了 github.com**(`fetch failed`)→ 国内网络现实;改用局域网/自建镜像即可
  (本轮用 `http://192.168.1.100:8081/...` 拉取成功 ✓)→ 故 URL 参数化是必需的,不是花活
- **拉取式更新在 Windows 上可用**(内容确实变化:构建哈希 4833b0b5 → 216328d5 ✓,
  重启后复核通过 ✓)
- **Windows 音频能力真机跑通**(Core Audio COM):🔇 静音 · 音量 77%,可读回真实状态
- **证书指纹钉住**:普通 agent 更新不影响钉住(证书未变 → 仍一致 ✓);只有证书重建才触发
  `E_CERT_MISMATCH` —— 符合设计意图
2026-10-05 02:50:48 +08:00
jkooe 4106ada876 chore(release): v1.4.1 2026-10-05 02:46:37 +08:00
jkooe f88738d36d fix(v22): 修正构建期注入的版本号带多余引号(真机暴露)
## 现象
真机 `nodeagent info` 显示 `被控端版本 : v"1.4.0" @ "e841fbb"` —— 值里带了引号。

## 根因
esbuild 的 `--define:K=V` 中 **V 是按 JS 表达式解析的**。原先写成
`--define:__AGENT_VERSION__='"1.4.0"'`(单引号包裹),单引号被当成**字符串内容**,
于是运行时值变成 `"1.4.0"`(含引号)。产物内联可见:
`return false ? "dev" : '"e841fbb"';`

## 修复
改用 `JSON.stringify(v)`(纯双引号)→ 产物内联为 `return false ? "dev" : "1.4.0";` ✓
真机复核:`被控端版本 : v1.4.0 @ 50ac4b9  构建 05abbbc79abf` ✓

## 附带真机确认(v1.4.0 部署)
- 推送式部署 + **指纹校验通过**(能力 42 项、PID 16664)
- **证书指纹 TOFU 钉住生效**,且普通 agent 更新**不影响**钉住(证书未变 → 仍一致 ✓)——
  这正是设计意图:只有证书重建才会触发 E_CERT_MISMATCH
- **Windows 音频能力真机跑通**(Core Audio COM 路径):`✓ 已应用:🔇 静音 · 音量 77%`,
  且能**读回真实状态**(确定性设置而非切换)—— 这补上了此前只在 macOS 验证的空缺
2026-10-05 02:45:51 +08:00
jkooe 50ac4b9646 chore(release): v1.4.0 2026-10-05 02:42:56 +08:00
jkooe e841fbb187 feat(v22): 第二批安全加固 —— 每客户端独立 PSK + 发现广播最小化
接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。

## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效
**问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认),
一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。

**做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。
比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。

**实测**(五场景全对):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ |
| **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 |
| bob 用自己的 key | ✅ |
| 未登记 carol 用共享 key | ✅(向后兼容) |
| 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) |

配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段;
鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。

## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏
**问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。

**做法**:
- `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`,
  真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**)
- 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`),
  控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**;
  未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」)
- 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛

**实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓

## 验证
- 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败)
- e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为
- 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉

## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
2026-10-05 02:42:12 +08:00
jkooe 159ae2bf6f chore(release): v1.3.0 2026-10-05 02:26:21 +08:00
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。

- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
  `system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
  不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值

**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
   —— 现已双兼容

## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。

- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
  `::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛

## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿

## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
2026-10-05 02:25:19 +08:00
37 changed files with 963 additions and 52 deletions

No files matched your search

+1
View File
@@ -460,6 +460,7 @@ nodeagent/
## 文档
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "1.2.1",
"version": "1.4.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+2
View File
@@ -14,6 +14,8 @@ export type AuditType =
| 'rate.limited'
/** v16:网络配置变更(两阶段提交) */
| 'net.change'
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update';
+25 -2
View File
@@ -167,15 +167,38 @@ export async function agentUpdate(args: Args): Promise<unknown> {
detail: err instanceof Error ? err.message : String(err),
});
}
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
try {
renameSync(staged, scriptPath);
writeFileSync(scriptPath, buf);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '替换脚本失败', {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
detail: err instanceof Error ? err.message : String(err),
backup_path: backup,
staged_path: staged,
});
}
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
if (written !== got) {
try {
copyFileSync(backup, scriptPath); // 回滚
} catch {
/* 回滚失败时靠 backup_path 人工兜底 */
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
expected: got.slice(0, 12),
actual: written.slice(0, 12),
backup_path: backup,
});
}
try {
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
} catch {
/* 清理失败不影响正确性 */
}
audit({
type: 'agent.update',
+20 -1
View File
@@ -4,6 +4,8 @@ import { spawn } from 'node:child_process';
import os from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { currentCertFingerprint } from '../certs.js';
import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
@@ -55,6 +57,16 @@ declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
declare const __BUILD_TIME__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
try {
const cfg = loadAgentConfig().config;
return cfg.allow_from && cfg.allow_from.length > 0 ? cfg.allow_from : null;
} catch {
return null;
}
}
function injected(name: 'version' | 'commit' | 'time'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
@@ -81,6 +93,7 @@ export function buildInfo(): {
version: string;
commit: string;
built_at: string;
cert_sha256: string | null;
} {
if (!buildCache) {
let hash = 'unknown';
@@ -106,6 +119,8 @@ export function buildInfo(): {
version: injected('version'),
commit: injected('commit'),
built_at: injected('time'),
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
}
@@ -128,8 +143,12 @@ export async function systemInfo(args: Args): Promise<unknown> {
node_path: process.execPath,
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
ps_helper: allPsShellStats(),
// v18:构建指纹(部署校验用)
// v18/v21:构建指纹(部署校验用)+ 证书指纹(身份钉定用)
build: buildInfo(),
// v21:来源网段访问控制(null = 未配置 = 放行全部,属于应尽快收敛的风险)
network: {
allow_from: configAllowFrom(),
},
};
if (fields && fields.length > 0) {
+18
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import selfsigned from 'selfsigned';
import { certFingerprint } from '@nodeagent/protocol';
import { agentDir } from './config.js';
export interface TlsMaterial {
@@ -31,3 +32,20 @@ export function ensureCert(): TlsMaterial {
writeFileSync(keyPath, pems.private, { mode: 0o600 });
return { cert: pems.cert, key: pems.private };
}
/**
* 当前证书的 SHA256 指纹(hex,64 位)。
*
* 用途:控制端**钉住**这个指纹(v21 第一批加固)—— 自签证书没有 CA 可信链,
* 唯一能证明"还是那台被控端"的办法就是指纹比对。install.ps1 会用 .NET 算同一值
* 印给操作者带外核对。
*/
export function currentCertFingerprint(): string | null {
const certPath = join(agentDir(), 'certs', 'cert.pem');
try {
if (!existsSync(certPath)) return null;
return certFingerprint(readFileSync(certPath, 'utf8'));
} catch {
return null;
}
}
+29 -1
View File
@@ -8,8 +8,17 @@ export interface AgentConfig {
host: string;
port: number;
tls: boolean;
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
key: string;
/**
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
*
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
*/
keys?: Record<string, string>;
log_level: 'debug' | 'info' | 'warn';
/**
* v2:是否允许输入控制(鼠标 / 键盘)。
@@ -24,6 +33,14 @@ export interface AgentConfig {
auth_mode?: 'psk' | 'ed25519';
/** v3:ed25519 模式下的能力级授权策略(默认拒绝) */
acl?: AclPolicy;
/**
* v21:允许接入的来源网段(CIDR / 单 IP / `*`)。**未配置 = 放行全部**。
*
* ⚠️ 默认放行是为了不打断现有部署,但「换到不可信网络就自动暴露」正是
* 第一批要收敛的风险 —— 建议在家庭/办公网段上显式配置,例如:
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
*/
allow_from?: string[];
/** v3+:审计日志配置 */
audit?: {
/** 是否启用(默认 true) */
@@ -45,6 +62,17 @@ export interface AgentConfig {
broadcast?: string;
/** 广播间隔毫秒(默认 5000) */
interval_ms?: number;
/**
* v22:广播详情级别。
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
*/
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
secret?: string;
};
/**
* v5:文件访问白名单(根目录列表)。
+76 -18
View File
@@ -1,5 +1,6 @@
import { createSocket, type Socket } from 'node:dgram';
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol';
import { currentCertFingerprint } from './certs.js';
export { DEFAULT_DISCOVERY_PORT };
@@ -9,14 +10,41 @@ export interface BeaconPayload {
service: 'nodeagent';
version: string;
node_id: string;
/**
* 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里
* (顶层只留「存在性 + node_id + 时间」)。故为可选。
*/
/** 被控端可达地址(由被控端自行判定) */
host: string;
port: number;
tls: boolean;
auth_mode: string;
host?: string;
port?: number;
tls?: boolean;
auth_mode?: string;
/** 需要输入控制开关的状态,便于控制端提示 */
input_enabled: boolean;
platform: string;
input_enabled?: boolean;
/**
* v21:TLS 证书指纹(sha256 hex)。
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
*/
cert_sha256?: string | null;
platform?: string;
/**
* v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息,
* 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。
*/
minimal?: boolean;
/** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */
detail?: {
host: string;
port: number;
tls: boolean;
auth_mode: string;
input_enabled: boolean;
cert_sha256?: string | null;
platform: string;
};
/** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */
sig?: string;
ts: number;
}
@@ -27,8 +55,14 @@ export interface BeaconOptions {
/** 广播地址;局域网内通常为 255.255.255.255 */
broadcast: string;
intervalMs: number;
/** 报文中除 ts 外的固定字段 */
base: Omit<BeaconPayload, 'service' | 'version' | 'ts'>;
/** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */
base: Required<
Pick<BeaconPayload, 'node_id' | 'host' | 'port' | 'tls' | 'auth_mode' | 'input_enabled' | 'platform'>
>;
/** v22:详情级别;配了 secret 也会自动启用 minimal */
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */
secret?: string;
}
export interface Beacon {
@@ -56,15 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
const send = (): void => {
if (!ready) return;
const buf = Buffer.from(
JSON.stringify({
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
...opts.base,
ts: Date.now(),
} satisfies BeaconPayload),
'utf8',
);
const ts = Date.now();
// v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。
// 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。
const full = {
host: opts.base.host,
port: opts.base.port,
tls: opts.base.tls,
auth_mode: opts.base.auth_mode,
input_enabled: opts.base.input_enabled,
cert_sha256: currentCertFingerprint(),
platform: opts.base.platform,
};
const minimal = opts.detail === 'minimal' || !!opts.secret;
const payload: BeaconPayload = minimal
? {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
node_id: opts.base.node_id,
minimal: true,
detail: full,
sig: opts.secret
? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`)
: undefined,
ts,
}
: {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
cert_sha256: currentCertFingerprint(),
...opts.base,
ts,
};
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
try {
socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => {
if (err) onError?.(`广播失败: ${err.message}`);
+16
View File
@@ -61,6 +61,9 @@ async function main(): Promise<void> {
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
intervalMs: config.discovery?.interval_ms ?? 5000,
// v22:详情分级 + 认证(配了 secret 自动启用 minimal)
detail: config.discovery?.detail ?? 'full',
secret: config.discovery?.secret,
base: {
node_id: config.node_id,
host: ips[0] ?? '127.0.0.1',
@@ -88,6 +91,19 @@ async function main(): Promise<void> {
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`);
// v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒
if (config.discovery?.enabled !== false && !useHub) {
const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret;
if (!minimal) {
console.log(
' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' +
'建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)',
);
} else if (!config.discovery?.secret) {
console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)');
}
}
const discoOn = config.discovery?.enabled !== false;
console.log(
` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`,
+37 -9
View File
@@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
import {
Methods,
PROTOCOL_VERSION,
ipInAllowlist,
generateNonce,
verifyHmac,
verifyNonce,
@@ -131,6 +132,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
const clientId = params.client_id ?? '?';
let authorized: string[] | null = null;
let keyKindUsed = 'ed25519';
if (authMode === 'ed25519') {
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
@@ -155,13 +157,28 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId,
CAPABILITY_MANIFEST.map((c) => c.name),
);
} else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) {
log('warn', `鉴权失败 client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
return;
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
const scopedKey = cfg.keys?.[clientId];
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
audit({
type: 'auth.failure',
client_id: clientId,
remote: state.remote,
reason: scopedKey
? '该 client_id 的专属密钥校验失败'
: '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)',
});
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
return;
}
keyKindUsed = keyKind;
}
state.nonce = null; // 一次性,用后即废
@@ -171,7 +188,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
state.authorized = authorized;
log(
'info',
`鉴权通过 client_id=${clientId} mode=${authMode}` +
`鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` +
(authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''),
);
audit({
@@ -432,7 +449,18 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
: createHttpServer();
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (ws, req) => core.attach(ws, req.socket.remoteAddress ?? '?'));
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
wss.on('connection', (ws, req) => {
const remote = req.socket.remoteAddress ?? '?';
if (!ipInAllowlist(remote, cfg.allow_from)) {
log('warn', `连接被拒绝:来源不在 allow_from(remote=${remote},允许=${JSON.stringify(cfg.allow_from ?? null)})`);
audit({ type: 'net.deny', remote, reason: `source not in allow_from: ${JSON.stringify(cfg.allow_from ?? null)}` });
ws.close(1008, 'source not allowed');
return;
}
core.attach(ws, remote);
});
// WebSocket 层保活:30s ping,探测死连接
const heartbeat = setInterval(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "1.2.1",
"version": "1.4.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+53 -4
View File
@@ -25,8 +25,9 @@ import {
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
generateSharedKey,
matchPattern,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
@@ -85,6 +86,19 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
// 连接成功才落盘,避免把错配置写进设备表
const cfg = loadConfig() ?? emptyConfig(clientId);
// v21:TOFU 钉住证书指纹 —— 首次连接记录,之后每次连接都严格比对。
// 已有钉住值时**不覆盖**(除非 --forget-cert),否则冒充者可借重连刷新钉住值。
const fp = client.getPeerCertFingerprint();
if (opts.forgetCert === true) {
delete profile.cert_sha256;
console.log('已清除旧指纹,将按本次连接重新钉住');
}
if (fp && !profile.cert_sha256) {
profile.cert_sha256 = fp;
console.log(`\n🔐 已钉住被控端证书指纹(TOFU):${fp}`);
console.log(' 请与被控端安装时打印的指纹核对;不一致说明连的可能不是那台机器');
}
cfg.client_id = clientId;
cfg.nodes[name] = profile;
cfg.current = name;
@@ -163,6 +177,31 @@ export async function cmdRemove(name: string | undefined, opts: Options): Promis
export async function cmdKeygen(opts: Options): Promise<void> {
const clientId = opts.id ?? 'mac_01';
// v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。
// 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份**
// (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。
if (opts.psk === true) {
const psk = generateSharedKey();
console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)');
console.log(` client_id : ${clientId}`);
console.log(` 密钥 : ${psk}`);
console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n');
console.log(
JSON.stringify(
{ keys: { [clientId]: psk } },
null,
2,
),
);
console.log('\n然后控制端这样连(key 即上面的密钥):');
console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`);
console.log(
'\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。',
);
return;
}
const keys = createKeys(clientId);
console.log('✓ 已生成 Ed25519 密钥对');
console.log(` client_id : ${keys.client_id}`);
@@ -193,7 +232,7 @@ export async function cmdDiscover(opts: Options): Promise<void> {
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
try {
nodes = await discoverOnce(waitMs, { port });
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
} catch (err) {
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
}
@@ -209,11 +248,21 @@ export async function cmdDiscover(opts: Options): Promise<void> {
if (opts.json) return printJson(nodes);
console.log(`发现 ${nodes.length} 台被控端:\n`);
console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`);
console.log(
` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`,
);
for (const n of nodes) {
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
// v22:广播本身是未认证的,只有验签通过的才标 🔒
const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文';
console.log(
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`,
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`,
);
}
if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) {
console.log(
'\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' +
'\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。',
);
}
const first = nodes[0]!;
+11
View File
@@ -108,6 +108,17 @@ export async function cmdInfo(opts: Options): Promise<void> {
// 先打版本与兼容性(v20)——info 是最自然的「体检」命令
await withClient((c) => {
reportCompat(c);
// v21:把「身份钉定」与「来源网段限制」这两项安全状态摆出来
const cfg = getClientConfig();
const prof = cfg.nodes[cfg.current ?? ''];
const fp = c.getPeerCertFingerprint();
console.log(
prof?.cert_sha256
? `证书指纹 : 已钉住 ${prof.cert_sha256.slice(0, 16)}…${fp === prof.cert_sha256 ? '(本次一致 ✓)' : '(本次不一致 ✗)'}`
: `证书指纹 : **未钉住** —— 建议重连一次自动钉住(nodeagent connect <地址> …)`,
);
const info = c.listCapabilities();
void info;
return Promise.resolve();
});
await withClient((c) =>
+2
View File
@@ -161,6 +161,7 @@ export async function withClientDirect<T>(fn: (client: NodeAgentClient) => Promi
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
certSha256: profile.cert_sha256, // v21:钉住的证书指纹(不一致则拒绝连接)
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
@@ -244,6 +245,7 @@ export async function probeOnce(nodeName?: string): Promise<{
key: target.profile.key ?? '',
clientId: target.clientId,
insecure: target.profile.insecure,
certSha256: target.profile.cert_sha256, // v21:钉住的证书指纹
authMode: target.profile.auth_mode,
privateKey: keys?.privateKey,
hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined,
+1
View File
@@ -4,6 +4,7 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
用法:
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
配置并连接被控端(握手成功后打印能力清单)
首次连接会自动钉住被控端证书指纹(TOFU);被控端重装/换证书后用 --forget-cert 重新钉住
经 Hub 中转: 追加 --hub-token <Hub 令牌> --hub-node <被控端 node_id> (v6)
nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任)
+6
View File
@@ -94,6 +94,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
url: { type: 'string' },
sha256: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
psk: { type: 'boolean', default: false },
'discover-secret': { type: 'string' },
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
mute: { type: 'string' },
@@ -151,6 +154,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
url: values['url'] as string | undefined,
sha256: values['sha256'] as string | undefined,
dryRun: Boolean(values['dry-run']),
psk: Boolean(values['psk']),
discoverSecret: values['discover-secret'] as string | undefined,
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
mute: values['mute'] as string | undefined,
+6
View File
@@ -55,6 +55,12 @@ export interface Options {
kind?: string;
/** v12 宏变量 */
vars?: string[];
/** v22 发现广播认证密钥 */
discoverSecret?: string;
/** v22 每客户端 PSK */
psk?: boolean;
/** v21 证书指纹 */
forgetCert?: boolean;
/** v19 拉取式自更新 */
url?: string;
sha256?: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "1.2.1",
"version": "1.4.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "1.2.1",
"version": "1.4.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+137
View File
@@ -0,0 +1,137 @@
# 安全模型与加固指南
> 面向使用者与运维者。协议细节见 `DEVELOPMENT.md`,发版与升级见 `VERSIONING.md`。
## 1. 信任模型:钥匙 = 那台机器的 root
`system.shell.exec` 能执行任意命令,所以**拿到预共享密钥(PSK)就等于拿到被控端的 root**。
请按 root 密码的标准保管它:不外传、不进版本库、不进公开安装包、不贴进聊天/工单。
被控端首启时用 CSPRNG(`crypto.getRandomValues`,32 字节 / 256 bit)在本机生成密钥,
只存在 `<数据目录>/agent.json`,**不进任何分发包**。
## 2. 认证做了什么(已具备)
| 机制 | 说明 |
|---|---|
| 挑战-应答 | `hello → challenge(nonce) → auth(HMAC / Ed25519 签名)`,**钥匙全程不上网** |
| nonce 一次性 | 用后即废(`server.ts` 置 null),抓包重放无效 |
| 协议主版本校验 | 不一致直接 `E_PROTOCOL_MISMATCH` 拒连 |
| 能力级 ACL | `deny → allow → 默认拒绝`(**身份维度仅在 ed25519 模式有效**) |
| 调用限速 | 按 client_id 滑动窗口 |
| 审计链 | 每条含前一条哈希,可验篡改 |
| 键鼠控制 | **默认禁用**,需显式开启 |
> ⚠️ **PSK 模式的固有限制**:`client_id` 由客户端自报,ACL 的「身份」维度挡不住
> 持有同一把钥匙的人伪装成任意 client_id。要真正按身份授权,请用 **ed25519**
> (`nodeagent keygen` → 公钥登记到被控端 ACL → `--auth-mode ed25519`)。
## 3. v21 加固一:证书指纹钉住(TOFU)
自签证书没有 CA 链,历史上只能 `insecure` 接受任意证书 —— 于是同网段的中间人可以
**透明中继**(拿不到钥匙,但能读到截屏/剪贴板/键入内容)。现在:
1. 被控端在 `install.ps1` 输出里打印 **TLS 证书指纹**(sha256 of DER)
2. 控制端**首次**连接时把它钉进 `~/.nodeagent/config.json`(`nodes.<名>.cert_sha256`)
3. 之后每次连接**严格比对**,不一致直接拒绝:
```
✗ E_CERT_MISMATCH: 证书指纹不匹配,拒绝连接:期望 2ed4957b… 实际 ca8dc20c…
(被控端可能重装了、或中间人冒充)
```
**带外核对(重要)**:装机时屏幕/终端上会印一个 64 位指纹,与控制端首次连接打印的
`🔐 已钉住被控端证书指纹` 比对 —— 不一致说明你连的不是那台机器。
被控端**重装系统 / 证书重建**后指纹必然改变,此时显式重钉:
```bash
nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert
```
查看当前状态:`nodeagent info`(会显示「已钉住 … 本次一致 ✓ / 未钉住」)
## 4. v21 加固二:来源网段白名单(`allow_from`)
被控端默认监听 `0.0.0.0`(为了能远程接入)。这意味着**机器一换网络就自动暴露** ——
连到咖啡厅/酒店/便利店的 Wi-Fi 时,同网段任何人都能看到它的 8765 端口。
在被控端 `agent.json` 里限制来源:
```json
{
"host": "0.0.0.0",
"port": 8765,
"allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
}
```
- 支持 CIDR、单 IP、以及 `*`(放行全部)
- **未配置 = 放行全部**(保持向后兼容);`nodeagent info` 会提示尚未收敛
- 拒绝发生在**业务握手之前**(不浪费 TLS/挑战应答开销),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等 —— Node 在 IPv6 监听上把 IPv4 客户端报成
`::ffff:a.b.c.d`,已做归一化
## 4.5 发现广播最小化(v22)
UDP 广播是**未认证**的:默认配置下同网段任何人都能看到
「哪台机器可接管 / IP / 端口 / 用什么认证 / 键鼠是否开着」。
```json
"discovery": { "detail": "minimal", "secret": "<与控制端相同的密钥>" }
```
- `detail: "minimal"`:顶层只留 `service / version / node_id / ts`,
真实信息收进 `detail{}`(**同网段看不到地址、端口、认证方式、键鼠开关**)
- 配了 `secret`:`detail` 带 **HMAC 签名**(`node_id|ts|JSON(detail)`),
控制端用同一密钥验签 → 列表里显示 **🔒 已认证**;没配/验签失败显示 **⚠️ 明文**(仅作参考)
- 控制端:`nodeagent discover --discover-secret <同一密钥>`
- 未配置时 agent 启动会打印醒目告警,提示收敛
> 仍会暴露「这里有一台可被接管的机器」(存在性无法隐藏,除非关广播或改用 Hub)。
> 真正的身份确认始终发生在**握手之后**(`auth_ok` 带证书指纹,控制端严格比对)。
## 4.6 每客户端一把 PSK(v22,psk 模式下的身份绑定)
psk 模式里 `client_id` 由客户端自报,所以**一把共享 key 意味着持有者可冒充任意 client_id**,
ACL 的身份维度形同虚设。v22 起支持按 client_id 分配独立钥匙:
```bash
# 控制端:为某个 client_id 生成专属 PSK
nodeagent keygen --psk --id mac_02
```
```json
// 被控端 agent.json(可并列多个 client_id)
{ "keys": { "mac_02": "<那把密钥>" } }
```
效果(已实测):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ 通过 |
| **alice 的 key 冒用 bob 身份** | ❌ `E_AUTH_FAILED` |
| bob 用自己的 key | ✅ 通过 |
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
## 5. 还没做的(后续批次)
| 项 | 说明 |
|---|---|
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) |
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
| 会话上限 | 无最大连接数与空闲断开 |
| 安装期密钥传递 | `install.cmd` 用命令行 `-Key` 传参,同机其他用户 `ps` 可见 |
## 6. 应急处置
| 场景 | 处置 |
|---|---|
| 怀疑钥匙泄漏 | 换钥匙(被控端删 `agent.json` 的 `key` 后重启 → 首启重新生成并打印),或改用 ed25519 |
| 证书被换 / 重装后连不上 | `nodeagent connect … --forget-cert` 重钉指纹 |
| 怀疑被中间人 | 用 `install.ps1` 打印的指纹做带外核对;不一致立即断网排查 |
| 机器要带去不可信网络 | 事先配 `allow_from`,或直接不接入陌生网络 |
+12
View File
@@ -35,6 +35,18 @@
6. **能力清单是唯一事实源**:控制端不比版本号,而是比**能力集合**
(`capabilityDiff`)—— 版本号可能相同而内容不同(本地 dev 构建),能力集合不会骗人。
## 3.1 v21 新增字段(均为**可选**,向后兼容)
| 位置 | 字段 | 含义 |
|---|---|---|
| 被控端 `agent.json` | `allow_from: string[]` | 来源网段白名单;**未配置 = 放行全部**(与旧行为一致) |
| 被控端 `system.info` | `build.cert_sha256` / `network.allow_from` | 证书指纹与白名单状态(供控制端展示与巡检) |
| 被控端 `auth_ok` | `build.cert_sha256` | 同上(握手期即可得) |
| 控制端 `nodes.<名>` | `cert_sha256: string` | **钉住**的被控端证书指纹;不一致即 `E_CERT_MISMATCH` 拒连 |
| 发现报文 | `cert_sha256` | 未认证广播,**仅作参考**,不可当身份凭据 |
新增错误码:`E_CERT_MISMATCH (-32422)`、`E_NET_DENY` 类事件走审计 `net.deny`。
## 4. 升级顺序建议
**先升级被控端,再升级控制端**:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent",
"private": true,
"version": "1.2.1",
"version": "1.4.2",
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
"type": "module",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "1.2.1",
"version": "1.4.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+64
View File
@@ -7,6 +7,7 @@ import {
signNonce,
isErrorResponse,
ErrorCodes,
certFingerprint,
type RpcRequest,
type RpcResponse,
type RpcError,
@@ -38,6 +39,11 @@ export interface ClientOptions {
clientId: string;
/** v1 简化:跳过自签证书校验 */
insecure?: boolean;
/**
* v21:钉住的被控端证书指纹(sha256 hex)。给了就会**严格比对**,
* 不一致直接拒绝连接(E_CERT_MISMATCH)—— 防「连上了冒名顶替的机器」。
*/
certSha256?: string;
/** 握手超时(默认 10s) */
handshakeTimeoutMs?: number;
/** 单次调用默认超时(默认 60s) */
@@ -77,6 +83,8 @@ export class NodeAgentClient {
/** ed25519 模式:本次被授权的能力;psk 模式为 null */
private authorized: string[] | null = null;
/** v20:被控端语义化版本(旧被控端不返回则为 undefined) */
/** v21:本次连接观测到的对端证书指纹(TOFU 钉住用) */
private peerCertFingerprint: string | null = null;
private agentVersion?: string;
/** v20:被控端构建信息 { hash, commit, built_at } */
private agentBuild?: { hash?: string; commit?: string; built_at?: string };
@@ -96,6 +104,33 @@ export class NodeAgentClient {
const ws = new WebSocket(url, { rejectUnauthorized: !insecure });
this.ws = ws;
// v21:抓取对端证书指纹(在 upgrade 时机拿,握手阶段尚未加密业务数据)。
// ⚠️ 这解决的是「自签证书 + insecure=接受任意证书」留下的中间人空档:
// 加密照旧,但**连错了机器**会被立刻发现。
let peerCertFp: string | null = null;
// ⚠️ 取对端证书指纹踩了三层坑,都写在这里避免后人重走:
// 1) ws 客户端的 'upgrade' 事件**只传 IncomingMessage**,拿不到 socket;
// 2) socket 要在 'open' 之后从 `ws._socket` 取(TLSSocket);
// 3) Node ≥15 的 getPeerCertificate() 返回**对象**,DER 在 `.raw` 上
// (老版本才是直接返回 Buffer)—— 两者都要兼容。
ws.on('open', () => {
try {
const sock = (ws as unknown as {
_socket?: { getPeerCertificate?: (detailed?: boolean) => unknown };
})._socket;
const got = sock?.getPeerCertificate?.(false);
let der: Buffer | undefined;
if (Buffer.isBuffer(got)) der = got;
else if (got && typeof got === 'object') {
const raw = (got as { raw?: unknown }).raw;
if (Buffer.isBuffer(raw)) der = raw;
}
if (der && der.length > 0) peerCertFp = certFingerprint(der);
} catch {
/* 非 TLS 连接或取不到证书 → 保持 null(不做指纹校验) */
}
});
const timer = setTimeout(() => {
ws.terminate();
reject(new ClientError(ErrorCodes.TIMEOUT, 'E_TIMEOUT', `连接超时: ${url}`));
@@ -103,6 +138,21 @@ export class NodeAgentClient {
ws.once('open', () => {
clearTimeout(timer);
// v21:与钉住的指纹比对 —— 不一致说明「连的不是上次那台机器」,必须拒绝
const expected = this.opts.certSha256?.trim().toLowerCase();
if (expected && peerCertFp && peerCertFp !== expected) {
ws.terminate();
reject(
new ClientError(
ErrorCodes.CERT_MISMATCH,
'E_CERT_MISMATCH',
`证书指纹不匹配,拒绝连接:期望 ${expected.slice(0, 16)}… 实际 ${peerCertFp.slice(0, 16)}…` +
'(被控端可能重装了、或中间人冒充)。核对后可用 nodeagent connect --forget-cert 重新钉住',
),
);
return;
}
this.peerCertFingerprint = peerCertFp; // v21:留给调用方做 TOFU 钉住
onLog?.(`已连接 ${url}`);
// Hub 模式下先完成配对,再挂 RPC 处理器(否则控制面消息会被误当响应)
const ready: Promise<void> = this.opts.hub
@@ -162,6 +212,15 @@ export class NodeAgentClient {
// v20:远端版本/构建信息(旧被控端不返回 → 保持 undefined,不报错)
this.agentVersion = authOk.agent_version;
this.agentBuild = authOk.build;
// v21 交叉确认:upgrade 时抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充
if (this.peerCertFingerprint && authOk.build?.cert_sha256 &&
this.peerCertFingerprint !== authOk.build.cert_sha256) {
throw new ClientError(
ErrorCodes.CERT_MISMATCH,
'E_CERT_MISMATCH',
'被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)',
);
}
const label =
authOk.auth_mode === 'ed25519'
? `ed25519,授权 ${this.authorized?.length ?? 0}/${this.capabilities.length} 项能力`
@@ -191,6 +250,11 @@ export class NodeAgentClient {
}
/** v20:被控端版本(旧被控端未上报则为 undefined)。 */
/** v21:本次连接观测到的对端证书指纹(sha256 hex);非 TLS 连接为 null。 */
getPeerCertFingerprint(): string | null {
return this.peerCertFingerprint;
}
getAgentVersion(): string | undefined {
return this.agentVersion;
}
+7
View File
@@ -11,6 +11,13 @@ export interface NodeProfile {
insecure?: boolean;
/** psk 模式的预共享密钥 */
key?: string;
/**
* v21:**钉住的**被控端证书指纹(sha256 hex,64 位)。
*
* 自签证书没有可信 CA 链,唯一能证明「还是那台机器」的就是指纹。
* 首次连接会记录(TOFU);之后**严格比对**,不一致直接拒绝连接。
*/
cert_sha256?: string;
/** 认证模式;默认 psk */
auth_mode?: 'psk' | 'ed25519';
/** 覆盖全局 client_id(同一控制端接入多台时可区分身份) */
+47 -6
View File
@@ -1,4 +1,5 @@
import { createSocket, type RemoteInfo, type Socket } from 'node:dgram';
import { verifyHmac } from '@nodeagent/protocol';
/** 发现到的被控端。 */
export interface DiscoveredNode {
@@ -12,6 +13,11 @@ export interface DiscoveredNode {
auth_mode: string;
input_enabled: boolean;
platform: string;
/**
* v22:详情是否**通过 HMAC 验签**(被控端配了 `discovery.secret` 且我方也持有)。
* false/未定义 = 明文广播(任何人可伪造)→ 此时这些字段仅作展示,**不可当凭据**。
*/
authenticated?: boolean;
/** 最近一次收到广播的时间 */
last_seen: number;
}
@@ -23,6 +29,11 @@ export interface DiscoveryOptions {
ttlMs?: number;
/** 报文日志(调试用) */
onLog?: (msg: string) => void;
/**
* v22:发现广播的**认证密钥**。与被控端 `discovery.secret` 一致时,
* 最小化广播里的详情会带 HMAC 签名,我方验签通过才采信(`authenticated: true`)。
*/
secret?: string;
}
interface RawBeacon {
@@ -35,6 +46,20 @@ interface RawBeacon {
input_enabled?: boolean;
platform?: string;
ts?: number;
cert_sha256?: string | null;
/** v22:最小化广播标记(顶层只剩存在性,详情在 detail 里) */
minimal?: boolean;
detail?: {
host?: string;
port?: number;
tls?: boolean;
auth_mode?: string;
input_enabled?: boolean;
cert_sha256?: string | null;
platform?: string;
};
/** v22:详情签名(HMAC over `node_id|ts|JSON(detail)`) */
sig?: string;
}
/**
@@ -79,15 +104,31 @@ export class Discovery {
}
if (raw.service !== 'nodeagent' || !raw.node_id) return;
// v22:最小化广播 —— 顶层只有存在性,真实信息在 detail 里。
// 持有 secret 时**必须验签**通过才采信 detail;否则标记为未认证(仅作展示)。
const detail = raw.minimal && raw.detail ? raw.detail : raw;
let authenticated = false;
if (raw.minimal && raw.detail) {
if (this.opts.secret && raw.sig) {
authenticated = verifyHmac(
this.opts.secret,
`${raw.node_id}|${raw.ts}|${JSON.stringify(raw.detail)}`,
raw.sig,
);
}
}
const node: DiscoveredNode = {
node_id: raw.node_id,
// 地址永远取「报文来源 IP」—— 一定可达(自报地址仅作参考)
host: rinfo.address,
advertised_host: raw.host ?? rinfo.address,
port: Number(raw.port ?? 8765),
tls: raw.tls !== false,
auth_mode: raw.auth_mode ?? 'psk',
input_enabled: raw.input_enabled === true,
platform: raw.platform ?? 'unknown',
advertised_host: detail.host ?? rinfo.address,
port: Number(detail.port ?? 8765),
tls: detail.tls !== false,
auth_mode: detail.auth_mode ?? 'psk',
input_enabled: detail.input_enabled === true,
platform: detail.platform ?? 'unknown',
authenticated,
last_seen: Date.now(),
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "1.2.1",
"version": "1.4.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -40,6 +40,11 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
type: 'object',
description: 'PowerShell 常驻助手状态(v15):spawned/hits/failures/consecutiveFailures/avg_ms 等',
},
network: {
type: 'object',
description:
'v21 来源网段访问控制:allow_from 为 null 表示**未配置 = 放行全部**(应尽快收敛)',
},
build: {
type: 'object',
description:
+3
View File
@@ -15,6 +15,8 @@ export const ErrorCodes = {
EXECUTION_FAILED: -32419,
PROTOCOL_MISMATCH: -32420,
UNSUPPORTED_PLATFORM: -32421,
/** v21:被控端证书指纹与控制端钉住的不一致(可能是被冒充) */
CERT_MISMATCH: -32422,
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
@@ -36,6 +38,7 @@ export const ErrorNames: Record<number, string> = {
[ErrorCodes.EXECUTION_FAILED]: 'E_EXECUTION_FAILED',
[ErrorCodes.PROTOCOL_MISMATCH]: 'E_PROTOCOL_MISMATCH',
[ErrorCodes.UNSUPPORTED_PLATFORM]: 'E_UNSUPPORTED_PLATFORM',
[ErrorCodes.CERT_MISMATCH]: 'E_CERT_MISMATCH',
};
/** 协议错误对象。 */
+1
View File
@@ -7,3 +7,4 @@ export * from './ed25519.js';
export * from './acl.js';
export * from './validate.js';
export * from './capabilities.js';
export * from './net.js';
+1 -1
View File
@@ -60,7 +60,7 @@ export interface AuthOkParams {
/** v20:被控端语义化版本(新增字段,旧控制端忽略即可 → 向后兼容) */
agent_version?: string;
/** v20:被控端构建信息(指纹/提交/构建时间),供控制端做版本比对与提示 */
build?: { hash?: string; commit?: string; built_at?: string };
build?: { hash?: string; commit?: string; built_at?: string; cert_sha256?: string | null };
/** v20:被控端支持的协议版本(与 HelloParams.protocol 比对) */
protocol?: string;
}
+115
View File
@@ -0,0 +1,115 @@
import { createHash, X509Certificate } from 'node:crypto';
/**
* 网络访问控制与证书指纹(v21 第一批加固)。
*
* 背景:这两个能力必须**两端用同一套算法**,否则控制端钉的指纹与被控端算的对不上、
* 或白名单在 IPv4/IPv6 表示上出现分歧 → 所以放在 protocol 包里共享。
*/
/** 归一化 IP:去掉 IPv6 的 zone 后缀,并把 IPv4-mapped(::ffff:a.b.c.d)转成 4 字节。 */
function parseIp(ip: string): Uint8Array | null {
let s = ip.trim();
const zone = s.indexOf('%');
if (zone >= 0) s = s.slice(0, zone); // fe80::1%en0
if (!s) return null;
// Node 在 IPv6 监听上会把 IPv4 客户端报成 ::ffff:1.2.3.4
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(s);
if (mapped) s = mapped[1] as string;
if (/^\d+\.\d+\.\d+\.\d+$/.test(s)) {
const parts = s.split('.').map((n) => Number.parseInt(n, 10));
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
return Uint8Array.from(parts);
}
if (!s.includes(':')) return null;
// 完整展开 IPv6(:: 展开为全 0,末组按十六进制解析)
const halves = s.split('::');
const head = halves[0] ?? '';
const tail = halves[1];
const headParts = head.length > 0 ? head.split(':') : [];
const tailParts = tail === undefined || tail.length === 0 ? [] : tail.split(':');
const fill = 8 - headParts.length - tailParts.length;
if (fill < 0) return null;
const groups = [
...headParts,
...Array.from({ length: fill }, () => '0'),
...tailParts,
];
if (groups.length !== 8) return null;
const out = new Uint8Array(16);
for (let i = 0; i < 8; i += 1) {
const g = groups[i] as string;
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
const n = Number.parseInt(g, 16);
out[i * 2] = (n >> 8) & 0xff;
out[i * 2 + 1] = n & 0xff;
}
return out;
}
function parseCidr(spec: string): { bytes: Uint8Array; bits: number } | null {
const [addr, lenRaw] = spec.trim().split('/');
const bytes = parseIp(addr ?? '');
if (!bytes) return null;
const max = bytes.length * 8;
const bits = lenRaw === undefined ? max : Number.parseInt(lenRaw, 10);
if (Number.isNaN(bits) || bits < 0 || bits > max) return null;
return { bytes, bits };
}
/**
* 判断来源 IP 是否在允许列表内。
*
* 语义:
* - 列表**为空或未配置** → 放行全部(保持向后兼容;但 agent 会打印醒目告警,
* 并在 system.info / CLI 里提示「未设白名单」)。
* - 支持三种写法混用:CIDR(`192.168.0.0/16`)、单个 IP(`10.1.2.3`)、
* 以及 `*` 表示全放行。
*
* 注意:比对是**按前缀比特**而不是字符串相等 —— 否则 `::ffff:192.168.0.5`
* 与 `192.168.0.5` 会判不相等(前者是 IPv6-mapped,后者是 IPv4)。
*/
export function ipInAllowlist(remoteIp: string, list?: string[]): boolean {
if (!list || list.length === 0) return true;
if (list.some((x) => x.trim() === '*')) return true;
const ip = parseIp(remoteIp);
if (!ip) return false; // 解析不了来源地址 → 保守拒绝(配了白名单时)
for (const spec of list) {
const cidr = parseCidr(spec);
if (!cidr) continue;
if (cidr.bytes.length !== ip.length) continue; // v4 规则不匹配 v6 地址(除非显式写 ::/0)
let ok = true;
for (let i = 0; i < cidr.bytes.length; i += 1) {
const remain = cidr.bits - i * 8;
if (remain <= 0) break;
const take = remain >= 8 ? 8 : remain;
const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
if (((cidr.bytes[i] as number) & mask) !== ((ip[i] as number) & mask)) {
ok = false;
break;
}
}
if (ok) return true;
}
return false;
}
/**
* 计算 TLS 证书指纹:**sha256(DER) 的十六进制**(小写,64 位)。
*
* 为什么是 DER 而不是 PEM 文件的字节:PEM 里有多余的头尾/换行,两边算出来必然不同。
* DER 是证书的规范二进制编码 —— Windows 的 `X509Certificate2.RawData` 也是它,
* 所以 install.ps1 能用 .NET 算出完全相同的值(供带外核对)。
*/
export function certFingerprint(derOrPem: Uint8Array | string): string {
const der =
typeof derOrPem === 'string' ? new Uint8Array(new X509Certificate(derOrPem).raw) : derOrPem;
return createHash('sha256').update(der).digest('hex');
}
/** 格式化便于人读/口述:前 16 位 + 后 8 位(带冒号分组)。 */
export function shortFingerprint(fp: string): string {
const head = fp.slice(0, 16).toUpperCase();
const tail = fp.slice(-8).toUpperCase();
return `${head}:${tail.match(/.{1,4}/g)?.join(':')}`;
}
+18
View File
@@ -244,9 +244,27 @@ $ctlLabel = if ($AllowInput) { "ENABLED (mouse/keyboard control)" } else { "disa
Write-Host " Input ctl : $ctlLabel"
Write-Host " Auth mode : psk (pre-shared key)"
Write-Host " Discovery : UDP 8766 heartbeat (LAN auto-discovery; outbound only)"
# v21: TLS certificate fingerprint (sha256 of DER) — the Mac side pins this (TOFU).
# This must match what nodeagent computes, so out-of-band verification is possible:
# the Mac prints the same value at first connect; compare the two.
$certFp = ""
try {
$certPath = Join-Path (Split-Path -Parent $cfgPath) "certs\cert.pem"
if (Test-Path -LiteralPath $certPath) {
$x = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $certPath
$sha = [System.Security.Cryptography.SHA256]::Create().ComputeHash($x.GetRawCertData())
$certFp = ($sha | ForEach-Object { $_.ToString("x2") }) -join ""
}
} catch { }
Write-Host ""
Write-Host " Pre-shared key (copy this to the Mac side):" -ForegroundColor Yellow
Write-Host " $Key" -ForegroundColor White
if ($certFp) {
Write-Host ""
Write-Host " TLS cert fingerprint (verify the Mac side pinned the same one):" -ForegroundColor Yellow
Write-Host " $certFp" -ForegroundColor White
}
Write-Host ""
Write-Host " On the Mac, run:" -ForegroundColor Cyan
$insecureFlag = if ($NoTls) { "" } else { " --insecure" }
+5 -3
View File
@@ -97,9 +97,11 @@ async function main() {
'--target=node22',
'--format=esm',
'--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
`--define:__AGENT_VERSION__='"${pkg.version}"'`,
`--define:__BUILD_COMMIT__='"${commit}"'`,
`--define:__BUILD_TIME__='"${builtAt}"'`,
// 注意:define 的值按 **JS 表达式** 解析,故用双引号包裹;写成单引号会让引号成为值的一部分
// (真机踩过:版本显示成 v"1.4.0")。
`--define:__AGENT_VERSION__=${JSON.stringify(pkg.version)}`,
`--define:__BUILD_COMMIT__=${JSON.stringify(commit)}`,
`--define:__BUILD_TIME__=${JSON.stringify(builtAt)}`,
`--outfile=${bundleOut}`,
],
{ stdio: 'inherit' },
+112
View File
@@ -878,6 +878,117 @@ async function testMacroReplay() {
}
}
/**
* v21 第一批加固:来源网段白名单 + 证书指纹钉住。
* 自起一个 TLS agent(主 e2e agent 是明文 ws 且无白名单,不适合这两项)。
*/
async function testNetworkSecurity() {
const { spawn } = await import('node:child_process');
const { readFileSync } = await import('node:fs');
const { createConnection } = await import('node:net');
// 自建等待:主 e2e 的 waitReady 只认它那台 agent(硬编码 URL/KEY),这里用不上
const waitPort = async (p, ms) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
const up = await new Promise((res) => {
const sock = createConnection({ host: '127.0.0.1', port: p });
sock.once('connect', () => { sock.destroy(); res(true); });
sock.once('error', () => { sock.destroy(); res(false); });
});
if (up) return;
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`端口 ${p} 未在超时内就绪`);
};
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-sec-'));
const port = PORT + 7;
const key = 'sec-e2e-key-0123456789abcdef0123456789';
writeFileSync(
join(dataDir, 'agent.json'),
JSON.stringify({
node_id: 'sec_win',
host: '127.0.0.1',
port,
tls: true,
key,
log_level: 'info',
// 故意只放行本机 —— 前一步会先改成不放行来验证拦截
allow_from: ['10.0.0.0/8'],
}),
);
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stderr.on('data', () => {});
const cleanup = () => {
try { child.kill(); } catch { /* 已退出 */ }
};
try {
await waitPort(port, 20_000);
// ① 白名单不含本机 → 连接被拒(E_NODE_OFFLINE 形态:握手被服务端掐断)
await assert.rejects(
() => new NodeAgentClient({ url: `wss://127.0.0.1:${port}`, key, clientId: 'sec', insecure: true }).connect(),
/E_NODE_OFFLINE|E_CONNECT_FAILED|ECONNRESET|ECONNREFUSED|连接已断开/,
'白名单外的来源应被拒绝',
);
// ② 放行本机 → 连上,并拿到证书指纹
const cfgPath = join(dataDir, 'agent.json');
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
cfg.allow_from = ['127.0.0.0/8'];
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
cleanup();
await new Promise((r) => setTimeout(r, 800));
const child2 = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child2.stderr.on('data', () => {});
try {
await waitPort(port, 20_000);
const good = new NodeAgentClient({ url: `wss://127.0.0.1:${port}`, key, clientId: 'sec', insecure: true });
await good.connect();
const fp = good.getPeerCertFingerprint();
assert.ok(fp && /^[0-9a-f]{64}$/.test(fp), '应能观测到 64 位证书指纹');
const info = await good.invoke('system.info', { fields: ['build'] });
assert.equal(info.data.build.cert_sha256, fp, 'agent 自报指纹应与实际握手证书一致');
good.close();
// ③ 钉住错误指纹 → 必须拒绝(E_CERT_MISMATCH)
await assert.rejects(
() =>
new NodeAgentClient({
url: `wss://127.0.0.1:${port}`,
key,
clientId: 'sec',
insecure: true,
certSha256: 'deadbeef'.repeat(8),
}).connect(),
/E_CERT_MISMATCH/,
'指纹不符必须拒绝连接',
);
// ④ 钉住正确指纹 → 放行
const ok = new NodeAgentClient({
url: `wss://127.0.0.1:${port}`,
key,
clientId: 'sec',
insecure: true,
certSha256: fp,
});
await ok.connect();
ok.close();
} finally {
try { child2.kill(); } catch { /* 已退出 */ }
}
} finally {
cleanup();
}
}
/**
* v19:拉取式自更新。
* 用本地 HTTP 服务真跑「下载 + 哈希校验」路径;**一律 dry_run**,绝不改动测试 agent 自身文件。
@@ -1209,6 +1320,7 @@ async function main() {
await test('v13 成功指标与审计链:形状 + 分层时延 + 达标判定 + 链完整', testMetricsAndAudit);
await test('v17 音频控制:读状态 / 写回原值 / 参数校验', testAudio);
await test('v19 拉取式自更新:HTTP 下载 + 哈希校验(dry-run)', testSelfUpdate);
await test('v21 网络安全:网段白名单 + 证书指纹钉住(TOFU 与拒绝)', testNetworkSecurity);
} finally {
agent.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 300));
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { computeHmac, verifyHmac } from '../../packages/protocol/dist/index.js';
/**
* v22:发现广播的详情签名。
* 被控端与控制端必须**逐字节同构**,否则「🔒 已认证」永远不亮 —— 故这里做往返一致性测试。
*/
const detail = {
host: '192.168.1.100',
port: 8765,
tls: true,
auth_mode: 'psk',
input_enabled: false,
cert_sha256: null,
platform: 'win32',
};
const nodeId = 'win_01';
const ts = 1790000000000;
const SECRET = 'S3CRET-DISCOVERY-KEY';
test('详情签名:同一输入两端算出同一值并验签通过', () => {
const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`;
const sig = computeHmac(SECRET, payload);
assert.ok(verifyHmac(SECRET, payload, sig), '控制端应验签通过');
});
test('详情签名:错误密钥 / 篡改详情 / 改时间戳 都必须验签失败', () => {
const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`;
const sig = computeHmac(SECRET, payload);
assert.ok(!verifyHmac('wrong-key', payload, sig), '错误密钥应失败');
const tampered = `${nodeId}|${ts}|${JSON.stringify({ ...detail, port: 9999 })}`;
assert.ok(!verifyHmac(SECRET, tampered, sig), '篡改详情应失败');
assert.ok(!verifyHmac(SECRET, `${nodeId}|${ts + 1}|${JSON.stringify(detail)}`, sig), '改时间戳应失败');
});
test('详情签名:字段顺序变化会改变签名(提醒:两侧都按对象字面量顺序拼)', () => {
const a = computeHmac(SECRET, JSON.stringify({ x: 1, y: 2 }));
const b = computeHmac(SECRET, JSON.stringify({ y: 2, x: 1 }));
assert.notEqual(a, b);
});
+85
View File
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { X509Certificate } from 'node:crypto';
import { ipInAllowlist, certFingerprint, shortFingerprint } from '../../packages/protocol/dist/index.js';
// ---------- v21:来源网段白名单 ----------
test('ipInAllowlist:未配置 = 放行全部(向后兼容)', () => {
assert.equal(ipInAllowlist('1.2.3.4'), true);
assert.equal(ipInAllowlist('1.2.3.4', []), true);
assert.equal(ipInAllowlist('1.2.3.4', ['*']), true);
});
test('ipInAllowlist:CIDR 按前缀比特匹配(不是字符串相等)', () => {
assert.equal(ipInAllowlist('192.168.0.5', ['192.168.0.0/24']), true);
assert.equal(ipInAllowlist('192.168.1.5', ['192.168.0.0/24']), false);
assert.equal(ipInAllowlist('10.1.2.3', ['10.0.0.0/8']), true);
assert.equal(ipInAllowlist('11.1.2.3', ['10.0.0.0/8']), false);
assert.equal(ipInAllowlist('192.168.0.200', ['192.168.0.128/25']), true);
assert.equal(ipInAllowlist('192.168.0.100', ['192.168.0.128/25']), false);
});
test('ipInAllowlist:单 IP 与多条混合', () => {
assert.equal(ipInAllowlist('127.0.0.1', ['127.0.0.1']), true);
assert.equal(ipInAllowlist('127.0.0.2', ['127.0.0.1', '10.0.0.0/8']), false);
assert.equal(ipInAllowlist('10.9.9.9', ['127.0.0.1', '10.0.0.0/8']), true);
});
test('ipInAllowlist:IPv4-mapped IPv6 必须与纯 IPv4 判等(Node 的监听器会这么报)', () => {
// 关键:Node 在 IPv6 监听上把 IPv4 客户端报成 ::ffff:a.b.c.d
assert.equal(ipInAllowlist('::ffff:192.168.0.5', ['192.168.0.0/24']), true);
assert.equal(ipInAllowlist('::ffff:10.0.0.1', ['192.168.0.0/24']), false);
});
test('ipInAllowlist:IPv6 前缀与 zone 后缀', () => {
assert.equal(ipInAllowlist('fe80::1', ['fe80::/10']), true);
assert.equal(ipInAllowlist('fe80::1%en0', ['fe80::/10']), true, '带 zone 后缀应归一化');
assert.equal(ipInAllowlist('2001:db8::1', ['fe80::/10']), false);
});
test('ipInAllowlist:非法来源地址在配了白名单时保守拒绝', () => {
assert.equal(ipInAllowlist('not-an-ip', ['10.0.0.0/8']), false);
assert.equal(ipInAllowlist('999.1.1.1', ['10.0.0.0/8']), false);
});
// ---------- v21:证书指纹 ----------
test('certFingerprint:PEM 与 DER 得到同一值(Windows 侧用 .NET 算 DER)', () => {
// 固定的自签证书夹具(不引第三方依赖,保证测试确定性)
const der = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw);
assert.equal(certFingerprint(FIXTURE_CERT), certFingerprint(der), 'PEM 与 DER 必须算出同一指纹');
assert.match(certFingerprint(FIXTURE_CERT), /^[0-9a-f]{64}$/, '应为 64 位小写 hex');
});
test('certFingerprint:不同证书得到不同指纹(改一个字节即变)', () => {
const raw = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw);
raw[raw.length - 1] = (raw[raw.length - 1] + 1) & 0xff; // 篡改末字节
assert.notEqual(certFingerprint(FIXTURE_CERT), certFingerprint(raw));
});
test('shortFingerprint:可读格式且保留足够位数', () => {
const fp = 'a'.repeat(64);
const s = shortFingerprint(fp);
assert.ok(s.includes(':'));
assert.ok(s.replace(/:/g, '').length >= 20, '至少保留 20 位十六进制便于人工比对');
});
/** 固定的自签证书(仅测试用,与任何真实部署无关)。 */
const FIXTURE_CERT = `-----BEGIN CERTIFICATE-----
MIIDCjCCAfKgAwIBAgIJVXL5GdEwCNb0MA0GCSqGSIb3DQEBCwUAMCExHzAdBgNV
BAMTFm5vZGVhZ2VudC10ZXN0LWZpeHR1cmUwHhcNMjYxMDA0MTgxOTQxWhcNMzYx
MDAxMTgxOTQxWjAhMR8wHQYDVQQDExZub2RlYWdlbnQtdGVzdC1maXh0dXJlMIIB
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuxJei6y331/yEUAq71AcsXfV
tSOe2s3wKb2zqR2JKCJBSEmIIOZbDGfu5UE8pPkVmyOElQhipOQu5vFhjhbVE7Vs
WTFMCwu2uxpaJdYJ+BJqnPyo/gZMLGRzjL+FC5VWgZz4akP05vx4J7mMDksF+2qH
5aNGTVxMwRX3AoFh+kRjngv/bO5P7w+17VTY7TkC29Fa5fNSIcuFABQa1ToL1CCW
iX38nZR9iLaPAnNMGhjZTwgbTSGIVi06EIeK+2CVnlPt6eaUDLNPxGPME/3Fs5PK
xYTfDqOT9lfeKGD/AFK4RbqSpLlMm9QDT2TVDp/s9rMRjD9sTJQLEI/NiJHKDQID
AQABo0UwQzAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIC9DAmBgNVHREEHzAdhhto
dHRwOi8vZXhhbXBsZS5vcmcvd2ViaWQjbWUwDQYJKoZIhvcNAQELBQADggEBAFJa
7lpph2qGIy8k7BlC2FMbxoC7/m5ieIKv1csTPiWlxHXGp414TX7oX1Z1t2UOl4Un
M0v1OkI9IPU2hcfZLHp8H02/xsp8VxElYiosZU4tzjce4oN/Rqlk9EUT4mc9OpdH
2CwVzXoDub5980VlAF7Ujg/jCii+WIDVSkEqGHfi/QAuZKmNa6itYS9E8TCQDTA4
K9hC6qcRROVd4upaXxkQQ7EWYTFjujur3JKNqof+LHei8rb88NrVCEUDJshwn4zi
q8v1k2VO4R7E2PnYRdFdYeQTzc/PxeJmFM7LKYVK+oLy6GgmecdQKKy0Bs+RDG4a
LXRExo5k4XnxHRJ2TP4=
-----END CERTIFICATE-----`;