diff --git a/README.md b/README.md index fd1e404..7d6af44 100644 --- a/README.md +++ b/README.md @@ -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://:9443/hub/agent", "token": "" } +# 启动后 agent 会主动外连 Hub 注册 —— 无需公网 IP、无需开放入站端口 + +# 3) Mac 侧经 Hub 接入 +node apps/cli/dist/index.js connect --port 9443 --hub-token \ + --hub-node win_01 --key <设备密钥> --name win_01 +``` + +**安全模型**:Hub **只做字节透传**,不解析内容、不持有设备密钥 —— 控制端与被控端之间仍执行 Ed25519/PSK 端到端握手,因此 **Hub 无法窃听也无法伪造**。生产环境建议在 Hub 前挂 Caddy/Nginx 终止 TLS。 + ## 关键决策速览 | 决策点 | 结论 | diff --git a/apps/agent/src/config.ts b/apps/agent/src/config.ts index bb3d1c7..b3b7b30 100644 --- a/apps/agent/src/config.ts +++ b/apps/agent/src/config.ts @@ -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。 */ diff --git a/apps/agent/src/hub-mode.ts b/apps/agent/src/hub-mode.ts new file mode 100644 index 0000000..4f1afcb --- /dev/null +++ b/apps/agent/src/hub-mode.ts @@ -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 { + /* 忽略 */ + } + }, + }; +} diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 71638ab..28b0a00 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -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 { 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 { // 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 { 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 { const shutdown = async (): Promise => { 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()); diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index 022745e..7b5224c 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -48,7 +48,13 @@ export interface AgentServer { type LogLevel = 'debug' | 'info' | 'warn'; /** 创建并启动被控端 WebSocket 服务。 */ -export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Promise { +/** 连接级 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(); 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 { + const core = createAgentCore(cfg); + const levelOrder: Record = { 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(() => { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 744208b..e899c48 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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(fn: (client: NodeAgentClient) => Promise, 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 { 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 { 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 { 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, }; diff --git a/apps/hub/package.json b/apps/hub/package.json new file mode 100644 index 0000000..bd4eb33 --- /dev/null +++ b/apps/hub/package.json @@ -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" + } +} diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts new file mode 100644 index 0000000..e7f73bb --- /dev/null +++ b/apps/hub/src/config.ts @@ -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 }; +} diff --git a/apps/hub/src/hub.ts b/apps/hub/src/hub.ts new file mode 100644 index 0000000..dea9a9d --- /dev/null +++ b/apps/hub/src/hub.ts @@ -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; +} + +export function createHubServer(cfg: HubConfig): Promise { + const levelOrder: Record = { 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(); + + 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((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((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); + }); +} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts new file mode 100644 index 0000000..fa04a5c --- /dev/null +++ b/apps/hub/src/index.ts @@ -0,0 +1,36 @@ +import { hubConfigPath, loadHubConfig } from './config.js'; +import { createHubServer } from './hub.js'; + +async function main(): Promise { + 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 --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 => { + console.log('\n正在关闭...'); + await server.close(); + process.exit(0); + }; + process.on('SIGINT', () => void shutdown()); + process.on('SIGTERM', () => void shutdown()); +} + +await main(); diff --git a/apps/hub/src/types.ts b/apps/hub/src/types.ts new file mode 100644 index 0000000..40ca282 --- /dev/null +++ b/apps/hub/src/types.ts @@ -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; diff --git a/apps/hub/tsconfig.json b/apps/hub/tsconfig.json new file mode 100644 index 0000000..c99ec7b --- /dev/null +++ b/apps/hub/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 748f512..422450d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 章 各端实现指南 diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index f3e8e79..f211254 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -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 = 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 { + return new Promise((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; diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts index 96eb971..bd5712b 100644 --- a/packages/client/src/config.ts +++ b/packages/client/src/config.ts @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2617fb1..e0f4eec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/tests/e2e/run.mjs b/tests/e2e/run.mjs index 4e11008..c68785b 100644 --- a/tests/e2e/run.mjs +++ b/tests/e2e/run.mjs @@ -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));