接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。
## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效
**问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认),
一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
**做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。
比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。
**实测**(五场景全对):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ |
| **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 |
| bob 用自己的 key | ✅ |
| 未登记 carol 用共享 key | ✅(向后兼容) |
| 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) |
配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段;
鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。
## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏
**问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。
**做法**:
- `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`,
真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**)
- 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`),
控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**;
未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」)
- 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛
**实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓
## 验证
- 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败)
- e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为
- 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉
## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||
import fsSync from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path, { join } from 'node:path';
|
||
import {
|
||
NodeAgentClient,
|
||
loadMacroFile,
|
||
runMacro,
|
||
ClientError,
|
||
loadConfig,
|
||
saveConfig,
|
||
configPath,
|
||
toWsUrl,
|
||
loadKeys,
|
||
createKeys,
|
||
keysFilePath,
|
||
discoverOnce,
|
||
resolveTarget,
|
||
resolveNodeSelector,
|
||
emptyConfig,
|
||
type ClientConfig,
|
||
type ResolvedTarget,
|
||
type NodeProfile,
|
||
} from '@nodeagent/client';
|
||
import {
|
||
CapabilityNames,
|
||
DEFAULT_DISCOVERY_PORT,
|
||
generateSharedKey,
|
||
matchPattern,
|
||
type CapabilityDescriptor,
|
||
type InvokeResult,
|
||
} from '@nodeagent/protocol';
|
||
import {
|
||
callAndPrint,
|
||
reportCompat,
|
||
fail,
|
||
getClientConfig,
|
||
humanSize,
|
||
printJson,
|
||
riskIcon,
|
||
withClient,
|
||
withClientDirect,
|
||
} from '../core.js';
|
||
import { CHUNK_BYTES, type Options } from '../types.js';
|
||
|
||
// CLI 命令组:cmd/conn.ts
|
||
export async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||
const port = Number(opts.port ?? 8765);
|
||
const authMode: 'psk' | 'ed25519' = opts.authMode === 'ed25519' ? 'ed25519' : 'psk';
|
||
if (authMode === 'psk' && !opts.key) {
|
||
fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)');
|
||
}
|
||
|
||
const name = opts.name ?? host;
|
||
const clientId = opts.id ?? 'mac_01';
|
||
const profile: NodeProfile = {
|
||
host,
|
||
port,
|
||
tls: !process.argv.includes('--no-tls'),
|
||
insecure: opts.insecure,
|
||
key: opts.key ?? '',
|
||
auth_mode: authMode,
|
||
};
|
||
if (opts.note) profile.note = opts.note;
|
||
// v6:经 Hub 中转
|
||
if (opts.hubToken) profile.hub = { token: opts.hubToken, node_id: opts.hubNode ?? name };
|
||
|
||
const keys = authMode === 'ed25519' ? loadKeys() : null;
|
||
if (authMode === 'ed25519' && !keys) {
|
||
fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`);
|
||
}
|
||
|
||
const client = new NodeAgentClient({
|
||
url: toWsUrl(profile),
|
||
key: profile.key ?? '',
|
||
clientId,
|
||
insecure: profile.insecure,
|
||
authMode,
|
||
privateKey: keys?.privateKey,
|
||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||
});
|
||
try {
|
||
const caps = await client.connect();
|
||
const authorized = client.listAuthorized();
|
||
|
||
// 连接成功才落盘,避免把错配置写进设备表
|
||
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;
|
||
saveConfig(cfg);
|
||
|
||
const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`;
|
||
console.log(
|
||
`✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
|
||
);
|
||
console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`);
|
||
|
||
const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n));
|
||
console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`);
|
||
for (const c of caps) {
|
||
console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`);
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof ClientError) fail(`连接失败 ${err.name}: ${err.message}`);
|
||
throw err;
|
||
} finally {
|
||
client.close();
|
||
}
|
||
}
|
||
|
||
// ---------- v5 多设备管理 ----------
|
||
|
||
export async function cmdNodes(opts: Options): Promise<void> {
|
||
const cfg = loadConfig();
|
||
if (!cfg || Object.keys(cfg.nodes).length === 0) {
|
||
console.log('尚未配置任何设备。用 nodeagent connect <host> --key <密钥> 添加,或 nodeagent discover 先发现。');
|
||
return;
|
||
}
|
||
const names = Object.keys(cfg.nodes);
|
||
if (opts.json) {
|
||
return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes });
|
||
}
|
||
console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`);
|
||
console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`);
|
||
for (const n of names) {
|
||
const p = cfg.nodes[n]!;
|
||
const mark = n === cfg.current ? '●' : ' ';
|
||
const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`;
|
||
console.log(
|
||
`${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`,
|
||
);
|
||
}
|
||
console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)');
|
||
}
|
||
|
||
export async function cmdUse(name: string | undefined, opts: Options): Promise<void> {
|
||
if (!name) fail('用法: nodeagent use <设备名>');
|
||
const cfg = loadConfig();
|
||
if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect <host> --key <密钥>');
|
||
if (!cfg.nodes[name]) {
|
||
fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`);
|
||
}
|
||
cfg.current = name;
|
||
saveConfig(cfg);
|
||
if (opts.json) return printJson({ current: cfg.current });
|
||
const p = cfg.nodes[name]!;
|
||
console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`);
|
||
}
|
||
|
||
export async function cmdRemove(name: string | undefined, opts: Options): Promise<void> {
|
||
if (!name) fail('用法: nodeagent remove <设备名>');
|
||
const cfg = loadConfig();
|
||
if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`);
|
||
delete cfg.nodes[name];
|
||
if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? '';
|
||
saveConfig(cfg);
|
||
if (opts.json) return printJson({ removed: name, current: cfg.current });
|
||
console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`);
|
||
}
|
||
|
||
/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */
|
||
|
||
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}`);
|
||
console.log(` key_id : ${keys.key_id}`);
|
||
console.log(` 私钥 : ${keysFilePath()} (600 权限,永不外传)`);
|
||
console.log('\n请把下面这段加入被控端 agent.json 的 acl.clients 数组:\n');
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
client_id: keys.client_id,
|
||
pubkey: keys.publicKey,
|
||
allow: ['system.*', 'app.list', 'screen.*'],
|
||
deny: ['input.*'],
|
||
note: 'generated by nodeagent keygen',
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
console.log('\n然后让被控端启用零信任模式:agent.json 设 "auth_mode": "ed25519" 并重启 Agent。');
|
||
}
|
||
|
||
export async function cmdDiscover(opts: Options): Promise<void> {
|
||
const waitMs = (opts.wait ? Number(opts.wait) : 5) * 1000;
|
||
const port = opts.discoveryPort ? Number(opts.discoveryPort) : DEFAULT_DISCOVERY_PORT;
|
||
|
||
console.log(`正在监听局域网广播(UDP ${port},最多 ${waitMs / 1000}s)...\n`);
|
||
|
||
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
|
||
try {
|
||
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
|
||
} catch (err) {
|
||
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
|
||
if (nodes.length === 0) {
|
||
console.log('未发现设备。请检查:');
|
||
console.log(' 1) 被控端与本机在同一局域网(跨网段广播通常不通)');
|
||
console.log(' 2) 被控端配置未关闭 discovery.enabled');
|
||
console.log(` 3) 防火墙未拦截 UDP ${port}`);
|
||
return;
|
||
}
|
||
|
||
if (opts.json) return printJson(nodes);
|
||
|
||
console.log(`发现 ${nodes.length} 台被控端:\n`);
|
||
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)} ${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]!;
|
||
console.log('\n连接示例:');
|
||
console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`);
|
||
}
|
||
|
||
// ---------- v5 文件传输 ----------
|
||
|
||
/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */
|