Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ac4b9646 | ||
|
|
e841fbb187 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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:是否允许输入控制(鼠标 / 键盘)。
|
||||
@@ -53,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:文件访问白名单(根目录列表)。
|
||||
|
||||
+69
-19
@@ -1,5 +1,5 @@
|
||||
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 };
|
||||
@@ -10,20 +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;
|
||||
input_enabled?: boolean;
|
||||
/**
|
||||
* v21:TLS 证书指纹(sha256 hex)。
|
||||
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
|
||||
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
|
||||
*/
|
||||
cert_sha256?: string | null;
|
||||
platform: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,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 {
|
||||
@@ -63,16 +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,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
...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} 心跳广播)` : '已关闭'}`,
|
||||
|
||||
@@ -132,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);
|
||||
@@ -156,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; // 一次性,用后即废
|
||||
@@ -172,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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"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';
|
||||
@@ -176,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}`);
|
||||
@@ -206,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)}`);
|
||||
}
|
||||
@@ -222,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]!;
|
||||
|
||||
@@ -94,6 +94,8 @@ 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 },
|
||||
@@ -152,6 +154,8 @@ 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']),
|
||||
|
||||
@@ -55,6 +55,10 @@ export interface Options {
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
vars?: string[];
|
||||
/** v22 发现广播认证密钥 */
|
||||
discoverSecret?: string;
|
||||
/** v22 每客户端 PSK */
|
||||
psk?: boolean;
|
||||
/** v21 证书指纹 */
|
||||
forgetCert?: boolean;
|
||||
/** v19 拉取式自更新 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+46
-2
@@ -72,12 +72,56 @@ nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert
|
||||
- 判定按**前缀比特**而非字符串相等 —— 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. 还没做的(后续批次)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 发现报文最小化 | UDP 广播目前明文告知 IP/端口/认证方式/键鼠是否开启(可被伪造,仅作发现用) |
|
||||
| ed25519 默认化 | 新装仍默认 psk,ACL 的身份维度默认无效 |
|
||||
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
|
||||
| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) |
|
||||
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
|
||||
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
|
||||
| 会话上限 | 无最大连接数与空闲断开 |
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user