Files
nodeagent/apps/agent/src/config.ts
T
jkooe e23788d717 refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。

## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)

## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
   补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身

## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)

## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
2026-10-10 14:27:12 +08:00

163 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { generateSharedKey, type AclPolicy } from '@nodeagent/protocol';
export interface AgentConfig {
node_id: string;
host: string;
port: number;
tls: boolean;
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
key: string;
/**
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
*
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
*/
keys?: Record<string, string>;
log_level: 'debug' | 'info' | 'warn';
/**
* v2:是否允许输入控制(鼠标 / 键盘)。
* 高危能力,默认 false —— 需被控端显式开启后才可被远程操作。
*/
allow_input?: boolean;
/**
* v3:认证模式。
* - `psk`(默认):预共享密钥 + HMAC 挑战-应答,向后兼容
* - `ed25519`:Ed25519 签名挑战-应答,每个调用方独立密钥,配合 ACL 精细授权
*/
auth_mode?: 'psk' | 'ed25519';
/** v3:ed25519 模式下的能力级授权策略(默认拒绝) */
acl?: AclPolicy;
/**
* v21:允许接入的来源网段(CIDR / 单 IP / `*`)。**未配置 = 放行全部**。
*
* ⚠️ 默认放行是为了不打断现有部署,但「换到不可信网络就自动暴露」正是
* 第一批要收敛的风险 —— 建议在家庭/办公网段上显式配置,例如:
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
*/
allow_from?: string[];
/**
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
*/
security?: {
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
/** 最大并发连接数(默认 8) */
max_connections?: number;
/** 空闲断开(默认 30 分钟;0 = 关闭) */
idle_timeout_ms?: number;
};
/** v3+:审计日志配置 */
audit?: {
/** 是否启用(默认 true) */
enabled?: boolean;
/** 单文件上限字节(默认 10MB,超出即轮转) */
max_bytes?: number;
/** 保留的轮转文件数(默认 5) */
max_files?: number;
/** 是否记录参数预览(默认 false,仅记摘要;开启后自动脱敏) */
log_args?: boolean;
};
/** v4:局域网发现(UDP 心跳广播) */
discovery?: {
/** 是否广播(默认 true) */
enabled?: boolean;
/** 广播目标端口(默认 8766,控制端监听同一端口) */
port?: number;
/** 广播地址(默认 255.255.255.255) */
broadcast?: string;
/** 广播间隔毫秒(默认 5000) */
interval_ms?: number;
/**
* v22:广播详情级别。
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
*/
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
secret?: string;
};
/**
* v5:文件访问白名单(根目录列表)。
* 为空或缺省 = 不限制(访问控制完全交给 v3 的能力级 ACL);
* 非空 = 只允许读写这些目录之下的路径。
*/
fs_roots?: string[];
/**
* v6:Hub 模式。启用后**不再监听本地端口**,改为主动外连 Hub 注册,
* 适用于被控端位于 NAT / 无公网 IP 的环境。
*/
hub?: {
/** v12:最多维持的 Hub 槽位数(并发控制端上限),默认 2 */
max_slots?: number;
/** v12:常备(预热的)槽位数,默认 2;设为 1 可省一条空闲连接 */
warm_slots?: number;
enabled: boolean;
/** Hub 地址,例如 wss://hub.example.com/hub/agent */
url: string;
/** Hub 令牌 */
token: string;
/** 跳过 Hub 证书校验(自签证书场景) */
insecure?: boolean;
};
}
/** 数据目录:优先 NODEAGENT_HOME(跨平台一致),否则 ~/.nodeagent。 */
export function agentDir(): string {
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
}
export function agentConfigPath(): string {
return join(agentDir(), 'agent.json');
}
export function defaultAgentConfig(): AgentConfig {
return {
node_id: 'win_01',
host: '0.0.0.0',
port: 8765,
tls: true,
key: generateSharedKey(),
log_level: 'info',
auth_mode: 'psk',
};
}
/** 加载配置;不存在则生成默认配置并落盘(含随机密钥),返回 [config, isNew]。 */
export function loadAgentConfig(): { config: AgentConfig; isNew: boolean } {
const p = agentConfigPath();
if (existsSync(p)) {
// 剥离 UTF-8 BOM:Windows 上由 PowerShell 写出的 JSON 可能带 BOM,
// 直接 JSON.parse 会报 "Unexpected token ''"
const text = readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
const cfg = JSON.parse(text) as AgentConfig;
const envKey = process.env['NODEAGENT_KEY'];
if (envKey) cfg.key = envKey;
return { config: cfg, isNew: false };
}
const cfg = defaultAgentConfig();
saveAgentConfig(cfg);
return { config: cfg, isNew: true };
}
export function saveAgentConfig(cfg: AgentConfig): void {
const p = agentConfigPath();
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
try {
chmodSync(p, 0o600);
} catch {
/* Windows 上忽略 */
}
}