按 security-reports/ 的隐私审查台账逐项收口。纯脱敏与表述泛化, 不含任何业务逻辑变更;实测结论与排查步骤一字未动。 P1(可定位到具体设备/个人) - scripts/verify-b2.mjs:NA_URL 去掉硬编码缺省值(原指向一台真实机器),改为必填。 这是全仓库唯一「拿着就能连到某台真机」的常量。 - 测试账户名 pdverify → na-test(docs/VM-VERIFICATION.md 7 处、docs/console-plan.md 1 处) - 虚拟机与宿主的真实网段 → RFC 5737 文档保留段 192.0.2.x(含 CLOSE_WAIT 记录) - 跨项目名与私人称呼 → 中性表述(docs/PRD.md、docs/DEVELOPMENT.md、docs/console-plan.md 等) P2(低风险设备指纹) - 虚拟化产品及其 CLI / 访客工具名 → 中性表述(12 个文件 + 1 个工作流注释) - 测试目录 C:\nodeagent-test → C:\na-test - NAS 设备型号与系统名 → NAS 设备 / NAS 系统(docs/DEPLOY-LINUX.md、scripts/install-linux.sh、README.md) - 真机 CPU 型号、真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 泛化 - 单元测试示例段去掉真机 IP 尾八位:acl-v11 / net-scripts 的 fixture 与断言同步更新 - .gitignore 补 config.json / agent.json(含真实密钥与节点地址,绝不入库) - 顺带移除混入技术文档的「不构成投资建议」字样(与文档主题无关) 验证:pnpm test:unit 340/340 通过;独立扫描器对 235 个跟踪文件做 17 类模式 全量扫描,命中 0。
257 lines
13 KiB
JavaScript
257 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 构建 Windows 便携安装包。
|
||
*
|
||
* 用法: node scripts/pack.mjs [--node v22.20.0] [--out release]
|
||
*
|
||
* 产物: <out>/nodeagent-win-<arch>.zip(arch 默认 x64,见 --arch)
|
||
* ├── node.exe Node.js 运行时(免装 Node.js)
|
||
* ├── agent.mjs esbuild 打包的被控端(含全部依赖)
|
||
* ├── install.cmd 一键安装(双击即用,自提权 + 自动生成 PSK)
|
||
* ├── control.cmd 控制台菜单(启动/停止/状态/日志/卸载)
|
||
* ├── install.ps1 底层安装脚本(由 install.cmd 调用)
|
||
* ├── control.ps1 底层控制脚本(由 control.cmd 调用)
|
||
* └── uninstall.ps1 底层卸载脚本
|
||
*
|
||
* 注意:PSK.txt **不打进 zip**(zip 会作为公开 Release 资产发布,含密钥=公开密钥);
|
||
* 它在被控端首次运行 install.cmd 时生成,只留在本机安装目录。
|
||
*/
|
||
import { execFileSync, execSync } from 'node:child_process';
|
||
import {
|
||
copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync,
|
||
readFileSync, rmSync, statSync, writeFileSync,
|
||
} from 'node:fs';
|
||
import { get } from 'node:https';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { bundleAgent } from './lib/bundle-agent.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const root = join(__dirname, '..');
|
||
|
||
const args = process.argv.slice(2);
|
||
const getArg = (k, d) => {
|
||
const i = args.indexOf(k);
|
||
return i >= 0 && args[i + 1] ? args[i + 1] : d;
|
||
};
|
||
|
||
const NODE_VERSION = getArg('--node', 'v22.20.0');
|
||
// 2026-10-11:Windows 也分架构。x64 包在 **ARM Windows(含虚拟化平台的 Win11 ARM)** 上
|
||
// 能启动、能 listen,但 **TLS 握手不完成**(Prism 模拟下协议层异常)→ 必须按架构出包。
|
||
// 用法:node scripts/pack.mjs --arch arm64 → nodeagent-win-arm64.zip
|
||
const NODE_ARCH = getArg('--arch', 'x64');
|
||
const OUT_DIR = getArg('--out', join(root, 'release'));
|
||
const PKG_DIR = join(OUT_DIR, `nodeagent-win-${NODE_ARCH}`);
|
||
const MIRROR = process.env.NODEAGENT_NODE_MIRROR ?? 'https://npmmirror.com/mirrors/node';
|
||
const NODE_ZIP_URL = `${MIRROR}/${NODE_VERSION}/node-${NODE_VERSION}-win-${NODE_ARCH}.zip`;
|
||
|
||
const log = (msg) => console.log(msg);
|
||
const fail = (msg) => {
|
||
console.error(`✗ ${msg}`);
|
||
process.exit(1);
|
||
};
|
||
|
||
async function download(url, dest) {
|
||
log(` 下载 ${url}`);
|
||
return new Promise((resolve, reject) => {
|
||
const follow = (u, redirects = 0) => {
|
||
get(u, (res) => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
if (redirects > 5) return reject(new Error('重定向过多'));
|
||
return follow(res.headers.location, redirects + 1);
|
||
}
|
||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||
const file = createWriteStream(dest);
|
||
res.pipe(file);
|
||
file.on('finish', () => file.close(() => resolve()));
|
||
file.on('error', reject);
|
||
}).on('error', reject);
|
||
};
|
||
follow(url);
|
||
});
|
||
}
|
||
|
||
async function main() {
|
||
log('=== ① 构建 workspace ===');
|
||
execSync('pnpm -r build', { cwd: root, stdio: 'inherit' });
|
||
|
||
log('\n=== ② esbuild 打包被控端 ===');
|
||
// 版本源(根 package.json 的 version + git sha)与 esbuild 配置都在
|
||
// `scripts/lib/bundle-agent.mjs` 里**单一实现** —— 控制台打包(prepare-console.mjs)
|
||
// 用的是同一个函数,避免两处配置各自漂移。
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
const bundleOut = join(OUT_DIR, 'agent.mjs');
|
||
const bundled = await bundleAgent({ outfile: bundleOut });
|
||
log(` 版本 ${bundled.version} @ ${bundled.commit}(${(bundled.bytes / 1024 / 1024).toFixed(1)}MB)`);
|
||
|
||
log(`\n=== ③ 下载 Node.js Windows 运行时(${NODE_ARCH})===`);
|
||
// 缓存文件名**必须含架构**。曾用固定名 node-runtime.zip:release 先打 x64(下载并缓存),
|
||
// 再打 arm64 时误判「已缓存」→ 跳过下载 → 把 x64 的 node.exe 装进了 arm64 包。
|
||
// (真例:v2.0.1 的 nodeagent-win-arm64.zip 内 node.exe 实为 x86-64。)
|
||
const tmpZip = join(OUT_DIR, `node-runtime-${NODE_ARCH}.zip`);
|
||
if (existsSync(tmpZip)) {
|
||
log(' 已缓存,跳过下载');
|
||
} else {
|
||
await download(NODE_ZIP_URL, tmpZip);
|
||
}
|
||
|
||
log('\n=== ④ 组装便携包 ===');
|
||
rmSync(PKG_DIR, { recursive: true, force: true });
|
||
mkdirSync(PKG_DIR, { recursive: true });
|
||
execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' });
|
||
|
||
// 防呆 C:产物 node.exe 的 PE 架构必须与 --arch 一致。
|
||
// 这是**根治性守卫**:缓存串包、镜像发错、手工塞错文件,都会在此当场红,
|
||
// 而不是拖到「被控端在 ARM Windows 上 TLS 握手不完成」才由真机暴露。
|
||
// PE 头:偏移 0x3c 处是 PE 签名偏移,+4 是 Machine 字段(0x8664=x86-64,0xaa64=ARM64)。
|
||
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
|
||
const PE_NAME = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
|
||
const nodeExe = join(PKG_DIR, 'node.exe');
|
||
{
|
||
const head = readFileSync(nodeExe).subarray(0, 4096);
|
||
const machine = head.readUInt16LE(head.readUInt32LE(0x3c) + 4);
|
||
if (machine !== PE_MACHINE[NODE_ARCH]) {
|
||
fail(`node.exe 架构不符:--arch=${NODE_ARCH},实际 ${PE_NAME[machine] ?? `0x${machine.toString(16)}`}。` +
|
||
`多半是缓存串包 —— 删掉 ${OUT_DIR}/node-runtime-*.zip 后重试。`);
|
||
}
|
||
log(` node.exe 架构校验通过(${NODE_ARCH} / ${PE_NAME[machine]})`);
|
||
}
|
||
copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs'));
|
||
copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1'));
|
||
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
|
||
copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1'));
|
||
|
||
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
|
||
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
|
||
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
|
||
copyFileSync(join(root, 'scripts', cmd), join(PKG_DIR, cmd));
|
||
}
|
||
|
||
// 防呆 A:产物里的 .ps1 必须带 UTF-8 BOM,否则被控端的 **Windows PowerShell 5.1**
|
||
// 会按 ANSI/GBK 解析无 BOM 脚本,中文注释破坏字符串边界 → 整份脚本 ParseError。
|
||
// 真机踩过(2026-10-04):CI 全绿(冒烟用 pwsh/PS7),5.1 真机直接报
|
||
// 「","后面缺少表达式」。此处宁可在打包阶段失败,也不要把坏包发出去。
|
||
for (const ps1 of ['install.ps1', 'control.ps1', 'uninstall.ps1']) {
|
||
const p = join(PKG_DIR, ps1);
|
||
const head = readFileSync(p).subarray(0, 3);
|
||
if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) {
|
||
fail(`${ps1} 缺少 UTF-8 BOM —— Windows PowerShell 5.1 会解析失败。` +
|
||
`请先给 scripts/${ps1} 加上 BOM 再打包。`);
|
||
}
|
||
}
|
||
log(' .ps1 BOM 检查通过(兼容 Windows PowerShell 5.1)');
|
||
|
||
// 防呆 B:.cmd 里出现非 ASCII 字节 = 中文乱码/命令截断的隐患。
|
||
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
|
||
const buf = readFileSync(join(PKG_DIR, cmd));
|
||
const bad = [...buf].findIndex((b) => b > 0x7f);
|
||
if (bad >= 0) {
|
||
fail(`${cmd} 含非 ASCII 字节(偏移 ${bad})—— CMD 代码页无法可靠处理,` +
|
||
`请把该文件改为纯 ASCII,中文提示交给 .ps1。`);
|
||
}
|
||
// 行尾必须是 CRLF:LF-only 的 .cmd 在部分 Windows 环境会解析异常
|
||
const lf = buf.filter((b) => b === 0x0a).length;
|
||
const crlf = buf.filter((b, i) => b === 0x0a && buf[i - 1] === 0x0d).length;
|
||
if (lf !== crlf) {
|
||
fail(`${cmd} 含 ${lf - crlf} 处 LF-only 行尾 —— 打包前须统一为 CRLF。`);
|
||
}
|
||
}
|
||
log(' .cmd 纯 ASCII 检查通过(CMD 代码页安全)');
|
||
|
||
// PSK.txt:预置一把随机密钥,让 install.cmd 真正「零输入」。
|
||
// 用 --psk 传入指定密钥;不传则随机生成 32 字节 hex。
|
||
// 文件是纯 hex 文本,无需 BOM(install.cmd 用 `set /p` 读)。
|
||
const pskArg = getArg('--psk', '');
|
||
const psk = pskArg || randomBytes(32).toString('hex');
|
||
if (!/^[0-9a-fA-F]{32,128}$/.test(psk)) {
|
||
fail(`--psk 格式非法(需 32-128 位十六进制): ${psk}`);
|
||
}
|
||
writeFileSync(join(PKG_DIR, 'PSK.txt'), `${psk}\n`, 'utf8');
|
||
log(` PSK.txt 已生成(${psk.length} 位${pskArg ? ',来自 --psk' : ',随机'})`);
|
||
|
||
// README:给第一次上手的人看一眼该双击哪个文件
|
||
writeFileSync(join(PKG_DIR, 'README.txt'), [
|
||
'nodeagent - Windows 被控端',
|
||
'==========================',
|
||
'',
|
||
'【怎么装】双击 install.cmd',
|
||
' 1) 会弹一次「用户账户控制」→ 点「是」(必须管理员,装计划任务要用)',
|
||
' 2) 等它跑完,最后会打印连接信息',
|
||
' 3) 把那行 nodeagent connect ... 拿到 Mac 上执行',
|
||
'',
|
||
'【日常管理】双击 control.cmd',
|
||
' 1 状态 / 2 启动 / 3 停止 / 4 重启 / 5 日志 / 6 卸载 / 7 彻底卸载',
|
||
'',
|
||
'【免安装试用,不装也行】双击 run-portable.cmd',
|
||
' * 不需要管理员、不写计划任务、Ctrl+C 即停',
|
||
' * 首次运行自动生成配置与密钥(agent.json),并在控制台打印连接命令',
|
||
' * run-portable.cmd background = 最小化后台启动;stop = 结束;status = 看状态',
|
||
' * 适合临时测一下;长期用请走 install.cmd(有开机自启与进程守护)',
|
||
'',
|
||
'【密钥】本包**不含** PSK.txt(避免公开渠道泄露密钥)。密钥来源按序为:',
|
||
' 1) 若本目录已有 PSK.txt -> 直接用它',
|
||
' 2) 若本机已有 %USERPROFILE%\\.nodeagent\\agent.json -> 复用其中的 key',
|
||
' 3) 都没有 -> 自动生成一把随机密钥',
|
||
' 装完 / 免安装启动后,密钥会打印在屏幕上;也可用 run-portable.cmd status 查看',
|
||
' 这就是 Mac 端 --key 要填的值',
|
||
'',
|
||
'【注意】',
|
||
' * 整个文件夹可以固定放在 D:\\nodeagent,但**装完不要移动或改名**',
|
||
' * agent 每次开机登录后自动启动,无需再双击任何东西',
|
||
' * 首次运行若被杀软拦截,把本文件夹加入信任区后重试',
|
||
'',
|
||
].join('\n'), 'utf8');
|
||
|
||
const entries = readdirSync(PKG_DIR, { withFileTypes: true })
|
||
.filter((e) => e.isFile())
|
||
.map((e) => {
|
||
const st = statSync(join(PKG_DIR, e.name));
|
||
return `${e.name.padEnd(22)} ${(st.size / 1024).toFixed(0).padStart(8)} KB`;
|
||
});
|
||
log(entries.join('\n'));
|
||
|
||
log('\n=== ⑤ 打 zip ===');
|
||
const zipOut = join(OUT_DIR, `nodeagent-win-${NODE_ARCH}.zip`);
|
||
rmSync(zipOut, { force: true });
|
||
// ⚠️ 必须排除 PSK.txt:本 zip 会作为**公开的 GitHub Release 资产**发布,
|
||
// 把预共享密钥打进包里等于公开密钥。install.cmd 在文件缺失时会自动生成新密钥
|
||
// (生成在安装目录,只留在被控端本机),所以排除它不影响安装。
|
||
if (process.platform === 'win32') {
|
||
// Windows:GitHub runner / 多数 Windows 没有 Info-ZIP。
|
||
// 先删掉 PSK.txt(若存在)再压 —— 打包器无法按需排除单个文件。
|
||
const psk = join(PKG_DIR, 'PSK.txt');
|
||
if (existsSync(psk)) rmSync(psk, { force: true });
|
||
// ⚠️ 刻意不用 `Compress-Archive -Path '<dir>\*'`:它会把**目录名当前缀**写进条目
|
||
// (产物变成 `<dir>/node.exe`),而 macOS 的 `cd <dir> && zip -r .` 是**裸名** `node.exe`
|
||
// —— 两平台产物**结构不一致**。看着是小事,代价却不小:
|
||
// ① 按条目名取文件的校验会失配(2026-10-11 真例:CI 在 Windows 上报
|
||
// 「zip 内未找到条目 node.exe」,而 macOS 本地全绿 → 方向恰好相反);
|
||
// ② CI 校验的产物与发布产物**不同构**,校验价值打折。
|
||
// 改用 .NET 的 ZipFile::CreateFromDirectory —— 它打包**目录内容**,条目同样是裸名。
|
||
execFileSync(
|
||
'powershell',
|
||
[
|
||
'-NoProfile', '-Command',
|
||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||
`[IO.Compression.ZipFile]::CreateFromDirectory('${PKG_DIR}', '${zipOut}')`,
|
||
],
|
||
{ stdio: 'inherit' },
|
||
);
|
||
} else {
|
||
execSync(`cd "${PKG_DIR}" && zip -q -r "${zipOut}" . -x "PSK.txt"`, { stdio: 'inherit' });
|
||
}
|
||
|
||
const kb = statSync(zipOut).size / 1024;
|
||
const size = kb > 1024 ? `${(kb / 1024).toFixed(0)}M` : `${kb.toFixed(0)}K`;
|
||
log(`\n✓ 完成: ${zipOut}(${size})`);
|
||
log(' 分发方式:解压到固定目录 → 双击 install.cmd(自提权,零命令行知识)');
|
||
log(' 日后管理:双击 control.cmd');
|
||
}
|
||
|
||
try {
|
||
await main();
|
||
} catch (err) {
|
||
fail(err instanceof Error ? err.message : String(err));
|
||
}
|