diff --git a/README.md b/README.md index 2a64d02..62a7305 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可 | **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) | | **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) | -**35 项能力** · **31 个 MCP 工具** · **79 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证) +**35 项能力** · **31 个 MCP 工具** · **89 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证) > 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR) > 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。 @@ -330,6 +330,26 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token ", + "node_allowlist": ["win_*"], + "tokens": [ + { "value": "ci-token", "name": "ci", "allow_nodes": ["win_build_*"] }, + { "value": "guest-token", "name": "guest", "allow_nodes": ["*"], "deny_nodes": ["win_prod"] }, + { "value": "roll-*", "name": "rolling", "allow_nodes": ["win_a"] } + ] +} +``` + +- 判定顺序 **deny → allow → 默认拒绝**;`node_allowlist` 非空时只有匹配的 node_id 能注册 +- `value` 以 `*` 结尾表示**前缀匹配**(便于令牌轮换期间灰度) +- 设备列表按身份过滤(控制端只看得到自己被授权的设备),越权接入返回 `E_NODE_FORBIDDEN` +- **职责边界**:Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权 + 仍由被控端 ACL 按 client_id 裁决 —— 两层独立,Hub 不参与能力决策 + **安全边界**:Hub **只做字节透传**,不解析内容、不持有设备密钥 —— 控制端与被控端之间仍执行端到端握手,因此 **Hub 无法窃听也无法伪造**(Hub 被攻陷也不等于设备被接管)。生产环境建议在 Hub 前挂 Caddy / Nginx 终止 TLS。 ## 开发 @@ -337,7 +357,7 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + return new RegExp(`^${escaped}$`, 'i').test(value); +} + +/** 用呈现的令牌解析出身份;不匹配返回 null。 */ +export function resolveToken(cfg: HubConfig, presented: string | undefined): ResolvedToken | null { + if (!presented) return null; + + for (const rule of cfg.tokens ?? []) { + // 支持前缀式「滚动令牌」:配置值以 * 结尾时按前缀匹配(便于轮换期间的灰度) + const hit = rule.value.endsWith('*') + ? presented.startsWith(rule.value.slice(0, -1)) + : presented === rule.value; + if (hit) return normalizeRule(rule); + } + + // 向后兼容:顶层 token 视为主令牌 + if (presented === cfg.token) { + return { name: 'master', allowNodes: ['*'], denyNodes: [], isMaster: true }; + } + return null; +} + +function normalizeRule(rule: HubTokenRule): ResolvedToken { + return { + name: rule.name ?? 'unnamed', + allowNodes: rule.allow_nodes ?? ['*'], + denyNodes: rule.deny_nodes ?? [], + isMaster: false, + }; +} + +/** 该身份能否访问指定设备(deny 优先 → allow → 默认拒绝)。 */ +export function nodeAllowed(token: ResolvedToken, nodeId: string): { allowed: boolean; reason: string } { + for (const d of token.denyNodes) { + if (nodeMatches(d, nodeId)) { + return { allowed: false, reason: `命中设备黑名单 ${d}(令牌 ${token.name})` }; + } + } + for (const a of token.allowNodes) { + if (nodeMatches(a, nodeId)) { + return { allowed: true, reason: `命中设备白名单 ${a}` }; + } + } + return { + allowed: false, + reason: `令牌 ${token.name} 未被授权访问设备 ${nodeId}(白名单: ${token.allowNodes.join(', ') || '空'})`, + }; +} + +/** + * 被控端注册准入:`node_allowlist` 非空时,只有匹配的 node_id 才能注册。 + * 用于防止他人拿(可能泄露的)令牌把自家节点挂到你的 Hub 上。 + */ +export function nodeMayRegister(cfg: HubConfig, nodeId: string): { allowed: boolean; reason: string } { + const list = cfg.node_allowlist ?? []; + if (list.length === 0) return { allowed: true, reason: '未配置注册白名单' }; + for (const p of list) { + if (nodeMatches(p, nodeId)) return { allowed: true, reason: `命中注册白名单 ${p}` }; + } + return { allowed: false, reason: `设备 ${nodeId} 不在 Hub 注册白名单内` }; +} + +/** 过滤列表可见性:控制端只应看到自己被授权的设备。 */ +export function visibleNodes(token: ResolvedToken, all: T[]): T[] { + return all.filter((a) => nodeAllowed(token, a.node_id).allowed); +} diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 6056f92..6e0b76b 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -3,6 +3,21 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +/** + * Hub 侧访问规则(v12 / E3c):一个令牌 = 一个控制端身份。 + * `value` 以 `*` 结尾时按**前缀**匹配(便于令牌轮换期间灰度)。 + */ +export interface HubTokenRule { + /** 令牌值(前缀式轮换:以 * 结尾表示前缀匹配) */ + value: string; + /** 身份名(日志/审计可读) */ + name?: string; + /** 可访问设备(glob,默认 ["*"]) */ + allow_nodes?: string[]; + /** 禁止访问设备(优先级高于 allow) */ + deny_nodes?: string[]; +} + export interface HubConfig { node_id: string; host: string; @@ -20,6 +35,16 @@ export interface HubConfig { * 默认 3:够 AI + 人 + 备用同时在线;设为 1 即退回旧的「独占」语义。 */ max_slots_per_node?: number; + /** + * v12 / E3c:多控制端令牌(各自独立的设备白/黑名单)。 + * 未配置时退回顶层 `token`(视为主令牌,放行全部设备)。 + */ + tokens?: HubTokenRule[]; + /** + * v12 / E3c:允许注册的设备白名单(glob)。非空时,未匹配的 node_id 无法注册, + * 防止他人用泄露的令牌把自家节点挂到你的 Hub 上。 + */ + node_allowlist?: string[]; } export function hubDir(): string { diff --git a/apps/hub/src/hub.ts b/apps/hub/src/hub.ts index 7113fbe..fa44f88 100644 --- a/apps/hub/src/hub.ts +++ b/apps/hub/src/hub.ts @@ -3,6 +3,7 @@ import { createServer as createHttpServer, type IncomingMessage } from 'node:htt import { createServer as createHttpsServer } from 'node:https'; import { WebSocket, WebSocketServer, type RawData } from 'ws'; import type { HubConfig } from './config.js'; +import { nodeAllowed, nodeMayRegister, resolveToken, visibleNodes, type ResolvedToken } from './authz.js'; import type { HubClientConnect, HubError, @@ -160,8 +161,15 @@ export function createHubServer(cfg: HubConfig): Promise { log('info', `配对成功 ${entry.node_id} ↔ 控制端`); } - /** 解析 + 令牌校验;失败时已回错误消息并关闭连接。 */ - function readControl(ws: WebSocket, raw: string, remote: string): HubInbound | null { + /** + * 解析 + 身份校验(v12:多令牌)。失败时已回错误并关闭连接。 + * 返回消息 + 解析出的身份(供后续设备级授权判断)。 + */ + function readControl( + ws: WebSocket, + raw: string, + remote: string, + ): { msg: HubInbound; token: ResolvedToken } | null { let msg: HubInbound; try { msg = JSON.parse(raw) as HubInbound; @@ -169,12 +177,13 @@ export function createHubServer(cfg: HubConfig): Promise { fail(ws, 'E_BAD_REQUEST', '消息不是合法 JSON'); return null; } - if (msg.token !== cfg.token) { + const token = resolveToken(cfg, msg.token); + if (!token) { log('warn', `鉴权失败(令牌不匹配)from ${remote}`); fail(ws, 'E_HUB_AUTH', 'Hub 令牌错误'); return null; } - return msg; + return { msg, token }; } // ---------------- 被控端接入 ---------------- @@ -184,12 +193,21 @@ export function createHubServer(cfg: HubConfig): Promise { const onMessage = (data: RawData): void => { if (registeredId) return; // 注册后不再受理控制面消息 - const msg = readControl(ws, data.toString(), remote); - if (!msg) return; + const parsed = readControl(ws, data.toString(), remote); + if (!parsed) return; + const { msg, token } = parsed; if (msg.type !== 'register') { fail(ws, 'E_BAD_REQUEST', `被控端不应发送 ${msg.type}`); return; } + // v12:注册准入白名单(防止陌生节点借令牌挂靠) + const reg = nodeMayRegister(cfg, msg.node_id); + if (!reg.allowed) { + log('warn', `拒绝注册 ${msg.node_id}:${reg.reason}`); + fail(ws, 'E_NODE_FORBIDDEN', reg.reason); + return; + } + void token; const nodeId = msg.node_id; const pool = poolOf(nodeId).filter((e) => e.ws.readyState === WebSocket.OPEN); @@ -228,12 +246,17 @@ export function createHubServer(cfg: HubConfig): Promise { // ---------------- 控制端接入 ---------------- function handleClientSocket(ws: WebSocket, remote: string): void { + let clientToken: ResolvedToken | null = null; + void clientToken; const onMessage = (data: RawData): void => { - const msg = readControl(ws, data.toString(), remote); - if (!msg) return; + const parsed = readControl(ws, data.toString(), remote); + if (!parsed) return; + const { msg, token } = parsed; + clientToken = token; if (msg.type === 'list') { - send(ws, { type: 'list.result', agents: viewAgents() }); + // v12:只展示该身份被授权的设备 + send(ws, { type: 'list.result', agents: visibleNodes(token, viewAgents()) }); return; } @@ -247,6 +270,13 @@ export function createHubServer(cfg: HubConfig): Promise { fail(ws, 'E_NODE_OFFLINE', `设备离线: ${nodeId}`); return; } + // v12:设备级授权(Hub 只做接入过滤;能力级仍由被控端 ACL 裁夺) + const perm = nodeAllowed(token, nodeId); + if (!perm.allowed) { + log('warn', `拒绝接入 ${nodeId}(${token.name}):${perm.reason}`); + fail(ws, 'E_NODE_FORBIDDEN', perm.reason); + return; + } void (async () => { log('info', `控制端请求接入 ${nodeId} from ${remote}`); // 1) 有空闲槽位直接配 diff --git a/apps/hub/src/types.ts b/apps/hub/src/types.ts index 6426a5d..a3dcbdf 100644 --- a/apps/hub/src/types.ts +++ b/apps/hub/src/types.ts @@ -66,7 +66,14 @@ export interface HubListResult { export interface HubError { type: 'error'; - code: 'E_HUB_AUTH' | 'E_NODE_OFFLINE' | 'E_NODE_BUSY' | 'E_BAD_REQUEST' | 'E_TOO_MANY_SLOTS'; + code: + | 'E_HUB_AUTH' + | 'E_NODE_OFFLINE' + | 'E_NODE_BUSY' + | 'E_BAD_REQUEST' + | 'E_TOO_MANY_SLOTS' + /** v12:身份合法但未被授权访问该设备 / 该节点不允许注册 */ + | 'E_NODE_FORBIDDEN'; message: string; } diff --git a/tests/e2e/run.mjs b/tests/e2e/run.mjs index c3f5b6a..898e846 100644 --- a/tests/e2e/run.mjs +++ b/tests/e2e/run.mjs @@ -540,7 +540,16 @@ async function testHubMode() { 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' }), + JSON.stringify({ + node_id: 'hub_e2e', + host: '127.0.0.1', + port: HUB_PORT, + token: HUB_TOKEN, + log_level: 'warn', + // v12 / E3c:受限令牌(只允许访问 other_node,用于验证设备级授权) + tokens: [{ value: 'limited-token', name: 'limited', allow_nodes: ['other_node'] }], + node_allowlist: ['hub_*'], + }), ); writeFileSync( join(agentHome, 'agent.json'), @@ -625,6 +634,16 @@ async function testHubMode() { assert.equal(r4.status, 'ok', '控制端 2 第二次调用应成功'); cc1.close(); cc2.close(); + + // 6. v12 设备级授权:受限令牌访问未授权设备 -> E_NODE_FORBIDDEN + await assert.rejects( + () => mk('limited-token', 'hub_node').connect(), + (e) => { + assert.equal(e.name, 'E_NODE_FORBIDDEN', `期望 E_NODE_FORBIDDEN,实际 ${e.name}`); + assert.match(e.message, /未被授权|白名单/, '错误信息应说明授权原因'); + return true; + }, + ); } finally { hubProc.kill(); agentProc.kill(); diff --git a/tests/unit/hub-authz.test.mjs b/tests/unit/hub-authz.test.mjs new file mode 100644 index 0000000..5bef198 --- /dev/null +++ b/tests/unit/hub-authz.test.mjs @@ -0,0 +1,101 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + nodeAllowed, + nodeMatches, + nodeMayRegister, + resolveToken, + visibleNodes, +} from '../../apps/hub/dist/authz.js'; + +const cfg = { + node_id: 'hub_01', + host: '0.0.0.0', + port: 9443, + token: 'master-token', + log_level: 'warn', + tokens: [ + { value: 'owner-token', name: 'owner', allow_nodes: ['*'] }, + { value: 'ci-token', name: 'ci', allow_nodes: ['win_build_*'] }, + { value: 'guest-token', name: 'guest', allow_nodes: ['*'], deny_nodes: ['win_prod'] }, + { value: 'roll-*', name: 'rolling', allow_nodes: ['win_a'] }, + ], + node_allowlist: ['win_*', 'mac_a'], +}; + +test('令牌:顶层 token 视为主令牌(放行全部,向后兼容)', () => { + const t = resolveToken(cfg, 'master-token'); + assert.ok(t); + assert.equal(t.isMaster, true); + assert.equal(nodeAllowed(t, '任意设备').allowed, true); +}); + +test('令牌:未知令牌解析失败', () => { + assert.equal(resolveToken(cfg, 'nope'), null); + assert.equal(resolveToken(cfg, undefined), null); + assert.equal(resolveToken(cfg, ''), null); +}); + +test('令牌:前缀式滚动令牌按前缀匹配', () => { + const t = resolveToken(cfg, 'roll-2026-09'); + assert.ok(t); + assert.equal(t.name, 'rolling'); + assert.equal(nodeAllowed(t, 'win_a').allowed, true); + assert.equal(nodeAllowed(t, 'win_b').allowed, false, '白名单外应拒绝'); +}); + +test('设备授权:deny 优先于 allow', () => { + const guest = resolveToken(cfg, 'guest-token'); + assert.equal(nodeAllowed(guest, 'win_prod').allowed, false, '黑名单优先'); + assert.equal(nodeAllowed(guest, 'win_other').allowed, true); +}); + +test('设备授权:glob 白名单 + 默认拒绝', () => { + const ci = resolveToken(cfg, 'ci-token'); + assert.equal(nodeAllowed(ci, 'win_build_01').allowed, true); + assert.equal(nodeAllowed(ci, 'win_build_99').allowed, true); + assert.equal(nodeAllowed(ci, 'win_prod').allowed, false); + const r = nodeAllowed(ci, 'win_prod'); + assert.match(r.reason, /未被授权/); +}); + +test('设备授权:未配置 allow_nodes 时默认放行全部', () => { + const c2 = { ...cfg, tokens: [{ value: 'plain', name: 'plain' }] }; + const t = resolveToken(c2, 'plain'); + assert.equal(nodeAllowed(t, 'any_node').allowed, true); +}); + +test('注册准入:node_allowlist 非空时按 glob 过滤', () => { + assert.equal(nodeMayRegister(cfg, 'win_x').allowed, true); + assert.equal(nodeMayRegister(cfg, 'mac_a').allowed, true); + assert.equal(nodeMayRegister(cfg, 'mac_b').allowed, false); + assert.equal(nodeMayRegister(cfg, 'attacker_node').allowed, false); +}); + +test('注册准入:未配置白名单时放行(兼容旧配置)', () => { + const legacy = { ...cfg, node_allowlist: undefined }; + assert.equal(nodeMayRegister(legacy, 'anything').allowed, true); +}); + +test('列表可见性:只展示被授权的设备', () => { + const ci = resolveToken(cfg, 'ci-token'); + const all = [ + { node_id: 'win_build_01' }, + { node_id: 'win_prod' }, + { node_id: 'win_build_02' }, + ]; + assert.deepEqual( + visibleNodes(ci, all).map((x) => x.node_id), + ['win_build_01', 'win_build_02'], + ); +}); + +test('nodeMatches:glob 语义与转义(点号不被当通配)', () => { + assert.equal(nodeMatches('*', 'x'), true); + assert.equal(nodeMatches('win*', 'win_a'), true); + assert.equal(nodeMatches('win_a', 'win_a'), true); + assert.equal(nodeMatches('win_a', 'win_b'), false); + // '.' 应被转义为字面量 + assert.equal(nodeMatches('a.b', 'axb'), false); + assert.equal(nodeMatches('a.b', 'a.b'), true); +});