Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c650244bf | ||
|
|
a56d67c012 | ||
|
|
4106ada876 | ||
|
|
f88738d36d | ||
|
|
50ac4b9646 | ||
|
|
e841fbb187 | ||
|
|
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.4.2",
|
||||
"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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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} 心跳广播)` : '已关闭'}`,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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`,或直接不接入陌生网络 |
|
||||
@@ -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.4.2",
|
||||
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.2",
|
||||
"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,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,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:
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+5
-3
@@ -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' },
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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