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:
jkooe committed 2026-09-27 15:04:05 +08:00
1 parent 4ff1b4fd90
commit 3d03945448
17 files changed
+813 -22

No files matched your search

+26
View File
@@ -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。
## 关键决策速览
| 决策点 | 结论 |
+13
View File
@@ -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。 */
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+23
View File
@@ -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"
}
}
+47
View File
@@ -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 };
}
+236
View File
@@ -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);
});
}
+36
View File
@@ -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();
+52
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
+29
View File
@@ -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 章 各端实现指南
+51 -2
View File
@@ -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;
+14 -3
View File
@@ -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 {
+13
View File
@@ -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':
+87
View File
@@ -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));