接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。
## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效
**问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认),
一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
**做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。
比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。
**实测**(五场景全对):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ |
| **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 |
| bob 用自己的 key | ✅ |
| 未登记 carol 用共享 key | ✅(向后兼容) |
| 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) |
配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段;
鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。
## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏
**问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。
**做法**:
- `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`,
真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**)
- 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`),
控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**;
未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」)
- 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛
**实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓
## 验证
- 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败)
- e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为
- 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉
## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
|
import fsSync from 'node:fs';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path, { join } from 'node:path';
|
|
import { parseArgs } from 'node:util';
|
|
import {
|
|
NodeAgentClient,
|
|
loadMacroFile,
|
|
runMacro,
|
|
ClientError,
|
|
loadConfig,
|
|
saveConfig,
|
|
configPath,
|
|
toWsUrl,
|
|
loadKeys,
|
|
createKeys,
|
|
keysFilePath,
|
|
discoverOnce,
|
|
resolveTarget,
|
|
resolveNodeSelector,
|
|
emptyConfig,
|
|
type ClientConfig,
|
|
type ResolvedTarget,
|
|
type NodeProfile,
|
|
} from '@nodeagent/client';
|
|
import {
|
|
CapabilityNames,
|
|
matchPattern,
|
|
DEFAULT_DISCOVERY_PORT,
|
|
type CapabilityDescriptor,
|
|
type InvokeResult,
|
|
} from '@nodeagent/protocol';
|
|
import { type Options } from './types.js';
|
|
|
|
// 命令行参数解析(从 index.ts 拆出)
|
|
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
|
|
const { values, positionals } = parseArgs({
|
|
args: rest,
|
|
options: {
|
|
json: { type: 'boolean', default: false },
|
|
insecure: { type: 'boolean', default: false },
|
|
port: { type: 'string' },
|
|
key: { type: 'string' },
|
|
id: { type: 'string' },
|
|
limit: { type: 'string' },
|
|
args: { type: 'string' },
|
|
config: { type: 'boolean', default: false },
|
|
// v2 图形操作
|
|
out: { type: 'string' },
|
|
format: { type: 'string' },
|
|
scale: { type: 'string' },
|
|
region: { type: 'string' },
|
|
button: { type: 'string' },
|
|
duration: { type: 'string' },
|
|
interval: { type: 'string' },
|
|
'auth-mode': { type: 'string' },
|
|
since: { type: 'string' },
|
|
type: { type: 'string' },
|
|
'client-id': { type: 'string' },
|
|
wait: { type: 'string' },
|
|
'discovery-port': { type: 'string' },
|
|
node: { type: 'string' },
|
|
name: { type: 'string' },
|
|
note: { type: 'string' },
|
|
recursive: { type: 'boolean', default: false },
|
|
pattern: { type: 'string' },
|
|
'create-dirs': { type: 'boolean', default: false },
|
|
'hub-token': { type: 'string' },
|
|
'hub-node': { type: 'string' },
|
|
// v7 受控重启
|
|
delay: { type: 'string' },
|
|
// v10 后台任务 / 剪贴板
|
|
'timeout-ms': { type: 'string' },
|
|
kill: { type: 'boolean', default: false },
|
|
offset: { type: 'string' },
|
|
set: { type: 'string' },
|
|
'image-file': { type: 'string' },
|
|
fps: { type: 'string' },
|
|
nodes: { type: 'string' },
|
|
path: { type: 'string' },
|
|
kind: { type: 'string' },
|
|
var: { type: 'string', multiple: true },
|
|
mode: { type: 'string' },
|
|
iface: { type: 'string' },
|
|
ip: { type: 'string' },
|
|
mask: { type: 'string' },
|
|
gateway: { type: 'string' },
|
|
dns: { type: 'string' },
|
|
command: { type: 'string' },
|
|
'confirm-within': { type: 'string' },
|
|
'task-name': { type: 'string' },
|
|
yes: { type: 'boolean', default: false },
|
|
url: { type: 'string' },
|
|
sha256: { type: 'string' },
|
|
'dry-run': { type: 'boolean', default: false },
|
|
psk: { type: 'boolean', default: false },
|
|
'discover-secret': { type: 'string' },
|
|
'forget-cert': { type: 'boolean', default: false },
|
|
check: { type: 'boolean', default: false },
|
|
force: { type: 'boolean', default: false },
|
|
mute: { type: 'string' },
|
|
volume: { type: 'string' },
|
|
seconds: { type: 'string' },
|
|
'interval-ms': { type: 'string' },
|
|
},
|
|
allowPositionals: true,
|
|
strict: false,
|
|
});
|
|
return {
|
|
opts: {
|
|
json: Boolean(values['json']),
|
|
insecure: Boolean(values['insecure']),
|
|
port: values['port'] as string | undefined,
|
|
key: values['key'] as string | undefined,
|
|
id: values['id'] as string | undefined,
|
|
limit: values['limit'] as string | undefined,
|
|
args: values['args'] as string | undefined,
|
|
out: values['out'] as string | undefined,
|
|
format: values['format'] as string | undefined,
|
|
scale: values['scale'] as string | undefined,
|
|
region: values['region'] as string | undefined,
|
|
button: values['button'] as string | undefined,
|
|
duration: values['duration'] as string | undefined,
|
|
interval: values['interval'] as string | undefined,
|
|
authMode: values['auth-mode'] as string | undefined,
|
|
since: values['since'] as string | undefined,
|
|
type: values['type'] as string | undefined,
|
|
clientId: values['client-id'] as string | undefined,
|
|
wait: values['wait'] as string | undefined,
|
|
discoveryPort: values['discovery-port'] as string | undefined,
|
|
node: values['node'] as string | undefined,
|
|
delay: values['delay'] as string | undefined,
|
|
timeoutMs: values['timeout-ms'] as string | undefined,
|
|
kill: Boolean(values['kill']),
|
|
offset: values['offset'] as string | undefined,
|
|
set: values['set'] as string | undefined,
|
|
imageFile: values['image-file'] as string | undefined,
|
|
fps: values['fps'] as string | undefined,
|
|
nodes: values['nodes'] as string | undefined,
|
|
path: values['path'] as string | undefined,
|
|
kind: values['kind'] as string | undefined,
|
|
vars: (values['var'] as string[] | undefined) ?? [],
|
|
mode: values['mode'] as string | undefined,
|
|
iface: values['iface'] as string | undefined,
|
|
ip: values['ip'] as string | undefined,
|
|
mask: values['mask'] as string | undefined,
|
|
gateway: values['gateway'] as string | undefined,
|
|
dns: values['dns'] as string | undefined,
|
|
command: values['command'] as string | undefined,
|
|
confirmWithin: values['confirm-within'] as string | undefined,
|
|
taskName: values['task-name'] as string | undefined,
|
|
yes: Boolean(values['yes']),
|
|
url: values['url'] as string | undefined,
|
|
sha256: values['sha256'] as string | undefined,
|
|
dryRun: Boolean(values['dry-run']),
|
|
psk: Boolean(values['psk']),
|
|
discoverSecret: values['discover-secret'] as string | undefined,
|
|
forgetCert: Boolean(values['forget-cert']),
|
|
check: Boolean(values['check']),
|
|
force: Boolean(values['force']),
|
|
mute: values['mute'] as string | undefined,
|
|
volume: values['volume'] as string | undefined,
|
|
seconds: values['seconds'] as string | undefined,
|
|
intervalMs: values['interval-ms'] as string | undefined,
|
|
name: values['name'] as string | undefined,
|
|
note: values['note'] as string | undefined,
|
|
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,
|
|
};
|
|
}
|