feat(v6): Hub 中转 —— 跨网段 / 公网接入
新增 apps/hub(中转节点): - 被控端主动外连注册(/hub/agent),控制端接入配对(/hub/client) - 配对后双向字节透传 —— Hub 不解析内容、不持有设备密钥 - Hub 令牌鉴权;list 可查在线设备 - 连接语义:控制端断开仅解绑(被控端保持在线);被控端掉线连带关闭控制端 - 同一被控端同一时刻仅服务一个控制端(占用返回 E_NODE_BUSY) 被控端: - 新增 hub 配置;启用后不监听本地端口,改为主动外连 Hub(穿 NAT)+ 指数退避重连 - 重构抽出 createAgentCore(),监听模式与 Hub 模式共用同一套鉴权/ACL/能力分发 控制端: - NodeProfile 新增 hub 字段;toWsUrl 自动附 /hub/client 路径 - client 支持 Hub 配对(控制面消息与 RPC 分离) 修复(Hub 场景暴露): - cmdConnect 漏传 hub 字段,导致跳过配对直接发 RPC - Hub unpair 过激进:控制端断开时把被控端也踢下线 - 安全:被控端 hello 时重置认证态与授权(Hub 下同一连接被多控制端复用) 验证:本地 E2E 21/21;构建 + 类型检查全绿;冒烟确认 info/exec 穿透 Hub 且多次接入保持在线
This commit is contained in:
1 parent
4ff1b4fd90
commit
3d03945448
17 files changed
+813
-22
No files matched your search
@@ -184,9 +184,35 @@ nodeagent/
|
||||
| **v3+** | **审计日志**(谁在何时调了什么 + 查询能力)+ **限速** | ✅ 已实现 |
|
||||
| **v4** | 无感体验:**局域网自动发现** + **断线自动重连** | ✅ 已实现 |
|
||||
| **v5** | **多设备管理** + **文件传输**(分块 / 大文件 / 原子写) | ✅ 已实现 |
|
||||
| **v6** | **Hub 中转**:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ 已实现 |
|
||||
|
||||
> **输入控制安全默认**:`input.*` 为高危能力,**默认禁用**。需在被控端 `agent.json` 设 `"allow_input": true` 重启后生效,或用 `install.ps1 -AllowInput` 安装。
|
||||
|
||||
## Hub 中转(v6,跨网段 / 公网)
|
||||
|
||||
被控端与 Mac 不在同一局域网时,用 Hub 中转:
|
||||
|
||||
```
|
||||
Mac ──► Hub(VPS / 公网)──► Windows(NAT 后也可)
|
||||
↑ 只透传,不解密
|
||||
```
|
||||
|
||||
```bash
|
||||
# 1) 在 VPS 上启动 Hub
|
||||
node apps/hub/dist/index.js
|
||||
# 输出 Hub 令牌(首次启动自动生成,存于 ~/.nodeagent/hub.json)
|
||||
|
||||
# 2) 被控端 agent.json 增加:
|
||||
# "hub": { "enabled": true, "url": "ws://<VPS>:9443/hub/agent", "token": "<Hub 令牌>" }
|
||||
# 启动后 agent 会主动外连 Hub 注册 —— 无需公网 IP、无需开放入站端口
|
||||
|
||||
# 3) Mac 侧经 Hub 接入
|
||||
node apps/cli/dist/index.js connect <VPS> --port 9443 --hub-token <Hub 令牌> \
|
||||
--hub-node win_01 --key <设备密钥> --name win_01
|
||||
```
|
||||
|
||||
**安全模型**:Hub **只做字节透传**,不解析内容、不持有设备密钥 —— 控制端与被控端之间仍执行 Ed25519/PSK 端到端握手,因此 **Hub 无法窃听也无法伪造**。生产环境建议在 Hub 前挂 Caddy/Nginx 终止 TLS。
|
||||
|
||||
## 关键决策速览
|
||||
|
||||
| 决策点 | 结论 |
|
||||
|
||||
@@ -52,6 +52,19 @@ export interface AgentConfig {
|
||||
* 非空 = 只允许读写这些目录之下的路径。
|
||||
*/
|
||||
fs_roots?: string[];
|
||||
/**
|
||||
* v6:Hub 模式。启用后**不再监听本地端口**,改为主动外连 Hub 注册,
|
||||
* 适用于被控端位于 NAT / 无公网 IP 的环境。
|
||||
*/
|
||||
hub?: {
|
||||
enabled: boolean;
|
||||
/** Hub 地址,例如 wss://hub.example.com/hub/agent */
|
||||
url: string;
|
||||
/** Hub 令牌 */
|
||||
token: string;
|
||||
/** 跳过 Hub 证书校验(自签证书场景) */
|
||||
insecure?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据目录:优先 NODEAGENT_HOME(跨平台一致),否则 ~/.nodeagent。 */
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { WebSocket } from 'ws';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import { createAgentCore } from './server.js';
|
||||
|
||||
export interface HubClientOptions {
|
||||
/** 例如 wss://hub.example.com/hub/agent */
|
||||
url: string;
|
||||
token: string;
|
||||
/** 跳过 Hub 证书校验(自签证书场景) */
|
||||
insecure?: boolean;
|
||||
/** 重连基础退避(默认 1s,指数增长至 30s) */
|
||||
reconnectBaseMs?: number;
|
||||
}
|
||||
|
||||
export interface HubClient {
|
||||
isRegistered(): boolean;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 被控端 Hub 模式:主动外连 Hub 注册,随后在该连接上提供 RPC 服务。
|
||||
* 出站连接使其可工作于 NAT / 无公网 IP 的环境。
|
||||
*
|
||||
* 注册成功后 Hub 不再解析内容 —— 该连接上跑的就是与控制端的端到端 RPC。
|
||||
*/
|
||||
export function startHubClient(
|
||||
cfg: AgentConfig,
|
||||
opts: HubClientOptions,
|
||||
log: (level: 'info' | 'warn', msg: string) => void,
|
||||
): HubClient {
|
||||
const core = createAgentCore(cfg);
|
||||
const baseMs = opts.reconnectBaseMs ?? 1000;
|
||||
|
||||
let sock: WebSocket | null = null;
|
||||
let registered = false;
|
||||
let stopped = false;
|
||||
let attempts = 0;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const scheduleReconnect = (reason: string): void => {
|
||||
registered = false;
|
||||
if (stopped) return;
|
||||
const delay = Math.min(baseMs * 2 ** attempts, 30_000);
|
||||
attempts += 1;
|
||||
log('warn', `${reason},${delay}ms 后重连 Hub(第 ${attempts} 次)`);
|
||||
timer = setTimeout(connect, delay);
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
if (stopped) return;
|
||||
log('info', `正在连接 Hub ${opts.url}`);
|
||||
const s = new WebSocket(opts.url, { rejectUnauthorized: opts.insecure !== true });
|
||||
sock = s;
|
||||
|
||||
// 注册阶段的控制面处理;注册成功后即卸载,交由 core 接管 RPC
|
||||
const onHubMessage = (raw: { toString(): string }): void => {
|
||||
let msg: { type?: string; message?: string };
|
||||
try {
|
||||
msg = JSON.parse(raw.toString()) as { type?: string; message?: string };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'registered') {
|
||||
registered = true;
|
||||
attempts = 0;
|
||||
log('info', `已注册到 Hub(node_id=${cfg.node_id}),进入服务状态`);
|
||||
s.off('message', onHubMessage);
|
||||
core.attach(s, 'hub');
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
log('warn', `Hub 拒绝: ${msg.message ?? ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
s.on('open', () => {
|
||||
s.send(
|
||||
JSON.stringify({
|
||||
type: 'register',
|
||||
node_id: cfg.node_id,
|
||||
token: opts.token,
|
||||
meta: { platform: process.platform, auth_mode: cfg.auth_mode ?? 'psk' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
s.on('message', onHubMessage);
|
||||
s.on('close', () => scheduleReconnect('Hub 连接已关闭'));
|
||||
s.on('error', (err: Error) => log('warn', `Hub 连接错误: ${err.message}`));
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
isRegistered: () => registered,
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
try {
|
||||
sock?.close();
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+24
-5
@@ -5,6 +5,7 @@ import { ensureCert } from './certs.js';
|
||||
import { createAgentServer } from './server.js';
|
||||
import { initAudit, audit, auditFilePath } from './audit.js';
|
||||
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
|
||||
import { startHubClient, type HubClient } from './hub-mode.js';
|
||||
|
||||
function listLocalIps(): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -24,8 +25,19 @@ async function main(): Promise<void> {
|
||||
maxFiles: config.audit?.max_files,
|
||||
logArgs: config.audit?.log_args,
|
||||
});
|
||||
const tls = config.tls ? ensureCert() : null;
|
||||
const server = await createAgentServer(config, tls);
|
||||
// v6:Hub 模式下不监听本地端口,改为主动外连 Hub(穿 NAT)
|
||||
const useHub = config.hub?.enabled === true;
|
||||
const tls = !useHub && config.tls ? ensureCert() : null;
|
||||
const server = useHub ? null : await createAgentServer(config, tls);
|
||||
|
||||
let hubClient: HubClient | null = null;
|
||||
if (useHub && config.hub) {
|
||||
hubClient = startHubClient(
|
||||
config,
|
||||
{ url: config.hub.url, token: config.hub.token, insecure: config.hub.insecure },
|
||||
(level, msg) => console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`),
|
||||
);
|
||||
}
|
||||
|
||||
const auditMode = config.auth_mode ?? 'psk';
|
||||
audit({
|
||||
@@ -38,7 +50,7 @@ async function main(): Promise<void> {
|
||||
// v4:局域网心跳广播(报文不含任何凭据)
|
||||
const beacon: Beacon | null = startBeacon(
|
||||
{
|
||||
enabled: config.discovery?.enabled !== false,
|
||||
enabled: !useHub && config.discovery?.enabled !== false,
|
||||
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
|
||||
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
|
||||
intervalMs: config.discovery?.interval_ms ?? 5000,
|
||||
@@ -58,7 +70,13 @@ async function main(): Promise<void> {
|
||||
console.log(' nodeagent 被控端已启动');
|
||||
console.log(' ─────────────────────────────────────────────');
|
||||
console.log(` 节点 ID : ${config.node_id}`);
|
||||
console.log(` 监听地址 : ${config.host}:${config.port} (${config.tls ? 'wss / TLS' : 'ws / 明文'})`);
|
||||
console.log(
|
||||
` 接入方式 : ${
|
||||
useHub
|
||||
? `Hub 中转 → ${config.hub?.url ?? ''}`
|
||||
: `本地监听 ${config.host}:${config.port} (${config.tls ? 'wss / TLS' : 'ws / 明文'})`
|
||||
}`,
|
||||
);
|
||||
console.log(` 本机 IP : ${ips.join(', ') || '未检测到'}`);
|
||||
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
|
||||
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
|
||||
@@ -80,8 +98,9 @@ async function main(): Promise<void> {
|
||||
const shutdown = async (): Promise<void> => {
|
||||
console.log('\n正在关闭...');
|
||||
beacon?.stop();
|
||||
hubClient?.stop();
|
||||
audit({ type: 'agent.stop', reason: 'signal' });
|
||||
await server.close();
|
||||
if (server) await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => void shutdown());
|
||||
|
||||
+35
-11
@@ -48,7 +48,13 @@ export interface AgentServer {
|
||||
type LogLevel = 'debug' | 'info' | 'warn';
|
||||
|
||||
/** 创建并启动被控端 WebSocket 服务。 */
|
||||
export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Promise<AgentServer> {
|
||||
/** 连接级 RPC 处理 —— 监听模式与 Hub 模式共用同一套鉴权 / ACL / 能力分发。 */
|
||||
export interface AgentCore {
|
||||
/** 在给定连接上提供被控端服务(连接已建立)。 */
|
||||
attach(ws: WebSocket, remote: string): void;
|
||||
}
|
||||
|
||||
export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const capabilityRegistry = createCapabilityRegistry(cfg);
|
||||
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
|
||||
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
|
||||
@@ -75,13 +81,6 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
}
|
||||
};
|
||||
|
||||
// TLS 必须由 https server 承载:ws 的 WebSocketServer 不识别 cert/key 选项,
|
||||
// 直接传入会被静默忽略,导致「自称 wss、实为明文」的降级。
|
||||
const httpServer = tls
|
||||
? createHttpsServer({ cert: tls.cert, key: tls.key })
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
const states = new WeakMap<WebSocket, ConnState>();
|
||||
|
||||
const send = (ws: WebSocket, msg: unknown): void => {
|
||||
@@ -102,6 +101,10 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
state.nonce = generateNonce();
|
||||
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
|
||||
state.clientId = params?.client_id ?? null;
|
||||
// 新一轮握手开始:清除上一轮的认证态与授权
|
||||
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
|
||||
state.authenticated = false;
|
||||
state.authorized = null;
|
||||
sendResult(ws, req.id, { nonce: state.nonce, expires_at: state.nonceExpiresAt });
|
||||
}
|
||||
|
||||
@@ -318,8 +321,7 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
}
|
||||
}
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const remote = req.socket.remoteAddress ?? '?';
|
||||
function attachConnection(ws: WebSocket, remote: string): void {
|
||||
log('info', `新连接: ${remote}`);
|
||||
states.set(ws, {
|
||||
authenticated: false,
|
||||
@@ -334,7 +336,29 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
});
|
||||
ws.on('close', () => log('info', `连接关闭: ${remote}`));
|
||||
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
return { attach: attachConnection };
|
||||
}
|
||||
|
||||
/** 监听模式:被控端在本地起 HTTP(S) + WebSocket 服务。 */
|
||||
export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Promise<AgentServer> {
|
||||
const core = createAgentCore(cfg);
|
||||
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
|
||||
const log = (level: LogLevel, msg: string): void => {
|
||||
if (levelOrder[level] >= levelOrder[cfg.log_level]) {
|
||||
console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// TLS 必须由 https server 承载:ws 的 WebSocketServer 不识别 cert/key 选项,
|
||||
// 直接传入会被静默忽略,导致「自称 wss、实为明文」的降级。
|
||||
const httpServer = tls
|
||||
? createHttpsServer({ cert: tls.cert, key: tls.key })
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
wss.on('connection', (ws, req) => core.attach(ws, req.socket.remoteAddress ?? '?'));
|
||||
|
||||
// WebSocket 层保活:30s ping,探测死连接
|
||||
const heartbeat = setInterval(() => {
|
||||
|
||||
+13
-1
@@ -107,6 +107,9 @@ interface Options {
|
||||
recursive?: boolean;
|
||||
pattern?: string;
|
||||
createDirs?: boolean;
|
||||
/** v6 Hub */
|
||||
hubToken?: string;
|
||||
hubNode?: string;
|
||||
}
|
||||
|
||||
function getClientConfig(): ClientConfig {
|
||||
@@ -137,6 +140,7 @@ async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeNa
|
||||
insecure: profile.insecure,
|
||||
authMode: profile.auth_mode,
|
||||
privateKey: keys?.privateKey,
|
||||
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -206,6 +210,8 @@ async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||||
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) {
|
||||
@@ -219,6 +225,7 @@ async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||||
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();
|
||||
@@ -231,8 +238,9 @@ async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||||
cfg.current = name;
|
||||
saveConfig(cfg);
|
||||
|
||||
const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`;
|
||||
console.log(
|
||||
`✓ 已连接并保存设备「${name}」${profile.host}:${profile.port}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
|
||||
`✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
|
||||
);
|
||||
console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`);
|
||||
|
||||
@@ -802,6 +810,8 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
|
||||
recursive: { type: 'boolean', default: false },
|
||||
pattern: { type: 'string' },
|
||||
'create-dirs': { type: 'boolean', default: false },
|
||||
'hub-token': { type: 'string' },
|
||||
'hub-node': { type: 'string' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
@@ -834,6 +844,8 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
|
||||
recursive: Boolean(values['recursive']),
|
||||
pattern: values['pattern'] as string | undefined,
|
||||
createDirs: Boolean(values['create-dirs']),
|
||||
hubToken: values['hub-token'] as string | undefined,
|
||||
hubNode: values['hub-node'] as string | undefined,
|
||||
},
|
||||
positionals,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"bin": {
|
||||
"nodeagent-hub": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodeagent/protocol": "workspace:*",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export interface HubConfig {
|
||||
node_id: string;
|
||||
host: string;
|
||||
port: number;
|
||||
/** 鉴权令牌:被控端注册、控制端接入均需携带 */
|
||||
token: string;
|
||||
/**
|
||||
* 可选 TLS。生产环境通常由反向代理(Caddy/Nginx)终止 TLS,
|
||||
* 故此项默认关闭;如需 Hub 直接提供 TLS,给出 PEM 文件路径即可。
|
||||
*/
|
||||
tls?: { cert_file: string; key_file: string };
|
||||
log_level: 'debug' | 'info' | 'warn';
|
||||
}
|
||||
|
||||
export function hubDir(): string {
|
||||
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
|
||||
}
|
||||
|
||||
export function hubConfigPath(): string {
|
||||
return join(hubDir(), 'hub.json');
|
||||
}
|
||||
|
||||
export function defaultHubConfig(): HubConfig {
|
||||
return {
|
||||
node_id: 'hub_01',
|
||||
host: '0.0.0.0',
|
||||
port: 9443,
|
||||
token: randomBytes(24).toString('hex'),
|
||||
log_level: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
export function loadHubConfig(): { config: HubConfig; isNew: boolean } {
|
||||
const p = hubConfigPath();
|
||||
if (existsSync(p)) {
|
||||
return { config: JSON.parse(readFileSync(p, 'utf8')) as HubConfig, isNew: false };
|
||||
}
|
||||
const config = defaultHubConfig();
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
writeFileSync(p, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
||||
return { config, isNew: true };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer as createHttpServer, type IncomingMessage } from 'node:http';
|
||||
import { createServer as createHttpsServer } from 'node:https';
|
||||
import { WebSocket, WebSocketServer, type RawData } from 'ws';
|
||||
import type { HubConfig } from './config.js';
|
||||
import type {
|
||||
HubClientConnect,
|
||||
HubError,
|
||||
HubInbound,
|
||||
HubListRequest,
|
||||
HubOutbound,
|
||||
HubPaired,
|
||||
HubRegistered,
|
||||
} from './types.js';
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn';
|
||||
|
||||
export interface AgentView {
|
||||
node_id: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
connected_at: number;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
interface AgentEntry {
|
||||
node_id: string;
|
||||
ws: WebSocket;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
connected_at: number;
|
||||
/** 当前配对的客户端;同一时刻仅允许一个,避免多路复用复杂度 */
|
||||
paired: WebSocket | null;
|
||||
}
|
||||
|
||||
export interface HubServer {
|
||||
url: string;
|
||||
agents(): AgentView[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createHubServer(cfg: HubConfig): Promise<HubServer> {
|
||||
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
|
||||
const log = (level: LogLevel, msg: string): void => {
|
||||
if (levelOrder[level] >= levelOrder[cfg.log_level]) {
|
||||
console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const agents = new Map<string, AgentEntry>();
|
||||
|
||||
const httpServer = cfg.tls
|
||||
? createHttpsServer({ cert: readFileSync(cfg.tls.cert_file), key: readFileSync(cfg.tls.key_file) })
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
const send = (ws: WebSocket, msg: HubOutbound): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
};
|
||||
const fail = (ws: WebSocket, code: HubError['code'], message: string): void => {
|
||||
send(ws, { type: 'error', code, message });
|
||||
setTimeout(() => ws.close(), 50);
|
||||
};
|
||||
|
||||
/** 配对后双向透传;Hub 不再解析内容,端到端安全由双方自身的握手保证。 */
|
||||
function pair(clientWs: WebSocket, entry: AgentEntry): void {
|
||||
const agentWs = entry.ws;
|
||||
entry.paired = clientWs;
|
||||
|
||||
const onClientMsg = (data: RawData, isBinary: boolean): void => {
|
||||
if (agentWs.readyState === WebSocket.OPEN) agentWs.send(data, { binary: isBinary });
|
||||
};
|
||||
const onAgentMsg = (data: RawData, isBinary: boolean): void => {
|
||||
if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data, { binary: isBinary });
|
||||
};
|
||||
const detach = (): void => {
|
||||
clientWs.off('message', onClientMsg);
|
||||
agentWs.off('message', onAgentMsg);
|
||||
if (entry.paired === clientWs) entry.paired = null;
|
||||
};
|
||||
|
||||
clientWs.removeAllListeners('message');
|
||||
agentWs.removeAllListeners('message');
|
||||
clientWs.on('message', onClientMsg);
|
||||
agentWs.on('message', onAgentMsg);
|
||||
|
||||
// 控制端断开:仅解绑,被控端保持在线等待下一个控制端
|
||||
clientWs.once('close', detach);
|
||||
// 被控端掉线:连带关闭控制端,让控制端及时感知
|
||||
agentWs.once('close', () => {
|
||||
detach();
|
||||
if (clientWs.readyState === WebSocket.OPEN) clientWs.close();
|
||||
});
|
||||
|
||||
send(clientWs, { type: 'paired', node_id: entry.node_id } satisfies HubPaired);
|
||||
log('info', `配对成功 ${entry.node_id} ↔ 控制端`);
|
||||
}
|
||||
|
||||
/** 解析 + 令牌校验;失败时已回错误消息并关闭连接。 */
|
||||
function readControl(ws: WebSocket, raw: string, remote: string): HubInbound | null {
|
||||
let msg: HubInbound;
|
||||
try {
|
||||
msg = JSON.parse(raw) as HubInbound;
|
||||
} catch {
|
||||
fail(ws, 'E_BAD_REQUEST', '消息不是合法 JSON');
|
||||
return null;
|
||||
}
|
||||
if (msg.token !== cfg.token) {
|
||||
log('warn', `鉴权失败(令牌不匹配)from ${remote}`);
|
||||
fail(ws, 'E_HUB_AUTH', 'Hub 令牌错误');
|
||||
return null;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ---------------- 被控端接入 ----------------
|
||||
|
||||
function handleAgentSocket(ws: WebSocket, remote: string): void {
|
||||
let registeredId: string | null = null;
|
||||
|
||||
const onMessage = (data: RawData): void => {
|
||||
if (registeredId) return; // 注册后不再受理控制面消息
|
||||
const msg = readControl(ws, data.toString(), remote);
|
||||
if (!msg) return;
|
||||
if (msg.type !== 'register') {
|
||||
fail(ws, 'E_BAD_REQUEST', `被控端不应发送 ${msg.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = msg.node_id;
|
||||
const existing = agents.get(nodeId);
|
||||
if (existing && existing.ws.readyState === WebSocket.OPEN) {
|
||||
log('warn', `节点 ${nodeId} 重复注册,踢掉旧连接`);
|
||||
existing.ws.close();
|
||||
}
|
||||
|
||||
const entry: AgentEntry = {
|
||||
node_id: nodeId,
|
||||
ws,
|
||||
platform: msg.meta?.platform,
|
||||
version: msg.meta?.version,
|
||||
connected_at: Date.now(),
|
||||
paired: null,
|
||||
};
|
||||
agents.set(nodeId, entry);
|
||||
registeredId = nodeId;
|
||||
send(ws, { type: 'registered', node_id: nodeId } satisfies HubRegistered);
|
||||
log('info', `被控端已注册 ${nodeId} from ${remote}(platform=${msg.meta?.platform ?? '?'})`);
|
||||
};
|
||||
|
||||
ws.on('message', onMessage);
|
||||
ws.once('close', () => {
|
||||
if (registeredId && agents.get(registeredId)?.ws === ws) {
|
||||
agents.delete(registeredId);
|
||||
log('info', `被控端已离线 ${registeredId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 控制端接入 ----------------
|
||||
|
||||
function handleClientSocket(ws: WebSocket, remote: string): void {
|
||||
const onMessage = (data: RawData): void => {
|
||||
const msg = readControl(ws, data.toString(), remote);
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === 'list') {
|
||||
send(ws, {
|
||||
type: 'list.result',
|
||||
agents: [...agents.values()].map((a) => ({
|
||||
node_id: a.node_id,
|
||||
platform: a.platform,
|
||||
connected_at: a.connected_at,
|
||||
paired: a.paired !== null,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== 'connect') {
|
||||
fail(ws, 'E_BAD_REQUEST', `控制端不应发送 ${msg.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = agents.get((msg as HubClientConnect).node_id);
|
||||
if (!entry || entry.ws.readyState !== WebSocket.OPEN) {
|
||||
fail(ws, 'E_NODE_OFFLINE', `设备离线: ${msg.node_id}`);
|
||||
return;
|
||||
}
|
||||
if (entry.paired) {
|
||||
fail(ws, 'E_NODE_BUSY', `设备正被其他控制端占用: ${msg.node_id}`);
|
||||
return;
|
||||
}
|
||||
log('info', `控制端请求接入 ${msg.node_id} from ${remote}`);
|
||||
pair(ws, entry);
|
||||
};
|
||||
|
||||
ws.on('message', onMessage);
|
||||
}
|
||||
|
||||
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const path = url.pathname.replace(/\/+$/, '');
|
||||
const remote = req.socket.remoteAddress ?? '?';
|
||||
|
||||
if (path === '/hub/agent') handleAgentSocket(ws, remote);
|
||||
else if (path === '/hub/client') handleClientSocket(ws, remote);
|
||||
else fail(ws, 'E_BAD_REQUEST', `未知路径: ${path}(应使用 /hub/agent 或 /hub/client)`);
|
||||
});
|
||||
|
||||
return new Promise<HubServer>((resolve, reject) => {
|
||||
httpServer.once('error', reject);
|
||||
httpServer.once('listening', () => {
|
||||
const scheme = cfg.tls ? 'wss' : 'ws';
|
||||
log('info', `Hub 已监听 ${scheme}://${cfg.host}:${cfg.port}`);
|
||||
resolve({
|
||||
url: `${scheme}://${cfg.host}:${cfg.port}`,
|
||||
agents: () =>
|
||||
[...agents.values()].map((a) => ({
|
||||
node_id: a.node_id,
|
||||
platform: a.platform,
|
||||
version: a.version,
|
||||
connected_at: a.connected_at,
|
||||
paired: a.paired !== null,
|
||||
})),
|
||||
close: () =>
|
||||
new Promise<void>((res) => {
|
||||
for (const a of agents.values()) a.ws.terminate();
|
||||
for (const ws of wss.clients) ws.terminate();
|
||||
wss.close(() => httpServer.close(() => res()));
|
||||
}),
|
||||
});
|
||||
});
|
||||
httpServer.listen(cfg.port, cfg.host);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { hubConfigPath, loadHubConfig } from './config.js';
|
||||
import { createHubServer } from './hub.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { config, isNew } = loadHubConfig();
|
||||
const server = await createHubServer(config);
|
||||
|
||||
console.log('');
|
||||
console.log(' nodeagent Hub 已启动(中转节点)');
|
||||
console.log(' ─────────────────────────────────────────────');
|
||||
console.log(` 节点 ID : ${config.node_id}`);
|
||||
console.log(` 监听 : ${server.url}`);
|
||||
console.log(` 配置文件 : ${hubConfigPath()}`);
|
||||
console.log(` Hub 令牌 : ${config.token}`);
|
||||
console.log('');
|
||||
console.log(' 被控端接入 : 在 agent.json 配置 hub: { url, token } 后启动 agent');
|
||||
console.log(` 控制端接入 : nodeagent connect <hub-主机> --port ${config.port} --hub --key <设备密钥>`);
|
||||
console.log('');
|
||||
console.log(' 安全说明 : Hub 只透传字节流,不解析内容 ——');
|
||||
console.log(' 控制端与被控端仍执行自有握手,Hub 无法解密或伪造。');
|
||||
if (isNew) {
|
||||
console.log('');
|
||||
console.log(' ⚠️ 首次启动,已生成 Hub 令牌,请一并配置到被控端与控制端。');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
console.log('\n正在关闭...');
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => void shutdown());
|
||||
process.on('SIGTERM', () => void shutdown());
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Hub 控制面消息。
|
||||
*
|
||||
* 配对成功后即进入**透传**模式 —— Hub 不再解析内容,
|
||||
* 控制端与被控端之间的鉴权/加密由各自的 v3 握手保证(端到端)。
|
||||
*/
|
||||
|
||||
export interface HubAgentRegister {
|
||||
type: 'register';
|
||||
node_id: string;
|
||||
token: string;
|
||||
meta?: {
|
||||
platform?: string;
|
||||
version?: string;
|
||||
auth_mode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HubClientConnect {
|
||||
type: 'connect';
|
||||
node_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface HubListRequest {
|
||||
type: 'list';
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface HubRegistered {
|
||||
type: 'registered';
|
||||
node_id: string;
|
||||
}
|
||||
|
||||
export interface HubPaired {
|
||||
type: 'paired';
|
||||
node_id: string;
|
||||
}
|
||||
|
||||
export interface HubListResult {
|
||||
type: 'list.result';
|
||||
agents: Array<{ node_id: string; platform?: string; connected_at: number; paired: boolean }>;
|
||||
}
|
||||
|
||||
export interface HubError {
|
||||
type: 'error';
|
||||
code: 'E_HUB_AUTH' | 'E_NODE_OFFLINE' | 'E_NODE_BUSY' | 'E_BAD_REQUEST';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type HubInbound = HubAgentRegister | HubClientConnect | HubListRequest;
|
||||
export type HubOutbound = HubRegistered | HubPaired | HubListResult | HubError;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -600,6 +600,35 @@ CLI 便捷命令:`ls` / `stat` / `cat` / `pull` / `push`(pull 与 push 自
|
||||
|
||||
> **实测**:E2E 覆盖 —— 写入 → 读回 → 递归列表 → **1.5MB 分块重组 sha256 一致** → append 语义 → 白名单越界被拒。
|
||||
|
||||
### 6.8 Hub 中转(v6)
|
||||
|
||||
用于被控端位于 NAT 后 / 与控制端不在同一网段的场景。
|
||||
|
||||
```
|
||||
控制端 ──► Hub ──► 被控端
|
||||
↑ 字节透传,不认识 RPC 内容
|
||||
```
|
||||
|
||||
| 环节 | 设计 |
|
||||
|---|---|
|
||||
| 被控端接入 | **主动外连** `ws(s)://hub/hub/agent`,发 `register`(node_id + Hub 令牌 + 元信息)→ 免公网 IP、免入站放行 |
|
||||
| 控制端接入 | 连 `ws(s)://hub/hub/client`,发 `connect`(node_id + Hub 令牌)→ Hub 回 `paired` |
|
||||
| 配对之后 | **双向字节透传**:Hub 卸载自身消息监听,仅做 `send` 转发,不再解析 |
|
||||
| 连接语义 | 控制端断开 → 仅解绑配对,**被控端保持在线**;被控端掉线 → 连带关闭控制端 |
|
||||
| 并发模型 | 同一被控端同一时刻只服务一个控制端(避免多路复用复杂度),占用时返回 `E_NODE_BUSY` |
|
||||
| 鉴权 | Hub 令牌(被控端/控制端共用);`list` 可查在线设备 |
|
||||
|
||||
**安全边界**:Hub 位于信任边界之外 —— 它不持有设备密钥,也无法解密业务流量(端到端握手绕不过去)。因此:
|
||||
|
||||
- Hub 被攻陷 ≠ 设备被接管(攻击者仍需通过设备侧鉴权)
|
||||
- Hub 能审计「哪些设备在线」,但看不到「做了什么」(那是被控端本地审计的职责)
|
||||
|
||||
**连接复用注意**:Hub 模式下同一连接会被不同控制端先后复用,故被控端在 `hello` 时必须**重置认证态与授权**(已实现),否则存在越权风险。
|
||||
|
||||
**TLS**:默认由反向代理终止(Caddy/Nginx),Hub 也可通过 `tls.cert_file` / `tls.key_file` 直接提供。
|
||||
|
||||
> **实测**:E2E 覆盖 —— 经 Hub 完成端到端握手并调用 / Hub 令牌错误被拒 / 目标设备不存在 / 控制端断开后被控端仍可再次接入。
|
||||
|
||||
---
|
||||
|
||||
## 第 7 章 各端实现指南
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface ClientOptions {
|
||||
maxReconnectDelayMs?: number;
|
||||
/** v4:连接状态变化回调 */
|
||||
onStateChange?: (state: 'connected' | 'reconnecting' | 'closed') => void;
|
||||
/** v6:经 Hub 中转(URL 需指向 `/hub/client`) */
|
||||
hub?: { token: string; nodeId: string };
|
||||
/** 日志回调 */
|
||||
onLog?: (msg: string) => void;
|
||||
}
|
||||
@@ -96,7 +98,19 @@ export class NodeAgentClient {
|
||||
ws.once('open', () => {
|
||||
clearTimeout(timer);
|
||||
onLog?.(`已连接 ${url}`);
|
||||
resolve();
|
||||
// Hub 模式下先完成配对,再挂 RPC 处理器(否则控制面消息会被误当响应)
|
||||
const ready: Promise<void> = this.opts.hub
|
||||
? this.pairViaHub(ws, this.opts.hub, handshakeTimeoutMs)
|
||||
: Promise.resolve();
|
||||
ready
|
||||
.then(() => {
|
||||
ws.on('message', (raw: WebSocket.RawData) => this.handleMessage(raw.toString()));
|
||||
resolve();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ws.terminate();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
});
|
||||
ws.once('error', (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
@@ -108,7 +122,6 @@ export class NodeAgentClient {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (raw: WebSocket.RawData) => this.handleMessage(raw.toString()));
|
||||
ws.on('close', () => this.handleClose());
|
||||
});
|
||||
|
||||
@@ -216,6 +229,42 @@ export class NodeAgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** v6:Hub 配对(控制面消息,非 JSON-RPC)。配对后该连接即为到被控端的透明通道。 */
|
||||
private pairViaHub(ws: WebSocket, hub: { token: string; nodeId: string }, timeoutMs: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onMessage = (raw: WebSocket.RawData): void => {
|
||||
let msg: { type?: string; code?: string; message?: string };
|
||||
try {
|
||||
msg = JSON.parse(raw.toString()) as { type?: string; code?: string; message?: string };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'paired') {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', onMessage);
|
||||
this.opts.onLog?.(`Hub 配对成功(node_id=${hub.nodeId})`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', onMessage);
|
||||
// Hub 令牌错误属鉴权问题,其余(离线/占用)归为不可达
|
||||
const code = msg.code === 'E_HUB_AUTH' ? ErrorCodes.AUTH_FAILED : ErrorCodes.NODE_OFFLINE;
|
||||
reject(new ClientError(code, msg.code ?? 'E_HUB', msg.message ?? 'Hub 拒绝接入'));
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ws.off('message', onMessage);
|
||||
reject(new ClientError(ErrorCodes.TIMEOUT, 'E_TIMEOUT', 'Hub 配对超时'));
|
||||
}, timeoutMs);
|
||||
|
||||
ws.on('message', onMessage);
|
||||
ws.send(JSON.stringify({ type: 'connect', node_id: hub.nodeId, token: hub.token }));
|
||||
});
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
|
||||
@@ -15,6 +15,16 @@ export interface NodeProfile {
|
||||
auth_mode?: 'psk' | 'ed25519';
|
||||
/** 覆盖全局 client_id(同一控制端接入多台时可区分身份) */
|
||||
client_id?: string;
|
||||
/**
|
||||
* v6:经 Hub 中转接入。填写后连接走 `/hub/client`,
|
||||
* 并在握手前完成 Hub 配对(Hub 只透传,端到端安全不受影响)。
|
||||
*/
|
||||
hub?: {
|
||||
/** Hub 令牌 */
|
||||
token: string;
|
||||
/** 目标被控端的 node_id */
|
||||
node_id: string;
|
||||
};
|
||||
/** 备注,便于识别 */
|
||||
note?: string;
|
||||
}
|
||||
@@ -105,10 +115,11 @@ export function resolveTarget(cfg: ClientConfig, name?: string): ResolvedTarget
|
||||
return { name: target, profile, clientId: profile.client_id ?? cfg.client_id };
|
||||
}
|
||||
|
||||
/** 解析连接目标为 WebSocket URL。 */
|
||||
export function toWsUrl(profile: { host: string; port: number; tls: boolean }): string {
|
||||
/** 解析连接目标为 WebSocket URL(经 Hub 时附上 /hub/client 路径)。 */
|
||||
export function toWsUrl(profile: { host: string; port: number; tls: boolean; hub?: unknown }): string {
|
||||
const scheme = profile.tls ? 'wss' : 'ws';
|
||||
return `${scheme}://${profile.host}:${profile.port}`;
|
||||
const path = profile.hub ? '/hub/client' : '';
|
||||
return `${scheme}://${profile.host}:${profile.port}${path}`;
|
||||
}
|
||||
|
||||
export function emptyConfig(clientId = 'mac_01'): ClientConfig {
|
||||
|
||||
Generated
+13
@@ -40,6 +40,19 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/protocol
|
||||
|
||||
apps/hub:
|
||||
dependencies:
|
||||
'@nodeagent/protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/protocol
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
devDependencies:
|
||||
'@types/ws':
|
||||
specifier: ^8.5.13
|
||||
version: 8.18.1
|
||||
|
||||
apps/mcp:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
|
||||
@@ -28,6 +28,10 @@ const RECONNECT_PORT = 18798;
|
||||
const RECONNECT_KEY = 'reconnect-key-0123456789abcdef0123456789';
|
||||
const FS_PORT = 18799;
|
||||
const FS_KEY = 'fs-key-0123456789abcdef0123456789abcdef';
|
||||
const HUB_PORT = 18800;
|
||||
const HUB_AGENT_PORT = 18801;
|
||||
const HUB_TOKEN = 'hub-e2e-token-0123456789abcdef';
|
||||
const HUB_AGENT_KEY = 'hub-agent-key-0123456789abcdef0123';
|
||||
const KEY = 'e2e-test-key-0123456789abcdef0123456789abcdef';
|
||||
const URL = `ws://127.0.0.1:${PORT}`;
|
||||
|
||||
@@ -524,6 +528,86 @@ async function testFileTransfer() {
|
||||
}
|
||||
}
|
||||
|
||||
/** v6 场景:Hub 中转 —— 被控端主动注册,控制端经 Hub 配对后跑端到端握手。 */
|
||||
async function testHubMode() {
|
||||
const hubHome = mkdtempSync(join(tmpdir(), 'nodeagent-hub-'));
|
||||
const agentHome = mkdtempSync(join(tmpdir(), 'nodeagent-hubagent-'));
|
||||
const spawnWith = (home, script) =>
|
||||
spawn(process.execPath, [join(root, script)], {
|
||||
env: { ...process.env, NODEAGENT_HOME: home, HOME: home, USERPROFILE: home },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(hubHome, 'hub.json'),
|
||||
JSON.stringify({ node_id: 'hub_e2e', host: '127.0.0.1', port: HUB_PORT, token: HUB_TOKEN, log_level: 'warn' }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(agentHome, 'agent.json'),
|
||||
JSON.stringify({
|
||||
node_id: 'hub_node',
|
||||
host: '127.0.0.1',
|
||||
port: HUB_AGENT_PORT,
|
||||
tls: false,
|
||||
key: HUB_AGENT_KEY,
|
||||
log_level: 'warn',
|
||||
discovery: { enabled: false },
|
||||
hub: { enabled: true, url: `ws://127.0.0.1:${HUB_PORT}/hub/agent`, token: HUB_TOKEN },
|
||||
}),
|
||||
);
|
||||
|
||||
const hubProc = spawnWith(hubHome, 'apps/hub/dist/index.js');
|
||||
const agentProc = spawnWith(agentHome, 'apps/agent/dist/index.js');
|
||||
hubProc.stderr.on('data', (d) => process.stderr.write(`[hub] ${d}`));
|
||||
agentProc.stderr.on('data', (d) => process.stderr.write(`[hub-agent] ${d}`));
|
||||
|
||||
const url = `ws://127.0.0.1:${HUB_PORT}/hub/client`;
|
||||
const mk = (token, nodeId) =>
|
||||
new NodeAgentClient({ url, key: HUB_AGENT_KEY, clientId: 'hub_test', hub: { token, nodeId } });
|
||||
|
||||
try {
|
||||
// 等被控端注册到 Hub
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// 1. 正常链路:经 Hub 握手并调用
|
||||
const c = mk(HUB_TOKEN, 'hub_node');
|
||||
const caps = await c.connect();
|
||||
assert.ok(caps.length >= 19, `经 Hub 应拿到完整能力清单,实际 ${caps.length}`);
|
||||
const info = await c.invoke('system.info');
|
||||
assert.equal(info.status, 'ok', '经 Hub 的调用应成功');
|
||||
c.close();
|
||||
|
||||
// 2. Hub 令牌错误 → 鉴权失败
|
||||
await assert.rejects(
|
||||
() => mk('wrong-token', 'hub_node').connect(),
|
||||
(e) => {
|
||||
assert.equal(e.code, -32401, `期望 -32401(鉴权失败),实际 ${e.code}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// 3. 目标设备不存在 → 不可达
|
||||
await assert.rejects(
|
||||
() => mk(HUB_TOKEN, 'ghost_node').connect(),
|
||||
(e) => {
|
||||
assert.equal(e.code, -32405, `期望 -32405(设备离线),实际 ${e.code}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// 4. 被控端应保持在线(前一次控制端断开不应把它踢下线)
|
||||
const c2 = mk(HUB_TOKEN, 'hub_node');
|
||||
await c2.connect();
|
||||
const st = await c2.invoke('system.status');
|
||||
assert.equal(st.status, 'ok', '被控端应仍在线可再次接入');
|
||||
c2.close();
|
||||
} finally {
|
||||
hubProc.kill();
|
||||
agentProc.kill();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nnodeagent 端到端测试\n');
|
||||
const agent = startAgent();
|
||||
@@ -716,6 +800,9 @@ async function main() {
|
||||
|
||||
// ---------- v5 文件传输 ----------
|
||||
await test('v5 文件传输:写入→读回→列表→分块重组→append→白名单', testFileTransfer);
|
||||
|
||||
// ---------- v6 Hub 中转 ----------
|
||||
await test('v6 Hub 中转:注册→配对→端到端握手→多次接入', testHubMode);
|
||||
} finally {
|
||||
agent.kill('SIGTERM');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
Reference in new issue
Block a user