Files
nodeagent/apps/cli/src/core.ts
T
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

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

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

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

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

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

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

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

302 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
CAPABILITY_MANIFEST,
CapabilityNames,
capabilityDiff,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import { type Options } from './types.js';
// CLI 共享运行时(客户端/守护进程代理/输出工具)
export function getClientConfig(): ClientConfig {
const cfg = loadConfig();
if (!cfg) {
fail('尚未配置被控端。请先运行:\n nodeagent connect <host> --port 8765 --key <密钥>');
}
return cfg;
}
/** 本次命令的临时目标设备(来自全局 --node)。 */
// v16:全局 --node 的临时覆盖。跨模块共享可变状态:ESM 的 import 绑定只读,
// 故一律经访问器读写(直接 import 变量再赋值会编译不过)。
let currentNodeOverride: string | undefined;
/** 设置本次命令的 --node 覆盖(由入口在解析参数后调用)。 */
export function setNodeOverride(v: string | undefined): void {
currentNodeOverride = v;
}
/** 读取当前生效的 --node 覆盖。 */
export function getNodeOverride(): string | undefined {
return currentNodeOverride;
}
// ---------- v10 daemon 转发 ----------
export const DAEMON_SOCK = path.join(os.tmpdir(), 'nodeagentd.sock');
interface DaemonResponse {
status: 'ok' | 'failed' | 'daemon_error';
data?: unknown;
error?: { name: string; message: string } | string;
}
/** 经 daemon 转发一次能力调用(JSON lines over UDS)。 */
export function viaDaemon(node: string, capability: string, args: Record<string, unknown>, timeoutMs?: number): Promise<DaemonResponse> {
return new Promise((resolve, reject) => {
const s = net.connect(DAEMON_SOCK);
s.setTimeout(timeoutMs ?? 60_000);
let buf = '';
const failOnce = (err: Error): void => {
s.destroy();
reject(err);
};
s.once('error', failOnce);
s.once('timeout', () => failOnce(new Error('daemon 转发超时')));
s.once('connect', () => {
s.write(JSON.stringify({ node, capability, args, timeout_ms: timeoutMs }) + '\n');
});
s.on('data', (chunk) => {
buf += chunk.toString('utf8');
const idx = buf.indexOf('\n');
if (idx >= 0) {
try {
resolve(JSON.parse(buf.slice(0, idx)) as DaemonResponse);
} catch (err) {
failOnce(err instanceof Error ? err : new Error(String(err)));
return;
}
s.end();
}
});
});
}
/** 「虚拟客户端」:invoke 转发给 daemon,业务错误原样返回、daemon 自身错误抛异常触发回退。 */
export function daemonProxyClient(nodeName: string): { invoke: NodeAgentClient['invoke'] } {
return {
async invoke<T>(capability: string, args: Record<string, unknown>, timeoutMs?: number) {
const resp = await viaDaemon(nodeName, capability, args, timeoutMs);
if (resp.status === 'daemon_error') {
throw new Error(typeof resp.error === 'string' ? resp.error : resp.error?.message);
}
if (resp.status === 'failed') {
const e = resp.error;
return {
status: 'failed' as const,
error: typeof e === 'string' ? { name: 'E_EXECUTION_FAILED', message: e } : e,
} as never;
}
return { status: 'ok' as const, data: resp.data } as never;
},
};
}
export async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const node = nodeName ?? getNodeOverride() ?? cfg.current;
// v10 daemon 快路径:socket 存活则经 daemon 复用长连接(省 1~2s TLS+握手)
if (node && existsSync(DAEMON_SOCK)) {
try {
return await fn(daemonProxyClient(node) as unknown as NodeAgentClient);
} catch (err) {
// daemon 自身错误(转发失败/超时)→ 静默回退直连;业务错误原样抛出
if (!(err instanceof ClientError)) {
return withClientDirect(fn, nodeName);
}
throw err;
}
}
return withClientDirect(fn, nodeName);
}
export async function withClientDirect<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(profile),
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,
});
try {
await client.connect();
return await fn(client);
} catch (err) {
if (err instanceof ClientError) {
fail(`${err.name}: ${err.message}`);
}
throw err;
} finally {
client.close();
}
}
export function fail(msg: string): never {
console.error(`✗ ${msg}`);
process.exit(1);
}
export function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2));
}
/** 调用能力并处理 status === 'failed' 的业务失败。 */
export async function callAndPrint(
client: NodeAgentClient,
capability: string,
args: Record<string, unknown>,
json: boolean,
render: (data: unknown) => void,
timeoutMs?: number,
): Promise<void> {
const result = await client.invoke(capability, args, timeoutMs);
if (result.status === 'failed') {
fail(`${result.error?.name ?? 'E_EXECUTION_FAILED'}: ${result.error?.message ?? '执行失败'}`);
}
if (json) printJson(result.data);
else render(result.data);
}
export function humanSize(bytes: number): string {
if (!Number.isFinite(bytes)) return String(bytes);
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function riskIcon(risk: string): string {
return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢';
}
/**
* 探测一次被控端(用于「等待重启/上线」这类**需要反复重试**的场景)。
*
* ⚠️ 不能用 withClient:它在连接失败时会直接 `fail()` **退出进程**(见 withClientDirect),
* 于是重试循环第一次连不上就整个 CLI 退出了 —— 真机踩过:部署其实已成功,
* CLI 却报 E_NODE_OFFLINE,且看不到任何自定义提示。本函数**永不抛出**。
*/
export async function probeOnce(nodeName?: string): Promise<{
ok: boolean;
pid?: number;
buildHash?: string;
caps: number;
error?: string;
}> {
try {
const cfg = getClientConfig();
const target = resolveTarget(cfg, nodeName ?? getNodeOverride());
const keys = target.profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(target.profile),
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,
});
await client.connect();
const r = await client.invoke<{ pid?: number; build?: { hash?: string } }>(CapabilityNames.SystemInfo, {});
const caps = client.listCapabilities().length;
client.close();
const d = r.data ?? {};
return { ok: r.status === 'ok', pid: d.pid, buildHash: d.build?.hash, caps };
} catch (err) {
return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* 新旧版本兼容提示(v20)。
*
* 背景:agent 与 CLI 是**分开更新**的(CLI 随 Mac 装,agent 推/拉到被控端),
* 必然出现新旧混用。等到调用时才抛 E_CAPABILITY_NOT_FOUND 会让人摸不着头脑,
* 所以在握手后就明确告诉用户「谁旧、缺什么、怎么升」。
*
* 兼容性依据(见 docs/VERSIONING.md):**只增不改** ——
* 新 agent 向后兼容旧 CLI(新增能力/可选参数),因此升级顺序建议先被控端。
*/
export function reportCompat(client: NodeAgentClient): void {
const remoteNames = client.listCapabilities().map((c) => c.name);
const localNames = CAPABILITY_MANIFEST.map((c) => c.name);
const diff = capabilityDiff(localNames, remoteNames);
const v = client.getAgentVersion();
const b = client.getAgentBuild();
if (v) {
console.log(`被控端版本 : v${v}${b?.commit ? ` @ ${b.commit}` : ''}${b?.hash ? ` 构建 ${b.hash}` : ''}`);
} else {
console.log('被控端版本 : 未上报(多为 v20 之前的版本,建议更新一次)');
}
if (diff.missingOnRemote.length > 0) {
console.warn(`⚠️ 被控端缺少 ${diff.missingOnRemote.length} 项本控制端已知的能力 —— 远端较旧:`);
console.warn(` ${diff.missingOnRemote.join(', ')}`);
console.warn(' → 建议更新被控端:nodeagent update --url <包地址> --sha256 <哈希>');
}
if (diff.unknownLocally.length > 0) {
console.warn(`ℹ️ 被控端还有 ${diff.unknownLocally.length} 项本控制端不认识的能力 —— 本 CLI 较旧:`);
console.warn(` ${diff.unknownLocally.join(', ')}`);
console.warn(' → 不影响既有功能;要用到这些新能力需更新控制端(git pull 后重跑 pack:mac + install-macos.sh)');
}
if (diff.missingOnRemote.length === 0 && diff.unknownLocally.length === 0) {
console.log(`能力对齐 : ${localNames.length} 项,两端一致 ✓`);
}
}