aps-agent/apps/desktop/updater.cjs

571 lines
24 KiB
JavaScript
Raw Permalink Normal View History

/**
* APS 桌面端离线升级模块(纯 Node,无 Electron 依赖,可在 node --test 下直接测试)。
*
* 升级流程(applyUpdate):
* ① 校验升级包(manifest 版本号 / sha256 / size / 平台)
* ② 升级前备份当前安装目录(copy 或 listing 两种模式)
* ③ 触发安装(默认 NSIS 静默 /S;可注入 install 函数便于测试)
* ④ 安装后健康检查(GET /api/health,可选期望版本)
* ⑤ 任一步失败 → 自动回滚(恢复备份)+ 审计记录(本地 JSONL 为主,可选上报 /api/gov/audit)
*
* 审计取舍说明:
* - 本地日志文件({apsHome}/logs/upgrade-audit.jsonl,append-only)为默认审计通道;
* - 上报 /api/gov/audit/events(round-45 HH,矩阵 103 收口)为默认尝试的旁路通道:
* sidecar 启动时生成一次性审计 token(仅限本机、进程内,经 Electron main 注入
* auditApiUrl + auditToken + sidecarNonce);配置 auditApiUrl 即默认上报(带 Bearer
* token + x-aps-sidecar-nonce,过 SidecarIdentityApp nonce 门禁),端点不存在/403/
* 网络错误 → fail-soft 记本地、不阻塞升级流程(保持 round-43 语义);
* - web 模式该端点要求登录身份;desktop 模式由 nonce 门禁保护(见 docs/architecture/harness.md)。
*
* 回滚模式说明:
* - mode 'copy'(默认):整目录拷贝备份,可完整恢复文件内容;
* - mode 'listing':仅记录文件清单 + 各文件 sha256(适合大目录/仅审计),
* 此模式无法恢复内容,restoreBackup 会抛出 UpgradeError(需重装或改用 copy)。
*/
const crypto = require('crypto');
const fs = require('fs');
const fsp = fs.promises;
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const LOOPBACK_HOST = '127.0.0.1';
class UpgradeError extends Error {
/**
* @param {string} code 机器可读错误码(CHECKSUM_MISMATCH / SIZE_MISMATCH / VERSION_NOT_NEWER / ...)
* @param {string} message 人类可读描述
* @param {object} [details] 附加字段(期望值/实际值)
*/
constructor(code, message, details = {}) {
super(message);
this.name = 'UpgradeError';
this.code = code;
this.details = details;
}
}
/** 语义化版本比较:返回 -1 / 0 / 1(支持 主.次.补 及任意数字段) */
function compareVersions(a, b) {
const parse = (v) => String(v).split('.').map((part) => {
const n = Number.parseInt(part, 10);
return Number.isNaN(n) ? 0 : n;
});
const pa = parse(a);
const pb = parse(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
/** 计算文件 SHA-256(小写十六进制) */
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
/** 读取并校验升级清单;返回规范化对象 */
function loadManifest(manifestPath) {
let manifest;
try {
manifest = readJson(manifestPath);
} catch (error) {
throw new UpgradeError('MANIFEST_READ_FAILED', `升级清单读取失败: ${manifestPath}`, { cause: String(error) });
}
const required = ['schemaVersion', 'product', 'version', 'package'];
for (const key of required) {
if (manifest[key] === undefined || manifest[key] === null || manifest[key] === '') {
throw new UpgradeError('MANIFEST_INVALID', `升级清单缺少必需字段: ${key}`);
}
}
const pkg = manifest.package;
if (!pkg.fileName || typeof pkg.size !== 'number' || !/^[0-9a-f]{64}$/.test(pkg.sha256 || '')) {
throw new UpgradeError('MANIFEST_INVALID', '升级清单 package 字段不完整(需 fileName/size/sha256[64hex])');
}
return manifest;
}
/**
* 校验升级包本身:存在性 + size + sha256 + 平台。
* @param {object} options
* @param {object} options.manifest 已 loadManifest 的清单
* @param {string} options.packagePath 安装包绝对路径
* @param {string} [options.platform] 期望平台(默认 process.platform)
*/
async function verifyPackage({ manifest, packagePath, platform = process.platform }) {
const pkg = manifest.package;
let stat;
try {
stat = await fsp.stat(packagePath);
} catch (error) {
throw new UpgradeError('PACKAGE_NOT_FOUND', `升级安装包不存在: ${packagePath}`, { path: packagePath });
}
if (!stat.isFile()) {
throw new UpgradeError('PACKAGE_NOT_FILE', `升级安装包不是文件: ${packagePath}`);
}
if (manifest.platform && manifest.platform !== platform) {
throw new UpgradeError('PLATFORM_MISMATCH', `升级包平台不匹配: 清单=${manifest.platform} 本机=${platform}`, {
expected: manifest.platform,
actual: platform,
});
}
if (stat.size !== pkg.size) {
throw new UpgradeError('SIZE_MISMATCH', `升级包大小不匹配: 期望=${pkg.size} 实际=${stat.size}`, {
expected: pkg.size,
actual: stat.size,
});
}
const actualSha = await sha256File(packagePath);
if (actualSha !== pkg.sha256) {
throw new UpgradeError('CHECKSUM_MISMATCH', `升级包 SHA-256 校验失败: 期望=${pkg.sha256} 实际=${actualSha}`, {
expected: pkg.sha256,
actual: actualSha,
});
}
return { verified: true, size: stat.size, sha256: actualSha };
}
/**
* 校验版本:目标必须严格高于当前版本(拒绝回退/同版本)。
*/
function ensureNewerVersion({ currentVersion, targetVersion }) {
const cmp = compareVersions(targetVersion, currentVersion);
if (cmp <= 0) {
throw new UpgradeError(
'VERSION_NOT_NEWER',
`拒绝升级: 目标版本 ${targetVersion} 不高于当前版本 ${currentVersion}`,
{ currentVersion, targetVersion },
);
}
return true;
}
function copyDirRecursive(src, dest) {
return fsp.cp(src, dest, { recursive: true, force: true });
}
async function walkFiles(root, base = root, acc = []) {
const entries = await fsp.readdir(root, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(root, entry.name);
if (entry.isDirectory()) {
await walkFiles(absolute, base, acc);
} else {
acc.push(path.relative(base, absolute));
}
}
return acc;
}
async function sha256OfTree(root) {
const files = await walkFiles(root);
const listing = [];
for (const rel of files.sort()) {
const absolute = path.join(root, rel);
const stat = await fsp.stat(absolute);
listing.push({ path: rel, size: stat.size, sha256: await sha256File(absolute) });
}
return listing;
}
/**
* 备份安装目录。
* @param {object} options
* @param {string} options.installDir
* @param {string} options.backupDir
* @param {'copy'|'listing'} [options.mode] 默认 copy
*/
async function backupInstall({ installDir, backupDir, mode = 'copy' }) {
const resolvedInstall = path.resolve(installDir);
const resolvedBackup = path.resolve(backupDir);
if (resolvedInstall === resolvedBackup) {
throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同');
}
if (!fs.existsSync(resolvedInstall)) {
throw new UpgradeError('INSTALL_DIR_MISSING', `安装目录不存在: ${resolvedInstall}`);
}
await fsp.rm(resolvedBackup, { recursive: true, force: true });
await fsp.mkdir(resolvedBackup, { recursive: true });
const listing = await sha256OfTree(resolvedInstall);
if (mode === 'copy') {
await copyDirRecursive(resolvedInstall, path.join(resolvedBackup, 'files'));
} else if (mode !== 'listing') {
throw new UpgradeError('BACKUP_MODE_INVALID', `未知备份模式: ${mode}`);
}
await fsp.writeFile(
path.join(resolvedBackup, 'backup-manifest.json'),
`${JSON.stringify({ mode, createdAt: new Date().toISOString(), files: listing }, null, 2)}\n`,
'utf8',
);
return { mode, backupDir: resolvedBackup, fileCount: listing.length };
}
/**
* 从备份恢复安装目录(copy 模式可完整恢复;listing 模式仅能校验、不能恢复内容)。
*/
async function restoreBackup({ backupDir, installDir, expectedMode = 'copy' }) {
const resolvedBackup = path.resolve(backupDir);
const resolvedInstall = path.resolve(installDir);
if (resolvedInstall === resolvedBackup) {
throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同');
}
const backupManifestPath = path.join(resolvedBackup, 'backup-manifest.json');
if (!fs.existsSync(backupManifestPath)) {
throw new UpgradeError('BACKUP_MISSING', `备份不存在: ${backupManifestPath}`);
}
const backup = readJson(backupManifestPath);
if (backup.mode !== expectedMode) {
throw new UpgradeError(
'BACKUP_MODE_MISMATCH',
`备份模式不匹配: 备份=${backup.mode} 期望=${expectedMode}(listing 模式无法恢复内容,需重装)`,
{ backupMode: backup.mode, expectedMode },
);
}
if (backup.mode === 'listing') {
throw new UpgradeError('BACKUP_LISTING_ONLY', 'listing 模式备份不含文件内容,无法自动回滚(请重装)');
}
const filesSource = path.join(resolvedBackup, 'files');
if (!fs.existsSync(filesSource)) {
throw new UpgradeError('BACKUP_FILES_MISSING', `备份文件缺失: ${filesSource}`);
}
// 清空安装目录后整体恢复;不做跨盘/越界检查之外的危险操作。
await fsp.rm(resolvedInstall, { recursive: true, force: true });
await fsp.mkdir(resolvedInstall, { recursive: true });
await copyDirRecursive(filesSource, resolvedInstall);
return { restored: true, installDir: resolvedInstall, fileCount: backup.files.length };
}
/**
* 健康检查(round-44 FF):GET {base}/api/health,期望 HTTP 200。
* 版本比对:若 expectedVersion 且响应体含 version 字符串,则版本必须与 expectedVersion
* 完全一致(不一致 → HEALTH_VERSION_MISMATCH);响应体无 version 字段(或非字符串)时
* 退化为仅 HTTP 200 检查,不因缺失版本而失败(兼容旧网关/开发环境)。
* nonce 续接(round-45 HH):sidecarNonce 提供时携带 x-aps-sidecar-nonce 头——升级后新
* sidecar 进程启用 nonce 门禁(缺失/不匹配 → 403),安装后健康检查必须用新网关的 nonce;
* 403 且已携带 nonce 时错误 details.nonceMismatch=true,便于调用方区分“nonce 过期”与普通故障。
*/
function probeHealth({ base, expectedVersion, timeoutMs = 5_000, sidecarNonce = null }) {
const url = new URL('/api/health', base);
return new Promise((resolve, reject) => {
const headers = sidecarNonce ? { 'x-aps-sidecar-nonce': sidecarNonce } : {};
const request = http.get(url, { timeout: timeoutMs, headers }, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new UpgradeError('HEALTH_HTTP_STATUS', `健康检查 HTTP ${response.statusCode}`, {
url: url.href,
statusCode: response.statusCode,
nonceMismatch: response.statusCode === 403 && Boolean(sidecarNonce),
}));
return;
}
let parsed = null;
try { parsed = JSON.parse(body); } catch { /* 非 JSON 仍视为健康 */ }
const version = parsed && typeof parsed.version === 'string' ? parsed.version : null;
if (expectedVersion && version && version !== expectedVersion) {
reject(new UpgradeError('HEALTH_VERSION_MISMATCH', `健康检查版本不匹配: 期望=${expectedVersion} 实际=${version}`, {
expectedVersion,
actual: version,
}));
return;
}
resolve({
ok: true, statusCode: 200, version,
interfaceVersion: parsed?.interfaceVersion ?? null,
nonce: sidecarNonce, // 本次探测使用的 nonce(可追溯)
});
});
});
request.once('timeout', () => request.destroy(new UpgradeError('HEALTH_TIMEOUT', '健康检查超时')));
request.once('error', (error) => reject(error instanceof UpgradeError ? error : new UpgradeError('HEALTH_UNREACHABLE', `健康检查失败: ${error.message}`)));
});
}
/**
* NSIS 静默安装触发(默认安装器;测试注入 install 函数替代)。
* NSIS 参数:/S 静默;/D= 安装目录(须为最后参数、不带引号)。
*/
function nsisInstall({ installerPath, installDir }) {
return new Promise((resolve, reject) => {
const args = ['/S'];
if (installDir) args.push(`/D=${installDir}`);
const child = spawn(installerPath, args, {
windowsHide: true,
stdio: 'ignore',
detached: false,
});
const timer = setTimeout(() => {
child.kill();
reject(new UpgradeError('INSTALL_TIMEOUT', '安装器执行超时'));
}, 10 * 60 * 1000);
child.once('error', (error) => {
clearTimeout(timer);
reject(new UpgradeError('INSTALL_SPAWN_FAILED', `安装器启动失败: ${error.message}`));
});
child.once('exit', (code) => {
clearTimeout(timer);
if (code === 0) resolve({ exitCode: 0 });
else reject(new UpgradeError('INSTALL_EXIT_NONZERO', `安装器退出码非零: ${code}`, { exitCode: code }));
});
});
}
class Updater {
/**
* @param {object} options
* @param {string} [options.apsHome] 用户目录(审计日志写 {apsHome}/logs/upgrade-audit.jsonl)
* @param {object} [options.logger] console 兼容 logger
* @param {string} [options.auditApiUrl] 可选:上报 /api/gov/audit 的 base(如 sidecar origin)
* @param {string} [options.auditToken] 可选:sidecar 一次性审计 token(Bearer 携带)
* @param {string} [options.sidecarNonce] 可选:sidecar 进程 nonce(x-aps-sidecar-nonce 头,过网关身份门禁)
*/
constructor({ apsHome, logger = console, auditApiUrl = null, auditToken = null, sidecarNonce = null } = {}) {
this.apsHome = apsHome || path.join(require('os').homedir(), '.aps');
this.logger = logger;
this.auditApiUrl = auditApiUrl;
this.auditToken = auditToken;
this.sidecarNonce = sidecarNonce;
}
/**
* ① 检查更新:读取清单 + 校验安装包 + 版本比较。
* @returns {Promise<{updateAvailable:boolean,currentVersion:string,targetVersion:string,manifest:object,verified:boolean}>}
*/
async checkForUpdate({ manifestPath, packagePath, currentVersion, platform }) {
const manifest = loadManifest(manifestPath);
let verified = false;
let verifyError = null;
try {
if (packagePath) {
await verifyPackage({ manifest, packagePath, platform });
verified = true;
}
} catch (error) {
verifyError = error instanceof UpgradeError ? error : new UpgradeError('VERIFY_FAILED', String(error));
}
const updateAvailable = (() => {
try {
ensureNewerVersion({ currentVersion, targetVersion: manifest.version });
return true;
} catch {
return false;
}
})();
return {
updateAvailable,
currentVersion,
targetVersion: manifest.version,
manifest,
verified,
...(verifyError ? { verifyError: { code: verifyError.code, message: verifyError.message } } : {}),
};
}
/** 写本地审计 JSONL(append-only);配置 auditApiUrl 即默认尝试上报 /api/gov/audit/events。 */
async writeAudit({ event, category = 'UPGRADE', result = 'SUCCESS', payload = {} }) {
const entry = {
ts: new Date().toISOString(),
event,
category,
result,
payload,
};
const logsDir = path.join(this.apsHome, 'logs');
await fsp.mkdir(logsDir, { recursive: true });
const auditFile = path.join(logsDir, 'upgrade-audit.jsonl');
await fsp.appendFile(auditFile, `${JSON.stringify(entry)}\n`, 'utf8');
// round-45 HH:配置 auditApiUrl 即默认尝试上报(带 nonce+token);端点不存在/403/网络
// 错误一律 fail-soft——本地 JSONL 是主通道,上报失败不抛错、不阻塞升级(保持 round-43 语义)。
if (this.auditApiUrl) {
await this.reportAuditToApi(entry).catch((error) => {
this.logger.warn?.('[updater] 审计上报失败(仅本地记录):', error.message);
});
}
return entry;
}
/** 上报 /api/gov/audit/events(round-45 HH:端点不存在/403 → 抛错,由 writeAudit fail-soft 兜底)。
* payload 转换:本地 entry {ts,event,category,result,payload} → 服务端审计链契约
* {category,action,power,actor,rationale,target,timestamp,result,idempotencyKey};
* idempotencyKey = sha256(event+category+result+ts+payload) 的确定性指纹——同一 entry
* 重试(如响应丢失)会被服务端幂等去重,不重复落链。 */
async reportAuditToApi(entry) {
const url = new URL('/api/gov/audit/events', this.auditApiUrl);
const headers = {
'content-type': 'application/json',
};
if (this.auditToken) headers.authorization = `Bearer ${this.auditToken}`;
if (this.sidecarNonce) headers['x-aps-sidecar-nonce'] = this.sidecarNonce;
const idempotencyKey = crypto
.createHash('sha256')
.update(JSON.stringify([entry.event, entry.category, entry.result, entry.ts, entry.payload ?? {}]))
.digest('hex');
const body = {
category: entry.category || 'UPGRADE',
action: entry.event,
power: 'P0',
actor: 'desktop-updater',
target: { type: 'UPGRADE' },
rationale: { source: 'desktop-updater', payload: entry.payload ?? {} },
timestamp: entry.ts,
result: entry.result || 'SUCCESS',
idempotencyKey,
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(3_000),
});
if (!response.ok) {
throw new UpgradeError('AUDIT_API_HTTP', `审计上报 HTTP ${response.status}`, { status: response.status });
}
}
/**
* 完整升级流程(含失败自动回滚)。
* @param {object} options
* @param {string} options.manifestPath 升级清单路径
* @param {string} options.packagePath 安装包路径
* @param {string} options.installDir 安装目录
* @param {string} options.backupDir 备份目录
* @param {string} options.currentVersion 当前版本
* @param {string} [options.healthBase] 健康检查 base URL(默认 http://127.0.0.1:8000)
* @param {string} [options.expectedVersion] 健康检查期望版本(可选)
* @param {string} [options.sidecarNonce] 安装后健康检查用的 nonce(round-45 HH:升级后新
* sidecar 进程启用 nonce 门禁,探测必须带新网关的 nonce;缺省回落到构造时的
* this.sidecarNonce。新 nonce 由调用方(Electron main)在 sidecar 重启后经 SidecarManager
* 重新生成并传入,或在升级完成后由 main 更新 nonce 后再做最终健康检查)
* @param {'copy'|'listing'} [options.backupMode]
* @param {Function} [options.install] 安装函数(默认 nsisInstall);测试注入
* @param {boolean} [options.skipHealthCheck] 测试/无 sidecar 场景可跳过
* @returns {Promise<{ok:boolean,manifest:object,backup:object,health?:object}>}
*/
async applyUpdate(options) {
const {
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion,
healthBase = `http://${LOOPBACK_HOST}:8000`,
expectedVersion = null,
backupMode = 'copy',
install = nsisInstall,
skipHealthCheck = false,
sidecarNonce = this.sidecarNonce,
} = options;
const manifest = loadManifest(manifestPath);
const report = {
ok: false,
manifest,
currentVersion,
targetVersion: manifest.version,
nonce: sidecarNonce ?? null, // round-45 HH:本次流程使用的 sidecar nonce(供调用方续接)
};
try {
// ① 校验
this.logger.info?.('[updater] 校验升级包…');
await verifyPackage({ manifest, packagePath });
ensureNewerVersion({ currentVersion, targetVersion: manifest.version });
await this.writeAudit({
event: 'upgrade.verified',
payload: { fromVersion: currentVersion, toVersion: manifest.version, fileName: manifest.package.fileName },
});
// ② 备份
this.logger.info?.('[updater] 备份当前安装目录…');
const backup = await backupInstall({ installDir, backupDir, mode: backupMode });
report.backup = backup;
await this.writeAudit({ event: 'upgrade.backup', payload: backup });
// ③ 安装
this.logger.info?.('[updater] 执行安装…');
await install({ installerPath: packagePath, installDir, manifest });
await this.writeAudit({ event: 'upgrade.installed', payload: { toVersion: manifest.version } });
// ④ 健康检查
let health = null;
if (!skipHealthCheck) {
this.logger.info?.('[updater] 安装后健康检查…');
health = await probeHealth({ base: healthBase, expectedVersion, sidecarNonce });
report.health = health;
await this.writeAudit({ event: 'upgrade.health-check', payload: health });
}
report.ok = true;
await this.writeAudit({ event: 'upgrade.succeeded', payload: { fromVersion: currentVersion, toVersion: manifest.version } });
return report;
} catch (error) {
// ⑤ 失败自动回滚(round-46:升级途中 sidecar 重启导致的 nonce 轮换除外——
// 安装已成功,健康检查 403 仅因 nonce 过期 → 返回 pending 语义,由调用方
// (Electron main 的 upgrade-nonce 协调器)以新 nonce 补验,不误触发回滚)
const reason = error instanceof UpgradeError ? error.code : 'UNKNOWN';
const isNonceMismatch = error instanceof UpgradeError
&& error.details && error.details.nonceMismatch === true;
if (isNonceMismatch) {
await this.writeAudit({
event: 'upgrade.health-pending', result: 'PENDING',
payload: { reason: 'NONCE_MISMATCH', message: error.message },
}).catch(() => {});
this.logger.warn?.('[updater] 健康检查 nonce 已轮换(sidecar 重启),跳过回滚,交由调用方以新 nonce 补验');
report.ok = false;
report.deferredVerification = true;
report.reason = 'NONCE_MISMATCH';
return report;
}
await this.writeAudit({ event: 'upgrade.failed', result: 'FAILED', payload: { reason, message: error.message } }).catch(() => {});
this.logger.error?.('[updater] 升级失败,自动回滚:', error.message);
const rollback = await this.rollback({
backupDir,
installDir,
reason,
backupMode,
}).catch((rollbackError) => {
this.logger.error?.('[updater] 回滚失败:', rollbackError.message);
return { rollbackFailed: true, error: rollbackError.message };
});
report.rollback = rollback;
throw error;
}
}
/**
* 手动/自动回滚:恢复备份 + 审计。
*/
async rollback({ backupDir, installDir, reason = 'manual', backupMode = 'copy' }) {
const restored = await restoreBackup({ backupDir, installDir, expectedMode: backupMode });
await this.writeAudit({ event: 'rollback.done', result: 'SUCCESS', payload: { reason, ...restored } });
return restored;
}
/** 供测试/外部直接调用备份 */
backupInstall(options) {
return backupInstall(options);
}
}
module.exports = {
UpgradeError,
Updater,
compareVersions,
sha256File,
loadManifest,
verifyPackage,
ensureNewerVersion,
backupInstall,
restoreBackup,
probeHealth,
nsisInstall,
LOOPBACK_HOST,
};