feat(E3c): Hub 侧按控制端授权(多令牌 + 按设备白/黑名单 + 注册准入)

## 背景
Hub 原先只有**一个**令牌:任何持有者都能接入任意设备,无法区分「谁在控制哪台机器」。
E3 的权限隔离因此只做到能力级(被控端 ACL),Hub 侧仍是全通。

## 实现(apps/hub/src/authz.ts,纯函数便于单测)
- 多令牌:每个令牌 = 一个控制端身份,可独立指定 allow_nodes / deny_nodes(glob)
- 判定顺序 deny -> allow -> 默认拒绝;未配置 allow_nodes 时默认放行(兼容)
- 顶层 token 视为主令牌(放行全部),旧配置零改动
- 前缀式滚动令牌:value 以 * 结尾时按前缀匹配(轮换期间可灰度)
- 注册准入:node_allowlist 非空时只有匹配的 node_id 能注册,防止他人借令牌挂靠节点
- 列表按身份过滤:控制端只看得到自己被授权的设备
- 越权接入返回新错误码 E_NODE_FORBIDDEN

## 职责边界(重要)
Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权仍由被控端 ACL
按 client_id 裁决。两层各自独立,Hub 不参与能力决策 —— 保持 Hub 不可信假设。

## 测试
- 单测 79 -> 89(新增 Hub 授权 10 例:主令牌兼容 / 未知令牌 / 前缀轮换 /
  deny 优先 / glob 白名单 / 默认放行 / 注册准入 / 旧配置兼容 / 列表过滤 / glob 转义)
- e2e:Hub 用例新增受限令牌访问未授权设备 -> 断言 E_NODE_FORBIDDEN 且错误信息含授权原因
This commit is contained in:
jkooe committed 2026-09-29 14:38:53 +08:00
1 parent 66c44f6822
commit cd774ae0a6
7 files changed
+318 -14

No files matched your search

+24 -3
View File
@@ -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 <Hub
被控端侧常备 2 条连接(`hub.warm_slots`)并在被占用时自动扩容 —— 因此
「AI + 人同时在线」不再互斥(旧版是独占,会返回 `E_NODE_BUSY`)。
**Hub 侧访问控制(v12)**:可配置多令牌,每个令牌独立指定能访问哪些设备:
```json
{
"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 <Hub
```bash
pnpm build # 构建全部包
pnpm typecheck # 类型检查
pnpm test:unit # 单元测试(79 项:协议纯函数 / 清单守护 / 审计链 / ACL v11 / 宏引擎 / 设备分组)
pnpm test:unit # 单元测试(89 项:协议纯函数 / 清单守护 / 审计链 / ACL / 宏引擎 / 设备分组 / Hub 授权)
pnpm test:e2e # 端到端测试(21 项,含 TLS / 零信任 / 发现 / 文件 / Hub)
pnpm test:windows # Windows 专属能力(服务 / 软件 / winget 真实装软件)
pnpm verify # 对已配置的被控端跑全套验收并输出报告
@@ -400,7 +420,8 @@ nodeagent/
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **待办** | v7~v12 能力入 CI(需 workflow scope 才能推 CI 改动)、macOS 端 GUI 能力对齐、Hub 侧按设备授权 | 🚧 |
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **待办** | v7~v12 能力入 CI(需 workflow scope 才能推 CI 改动)、macOS 端 GUI 能力对齐、事件订阅推给 MCP 客户端(当前为缓冲拉取) | 🚧 |
## 文档
+101
View File
@@ -0,0 +1,101 @@
import type { HubConfig, HubTokenRule } from './config.js';
/**
* Hub 侧授权(v12 / E3c)。
*
* 背景:Hub 原先只有**一个**令牌,任何持有者都能接入任意设备 —— 无法区分
* 「谁在控制哪台机器」。这里引入**多令牌 + 按设备白/黑名单**:
*
* tokens: [
* { value: "master-xxx", name: "owner", allow_nodes: ["*"] },
* { value: "ci-xxx", name: "ci", allow_nodes: ["win_build_*"] },
* { value: "guest-xxx", name: "guest", allow_nodes: ["*"], deny_nodes: ["win_prod"] }
* ]
*
* 兼容:顶层 `token` 依旧有效,视为「主令牌」(放行全部设备)。
*
* 注意职责边界:Hub 只做**接入过滤**(能否连到某设备);连上之后的**能力级**授权
* 仍由被控端 ACL 按 client_id 裁决 —— 两层各自独立,Hub 不参与能力决策。
*/
export interface ResolvedToken {
name: string;
allowNodes: string[];
denyNodes: string[];
isMaster: boolean;
}
/** 简易 glob(支持 `*`),与协议侧 matchPattern 语义一致但独立实现,避免跨包耦合。 */
export function nodeMatches(pattern: string, value: string): boolean {
if (pattern === '*') return true;
if (!pattern.includes('*')) return pattern === value;
const escaped = pattern
.split('*')
.map((s) => 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<T extends { node_id: string }>(token: ResolvedToken, all: T[]): T[] {
return all.filter((a) => nodeAllowed(token, a.node_id).allowed);
}
+25
View File
@@ -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 {
+39 -9
View File
@@ -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<HubServer> {
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<HubServer> {
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<HubServer> {
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<HubServer> {
// ---------------- 控制端接入 ----------------
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<HubServer> {
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) 有空闲槽位直接配
+8 -1
View File
@@ -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;
}
+20 -1
View File
@@ -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();
+101
View File
@@ -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);
});