Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159ae2bf6f | ||
|
|
0d59d6032b |
No files matched your search
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -14,6 +14,8 @@ export type AuditType =
|
||||
| 'rate.limited'
|
||||
/** v16:网络配置变更(两阶段提交) */
|
||||
| 'net.change'
|
||||
/** v21:来源不在 allow_from 被拒 */
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,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) */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSocket, type Socket } from 'node:dgram';
|
||||
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
|
||||
import { currentCertFingerprint } from './certs.js';
|
||||
|
||||
export { DEFAULT_DISCOVERY_PORT };
|
||||
|
||||
@@ -16,6 +17,12 @@ export interface BeaconPayload {
|
||||
auth_mode: string;
|
||||
/** 需要输入控制开关的状态,便于控制端提示 */
|
||||
input_enabled: boolean;
|
||||
/**
|
||||
* v21:TLS 证书指纹(sha256 hex)。
|
||||
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
|
||||
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
|
||||
*/
|
||||
cert_sha256?: string | null;
|
||||
platform: string;
|
||||
ts: number;
|
||||
}
|
||||
@@ -60,6 +67,7 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
|
||||
JSON.stringify({
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
...opts.base,
|
||||
ts: Date.now(),
|
||||
} satisfies BeaconPayload),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
import {
|
||||
Methods,
|
||||
PROTOCOL_VERSION,
|
||||
ipInAllowlist,
|
||||
generateNonce,
|
||||
verifyHmac,
|
||||
verifyNonce,
|
||||
@@ -432,7 +433,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -85,6 +85,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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 零信任)
|
||||
|
||||
@@ -94,6 +94,7 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
url: { type: 'string' },
|
||||
sha256: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'forget-cert': { type: 'boolean', default: false },
|
||||
check: { type: 'boolean', default: false },
|
||||
force: { type: 'boolean', default: false },
|
||||
mute: { type: 'string' },
|
||||
@@ -151,6 +152,7 @@ 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']),
|
||||
forgetCert: Boolean(values['forget-cert']),
|
||||
check: Boolean(values['check']),
|
||||
force: Boolean(values['force']),
|
||||
mute: values['mute'] as string | undefined,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Options {
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
vars?: string[];
|
||||
/** v21 证书指纹 */
|
||||
forgetCert?: boolean;
|
||||
/** v19 拉取式自更新 */
|
||||
url?: string;
|
||||
sha256?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 安全模型与加固指南
|
||||
|
||||
> 面向使用者与运维者。协议细节见 `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`,已做归一化
|
||||
|
||||
## 5. 还没做的(后续批次)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 发现报文最小化 | UDP 广播目前明文告知 IP/端口/认证方式/键鼠是否开启(可被伪造,仅作发现用) |
|
||||
| ed25519 默认化 | 新装仍默认 psk,ACL 的身份维度默认无效 |
|
||||
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
|
||||
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
|
||||
| 会话上限 | 无最大连接数与空闲断开 |
|
||||
| 安装期密钥传递 | `install.cmd` 用命令行 `-Key` 传参,同机其他用户 `ps` 可见 |
|
||||
|
||||
## 6. 应急处置
|
||||
|
||||
| 场景 | 处置 |
|
||||
|---|---|
|
||||
| 怀疑钥匙泄漏 | 换钥匙(被控端删 `agent.json` 的 `key` 后重启 → 首启重新生成并打印),或改用 ed25519 |
|
||||
| 证书被换 / 重装后连不上 | `nodeagent connect … --forget-cert` 重钉指纹 |
|
||||
| 怀疑被中间人 | 用 `install.ps1` 打印的指纹做带外核对;不一致立即断网排查 |
|
||||
| 机器要带去不可信网络 | 事先配 `allow_from`,或直接不接入陌生网络 |
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(同一控制端接入多台时可区分身份) */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/protocol",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"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:
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/** 协议错误对象。 */
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './ed25519.js';
|
||||
export * from './acl.js';
|
||||
export * from './validate.js';
|
||||
export * from './capabilities.js';
|
||||
export * from './net.js';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(':')}`;
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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-----`;
|
||||
Reference in new issue
Block a user