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:
1 parent
66c44f6822
commit
cd774ae0a6
7 files changed
+318
-14
No files matched your search
@@ -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 客户端(当前为缓冲拉取) | 🚧 |
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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) 有空闲槽位直接配
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user