614 lines
36 KiB
TypeScript
614 lines
36 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import type Database from "better-sqlite3";
|
|
import { z } from "zod";
|
|
import { acquireInstanceLock, loadConfig, prepareDataDirectories, type AppConfig } from "../config.js";
|
|
import { openDatabase } from "../db/index.js";
|
|
import { writeAudit } from "../audit.js";
|
|
import {
|
|
atomicSwitchDirectory,
|
|
atomicSwitchRelease,
|
|
applicationUpdateRuntimeHash,
|
|
compareSemver,
|
|
createSafeArchive,
|
|
detectPlatform,
|
|
downloadReleaseAsset,
|
|
extractSafeArchive,
|
|
fetchReleaseMetadata,
|
|
isNewerVersion,
|
|
normalizeReleasePermissions,
|
|
parseSemver,
|
|
runtimeHashFromLockfile,
|
|
selectReleaseAsset,
|
|
sanitizeAssetName,
|
|
validateHttpsUrl,
|
|
type ReleaseAsset,
|
|
type ReleaseMetadata,
|
|
type UrlPolicy,
|
|
} from "../update.js";
|
|
import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js";
|
|
import type { UpdateJobStatus } from "../../shared/contracts.js";
|
|
|
|
const updateRequestFileSchema = z.object({
|
|
jobId: z.string().uuid(),
|
|
operation: z.enum(["download", "apply"]).default("apply"),
|
|
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
|
metadataUrl: z.string().url(),
|
|
assetUrl: z.string().url(),
|
|
assetName: z.string().min(1).max(200),
|
|
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/i),
|
|
requestedAt: z.number().int().positive(),
|
|
currentLink: z.string().min(1),
|
|
releasesDir: z.string().min(1),
|
|
dataDir: z.string().min(1),
|
|
}).strict();
|
|
|
|
export type UpdateRequestFile = z.infer<typeof updateRequestFileSchema>;
|
|
|
|
/** Validate the hand-off from the unprivileged web process. URL and path
|
|
* fields are treated as untrusted data even though the file is local: the
|
|
* privileged runner must bind them to its own configuration before using it.
|
|
*/
|
|
export function validateUpdateRequest(requestValue: unknown, config: AppConfig): UpdateRequestFile {
|
|
const request = updateRequestFileSchema.parse(requestValue);
|
|
if (path.resolve(request.dataDir) !== path.resolve(config.dataDir)
|
|
|| path.resolve(request.currentLink) !== path.resolve(config.currentLink)
|
|
|| path.resolve(request.releasesDir) !== path.resolve(config.releasesDir)) {
|
|
throw new Error("更新请求目录与服务配置不一致");
|
|
}
|
|
const configuredMetadataUrl = validateHttpsUrl(config.updateMetadataUrl, {
|
|
allowedHosts: config.updateAllowedHosts,
|
|
baseUrl: config.updateMetadataUrl,
|
|
}).toString();
|
|
const requestedMetadataUrl = validateHttpsUrl(request.metadataUrl, {
|
|
allowedHosts: config.updateAllowedHosts,
|
|
baseUrl: config.updateMetadataUrl,
|
|
}).toString();
|
|
if (requestedMetadataUrl !== configuredMetadataUrl) throw new Error("更新请求源与服务配置不一致");
|
|
const requestAge = Date.now() - request.requestedAt;
|
|
if (requestAge > 24 * 60 * 60 * 1000 || requestAge < -5 * 60 * 1000) throw new Error("更新请求已过期");
|
|
return request;
|
|
}
|
|
|
|
export type UpdateRunOptions = UrlPolicy & {
|
|
sqlite?: Database.Database;
|
|
metadataUrl?: string | undefined;
|
|
assetUrl?: string | undefined;
|
|
assetName?: string | undefined;
|
|
version?: string | undefined;
|
|
expectedSha256?: string | undefined;
|
|
currentVersion?: string | undefined;
|
|
currentDir: string;
|
|
stagingDir: string;
|
|
backupArchivePath?: string | undefined;
|
|
dataBackupArchivePath?: string | undefined;
|
|
dataBackupSource?: string | undefined;
|
|
backupDir?: string | undefined;
|
|
releasesDir?: string | undefined;
|
|
currentLink?: string | undefined;
|
|
adminId?: string | undefined;
|
|
sessionHash?: string | undefined;
|
|
requestId?: string | undefined;
|
|
deferCompletion?: boolean | undefined;
|
|
maxBytes?: number | undefined;
|
|
dataBackupMaxBytes?: number | undefined;
|
|
fetchImpl?: typeof fetch;
|
|
platform?: ReturnType<typeof detectPlatform> | undefined;
|
|
jobId?: string | undefined;
|
|
publicKey?: string | undefined;
|
|
requireSignature?: boolean | undefined;
|
|
operation?: "download" | "apply" | undefined;
|
|
stagedPath?: string | undefined;
|
|
};
|
|
|
|
export type UpdateRunResult = {
|
|
jobId: string;
|
|
version: string;
|
|
asset: ReleaseAsset;
|
|
archivePath: string;
|
|
backupArchivePath?: string;
|
|
backupDir?: string;
|
|
};
|
|
|
|
function safeErrorMessage(error: unknown): string {
|
|
if (!(error instanceof Error)) return "更新失败";
|
|
const message = error.message;
|
|
if (message.length > 200 || /https?:\/\//i.test(message) || /authorization|token|secret|password|cookie|apikey/i.test(message)) return "更新失败";
|
|
return message || "更新失败";
|
|
}
|
|
|
|
function normalizedSha256(value: string | undefined): string | undefined {
|
|
if (value === undefined) return undefined;
|
|
const normalized = value.trim().replace(/^sha256:/i, "").toLowerCase();
|
|
if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效");
|
|
return normalized;
|
|
}
|
|
|
|
function writeJob(sqlite: Database.Database | undefined, jobId: string, values: {
|
|
status: UpdateJobStatus;
|
|
version: string;
|
|
platform: string;
|
|
releaseUrl?: string | undefined;
|
|
assetName?: string | undefined;
|
|
assetUrl: string;
|
|
expectedSha256?: string | undefined;
|
|
actualSha256?: string | undefined;
|
|
downloadPath?: string | undefined;
|
|
backupPath?: string | undefined;
|
|
sizeBytes?: number | undefined;
|
|
errorMessage?: string | undefined;
|
|
completedAt?: number | undefined;
|
|
adminId?: string | undefined;
|
|
sessionHash?: string | undefined;
|
|
requestId?: string | undefined;
|
|
requestedAt?: number | undefined;
|
|
startedAt?: number | undefined;
|
|
operation?: "download" | "apply" | undefined;
|
|
}): void {
|
|
if (!sqlite) return;
|
|
const now = Date.now();
|
|
const effectiveOperation = values.operation ?? (sqlite.prepare("SELECT operation FROM update_jobs WHERE id=?").get(jobId) as { operation?: "download" | "apply" } | undefined)?.operation ?? "apply";
|
|
sqlite.prepare(`
|
|
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
|
|
operation, status, version, platform, release_url, asset_name, asset_url,
|
|
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
|
|
created_at, updated_at, completed_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
|
|
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
|
|
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
|
|
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
|
|
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
|
|
operation=excluded.operation,
|
|
-- Terminal rows are immutable from the runner's ordinary progress
|
|
-- writes. In particular, a stale/replayed request must not resurrect a
|
|
-- failed job as queued/downloading/etc.
|
|
status=CASE WHEN update_jobs.status IN ('cancelled', 'failed', 'completed') THEN update_jobs.status ELSE excluded.status END,
|
|
version=excluded.version, platform=excluded.platform,
|
|
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
|
|
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
|
|
asset_url=excluded.asset_url,
|
|
expected_sha256=COALESCE(excluded.expected_sha256, update_jobs.expected_sha256),
|
|
actual_sha256=COALESCE(excluded.actual_sha256, update_jobs.actual_sha256),
|
|
download_path=COALESCE(excluded.download_path, update_jobs.download_path),
|
|
backup_path=COALESCE(excluded.backup_path, update_jobs.backup_path),
|
|
size_bytes=COALESCE(excluded.size_bytes, update_jobs.size_bytes),
|
|
error_message=COALESCE(excluded.error_message, update_jobs.error_message),
|
|
updated_at=excluded.updated_at,
|
|
completed_at=COALESCE(excluded.completed_at, update_jobs.completed_at)
|
|
-- Do not let a delayed runner replay overwrite any field on a terminal
|
|
-- row. The predicate is part of the same SQLite upsert, so a finalizer
|
|
-- racing this write still wins atomically instead of leaving a partially
|
|
-- mutated completed/failed/cancelled record.
|
|
WHERE update_jobs.status NOT IN ('cancelled', 'failed', 'completed')
|
|
`).run(
|
|
jobId,
|
|
values.adminId ?? null,
|
|
values.sessionHash ?? null,
|
|
values.requestId ?? null,
|
|
values.requestedAt ?? null,
|
|
values.startedAt ?? null,
|
|
effectiveOperation,
|
|
values.status,
|
|
values.version,
|
|
values.platform,
|
|
values.releaseUrl ?? null,
|
|
values.assetName ?? null,
|
|
values.assetUrl,
|
|
values.expectedSha256 ?? null,
|
|
values.actualSha256 ?? null,
|
|
values.downloadPath ?? null,
|
|
values.backupPath ?? null,
|
|
values.sizeBytes ?? null,
|
|
values.errorMessage ?? null,
|
|
now,
|
|
now,
|
|
values.completedAt ?? null,
|
|
);
|
|
}
|
|
|
|
function updateJob(sqlite: Database.Database | undefined, jobId: string, values: Parameters<typeof writeJob>[2]): void {
|
|
writeJob(sqlite, jobId, values);
|
|
}
|
|
|
|
function clearTransientJobPath(sqlite: Database.Database | undefined, jobId: string): void {
|
|
if (!sqlite) return;
|
|
sqlite.prepare("UPDATE update_jobs SET download_path=NULL, updated_at=? WHERE id=?").run(Date.now(), jobId);
|
|
}
|
|
|
|
async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<typeof detectPlatform>): Promise<{ release?: ReleaseMetadata; asset: ReleaseAsset; version: string; releaseUrl?: string }> {
|
|
if (options.metadataUrl) {
|
|
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
|
|
const release = await fetchReleaseMetadata(metadataUrl, options);
|
|
let runtimeHash: string | undefined;
|
|
try {
|
|
runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml")));
|
|
} catch {
|
|
// Fall back to the full archive when the current installation predates
|
|
// runtime fingerprints or is missing deployment provenance.
|
|
}
|
|
let asset = options.assetUrl && !options.requireSignature
|
|
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
|
|
: selectReleaseAsset(release, platform, runtimeHash);
|
|
if (!asset) throw new Error("没有匹配当前平台的更新文件");
|
|
const integrity = await attachSidecarHash(release, asset, {
|
|
allowedHosts: options.allowedHosts ?? [],
|
|
baseUrl: metadataUrl.toString(),
|
|
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
|
|
timeoutMs: options.timeoutMs,
|
|
publicKey: options.publicKey,
|
|
requireSignature: options.requireSignature,
|
|
});
|
|
asset = integrity.asset;
|
|
if (options.requireSignature && !integrity.signatureVerified) throw new Error("更新发布签名校验失败");
|
|
if (options.version && compareSemver(options.version, release.version) !== 0) throw new Error("更新版本与发布信息不一致");
|
|
return { release, asset: { ...asset, name: sanitizeAssetName(asset.name) }, version: release.version, releaseUrl: metadataUrl.toString() };
|
|
}
|
|
if (!options.assetUrl || !options.version) throw new Error("必须提供 metadata URL,或同时提供更新文件地址和版本号");
|
|
const assetUrl = validateHttpsUrl(options.assetUrl, options);
|
|
parseSemver(options.version);
|
|
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
|
|
}
|
|
|
|
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
|
|
const resolved = path.resolve(directory);
|
|
await mkdir(resolved, { recursive: true, mode: 0o700 });
|
|
const info = await lstat(resolved).catch(() => null);
|
|
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
|
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) {
|
|
throw new Error("更新工作目录必须是 root 拥有且权限为 0700");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** Validate a queued staged directory before a root process consumes it. */
|
|
async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise<string> {
|
|
const rootResolved = path.resolve(workspaceRoot);
|
|
const rootInfo = await lstat(rootResolved).catch(() => null);
|
|
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
|
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) {
|
|
throw new Error("更新工作目录权限无效");
|
|
}
|
|
const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); });
|
|
const resolved = path.resolve(candidate);
|
|
if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效");
|
|
const info = await lstat(resolved).catch(() => null);
|
|
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效");
|
|
const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); });
|
|
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效");
|
|
return real;
|
|
}
|
|
|
|
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
|
|
const platform = options.platform ?? detectPlatform();
|
|
const jobId = options.jobId ?? randomUUID();
|
|
const operation = options.operation ?? "apply";
|
|
const sqlite = options.sqlite;
|
|
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
|
|
try {
|
|
resolved = await resolveRelease(options, platform);
|
|
const suppliedSha256 = normalizedSha256(options.expectedSha256);
|
|
const expectedSha256 = normalizedSha256(options.requireSignature && options.metadataUrl ? resolved.asset.sha256 : suppliedSha256 ?? resolved.asset.sha256);
|
|
if (options.requireSignature && options.metadataUrl && suppliedSha256 && suppliedSha256 !== expectedSha256) throw new Error("更新校验值与发布信息不一致");
|
|
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
|
|
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
|
|
writeJob(options.sqlite, jobId, {
|
|
operation, status: "queued", version: resolved.version, platform: platform.target,
|
|
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
|
|
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
|
|
requestId: options.requestId, requestedAt: Date.now(),
|
|
});
|
|
|
|
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
|
|
let keepWorkspace = false;
|
|
const workspace = operation === "download"
|
|
? path.join(path.resolve(options.stagingDir), `update-${jobId}`)
|
|
: await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
|
if (operation === "download") await mkdir(workspace, { recursive: false, mode: 0o700 });
|
|
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
|
|
try {
|
|
if (sqlite) {
|
|
const claim = sqlite.prepare("UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'").run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId);
|
|
if (claim.changes !== 1) throw new Error("更新任务已取消或已被其他进程接管");
|
|
} else {
|
|
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
|
|
}
|
|
const progressStartedAt = Date.now();
|
|
let lastProgressWrite = 0;
|
|
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, {
|
|
...options,
|
|
onProgress: (downloadedBytes, totalBytes) => {
|
|
const now = Date.now();
|
|
if (!options.sqlite || now - lastProgressWrite < 250) return;
|
|
lastProgressWrite = now;
|
|
const elapsed = Math.max(1, now - progressStartedAt);
|
|
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
|
|
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, progressStartedAt, speedBps, now, jobId);
|
|
},
|
|
});
|
|
if (options.sqlite) {
|
|
const finishedAt = Date.now();
|
|
const elapsed = Math.max(1, finishedAt - progressStartedAt);
|
|
options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.size, downloaded.size, progressStartedAt, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
|
|
}
|
|
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
|
|
updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
|
|
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
|
const stagedDir = path.join(workspace, "payload");
|
|
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
|
if (applicationUpdateRuntimeHash(resolved.asset.name)) {
|
|
const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); });
|
|
const currentInfo = await lstat(currentRelease).catch(() => null);
|
|
if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
|
|
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
|
|
const source = path.join(currentRelease, entry);
|
|
const sourceInfo = await lstat(source).catch(() => null);
|
|
if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新");
|
|
await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false });
|
|
}
|
|
}
|
|
await normalizeReleasePermissions(stagedDir);
|
|
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
|
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
|
if (sqlite) {
|
|
const staged = sqlite.prepare("UPDATE update_jobs SET status='staged', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')").run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
|
|
if (staged.changes !== 1) throw new Error("更新任务已取消,已停止继续处理");
|
|
} else {
|
|
updateJob(options.sqlite, jobId, { operation, status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: workspace });
|
|
}
|
|
|
|
if (operation === "download") {
|
|
keepWorkspace = true;
|
|
return { jobId, version: resolved.version, asset: resolved.asset, archivePath };
|
|
}
|
|
|
|
let backupArchivePath: string | undefined;
|
|
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
|
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: options.dataBackupArchivePath });
|
|
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, {
|
|
maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024,
|
|
});
|
|
}
|
|
if (options.backupArchivePath) {
|
|
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
|
|
const backupSource = await realpath(options.currentDir).catch(() => options.currentDir);
|
|
await createSafeArchive(backupSource, options.backupArchivePath, {
|
|
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
|
|
});
|
|
backupArchivePath = options.backupArchivePath;
|
|
}
|
|
updateJob(options.sqlite, jobId, { status: "applying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
|
|
const switchedBackup = options.releasesDir && options.currentLink
|
|
? (await atomicSwitchRelease(stagedDir, options.currentLink, options.releasesDir, resolved.version)).previousTarget
|
|
: await atomicSwitchDirectory(stagedDir, options.currentDir, options.backupDir);
|
|
const completedAt = Date.now();
|
|
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
|
|
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
|
|
} finally {
|
|
if (!keepWorkspace) {
|
|
await rm(workspace, { recursive: true, force: true });
|
|
clearTransientJobPath(options.sqlite, jobId);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
|
|
const fallbackAsset = resolved?.asset ?? { name: options.assetName ?? "unknown", url: options.assetUrl ?? "https://invalid.invalid/unknown" };
|
|
updateJob(options.sqlite, jobId, { status: "failed", version: fallbackVersion, platform: platform.target, releaseUrl: resolved?.releaseUrl, assetName: fallbackAsset.name, assetUrl: fallbackAsset.url, expectedSha256: options.expectedSha256 ?? fallbackAsset.sha256, errorMessage: safeErrorMessage(error) });
|
|
throw new Error(safeErrorMessage(error));
|
|
}
|
|
}
|
|
|
|
export function finalizeUpdateJob(
|
|
sqlite: Database.Database,
|
|
jobId: string,
|
|
status: "completed" | "failed",
|
|
message?: string,
|
|
): void {
|
|
sqlite.transaction(() => {
|
|
const row = sqlite.prepare(`
|
|
SELECT id, status, version, platform, admin_id AS adminId,
|
|
request_id AS requestId, session_hash AS sessionHash
|
|
FROM update_jobs WHERE id=?
|
|
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
|
|
if (!row) throw new Error("更新任务不存在");
|
|
// A failed finalization can be retried by the runner. Once it has been
|
|
// committed, make retries a no-op so the error and audit trail stay stable.
|
|
if (row.status === status) return;
|
|
// A completed release is terminal. A delayed recovery process must never
|
|
// be able to downgrade it to failed after the service was healthy.
|
|
if (row.status === "completed" && status === "failed") throw new Error("更新任务状态不允许完成");
|
|
const canComplete = row.status === "applying" || row.status === "completed";
|
|
const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed";
|
|
if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成");
|
|
const now = Date.now();
|
|
const safeFailureMessage = status === "failed"
|
|
? (message?.trim() ? safeErrorMessage(new Error(message)) : "新版本健康检查失败,已恢复上一版本")
|
|
: null;
|
|
const result = sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=? AND status=?").run(status, safeFailureMessage, now, now, jobId, row.status);
|
|
if (result.changes !== 1) return;
|
|
writeAudit(sqlite, {
|
|
requestId: row.requestId || randomUUID(),
|
|
actorAdminId: row.adminId,
|
|
action: status === "completed" ? "update.completed" : "update.failed",
|
|
targetType: "update",
|
|
targetId: jobId,
|
|
outcome: status === "completed" ? "success" : "failure",
|
|
after: { status, version: row.version, platform: row.platform, ...(status === "failed" ? { reason: "health_check_failed" } : {}) },
|
|
});
|
|
})();
|
|
}
|
|
|
|
export async function applyStagedUpdate(options: {
|
|
sqlite: Database.Database;
|
|
jobId: string;
|
|
version: string;
|
|
stagedPath: string;
|
|
currentDir: string;
|
|
currentLink: string;
|
|
releasesDir: string;
|
|
backupArchivePath?: string;
|
|
dataBackupArchivePath?: string;
|
|
dataBackupSource?: string;
|
|
maxBytes?: number;
|
|
dataBackupMaxBytes?: number;
|
|
workspaceRoot?: string;
|
|
}): Promise<void> {
|
|
const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record<string, unknown> | undefined;
|
|
if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态");
|
|
if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致");
|
|
const stagedPath = options.workspaceRoot
|
|
? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot)
|
|
: options.stagedPath;
|
|
const payload = path.join(stagedPath, "payload");
|
|
const payloadInfo = await lstat(payload).catch(() => null);
|
|
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
|
await normalizeReleasePermissions(payload);
|
|
let switchedBackup: string | undefined;
|
|
let committed = false;
|
|
try {
|
|
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
|
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.dataBackupArchivePath });
|
|
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, { maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024 });
|
|
}
|
|
if (options.backupArchivePath) {
|
|
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath });
|
|
const source = await realpath(options.currentDir).catch(() => options.currentDir);
|
|
await createSafeArchive(source, options.backupArchivePath, { maxBytes: options.maxBytes ?? 512 * 1024 * 1024 });
|
|
}
|
|
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath, startedAt: Date.now() });
|
|
switchedBackup = (await atomicSwitchRelease(payload, options.currentLink, options.releasesDir, options.version)).previousTarget;
|
|
committed = true;
|
|
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
|
} catch (error) {
|
|
if (!committed) {
|
|
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
|
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "failed", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), errorMessage: safeErrorMessage(error) });
|
|
clearTransientJobPath(options.sqlite, options.jobId);
|
|
}
|
|
throw error;
|
|
}
|
|
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, backupPath: switchedBackup ?? options.backupArchivePath });
|
|
clearTransientJobPath(options.sqlite, options.jobId);
|
|
}
|
|
|
|
function arg(name: string): string | undefined {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
|
|
export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
|
const finalizeJobId = arg("--finalize-job");
|
|
if (finalizeJobId) {
|
|
const finalStatus = arg("--finalize-status");
|
|
if (finalStatus !== "completed" && finalStatus !== "failed") throw new Error("更新完成状态无效");
|
|
prepareDataDirectories(config);
|
|
const database = openDatabase(config);
|
|
try {
|
|
finalizeUpdateJob(database.sqlite, finalizeJobId, finalStatus, arg("--message"));
|
|
} finally {
|
|
database.sqlite.close();
|
|
}
|
|
return;
|
|
}
|
|
const requestPath = arg("--request-file");
|
|
const metadataUrl = arg("--metadata-url");
|
|
const assetUrl = arg("--asset-url");
|
|
const version = arg("--version");
|
|
let request: UpdateRequestFile | undefined;
|
|
if (requestPath) {
|
|
if (path.resolve(requestPath) !== path.resolve(config.updateRequestPath)) throw new Error("更新请求文件路径无效");
|
|
try {
|
|
const requestInfo = await lstat(requestPath);
|
|
if (!requestInfo.isFile() || requestInfo.isSymbolicLink() || (requestInfo.mode & 0o077) !== 0) throw new Error("权限");
|
|
request = validateUpdateRequest(JSON.parse(await readFile(requestPath, "utf8")), config);
|
|
} catch { throw new Error("更新请求文件无效"); }
|
|
}
|
|
const effectiveMetadataUrl = request ? config.updateMetadataUrl : metadataUrl;
|
|
const effectiveAssetUrl = request ? undefined : assetUrl;
|
|
const effectiveVersion = request?.version ?? version;
|
|
const deferCompletion = request ? process.argv.includes("--defer-completion") : false;
|
|
const currentDir = request?.currentLink ?? arg("--current-dir") ?? config.projectRoot;
|
|
const stagingDir = arg("--staging-dir") ?? (request ? config.updateWorkspaceDir : config.stagingDir);
|
|
const backupArchive = arg("--backup-archive") ?? (request ? path.join(config.dataDir, "backups", `update-${request.jobId}.tar.gz`) : undefined);
|
|
const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined);
|
|
const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []);
|
|
prepareDataDirectories(config);
|
|
if (request) await ensurePrivilegedWorkspace(stagingDir);
|
|
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
|
|
// The download phase intentionally runs beside the live app so users keep
|
|
// access while the archive is fetched and staged. SQLite WAL plus the
|
|
// configured busy timeout serializes writes; the exclusive process lock is
|
|
// reserved for apply/rollback, when the service is stopped by systemd.
|
|
const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config);
|
|
const database = openDatabase(config);
|
|
try {
|
|
if (request?.operation === "apply") {
|
|
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
|
|
if (staged?.status === "staged" && staged.operation === "apply") {
|
|
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
|
|
const root = path.resolve(config.updateWorkspaceDir);
|
|
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
|
|
await applyStagedUpdate({
|
|
sqlite: database.sqlite,
|
|
jobId: request.jobId,
|
|
version: request.version,
|
|
stagedPath: candidate,
|
|
currentDir,
|
|
currentLink: request.currentLink,
|
|
releasesDir: request.releasesDir,
|
|
workspaceRoot: root,
|
|
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
|
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
|
dataBackupSource: config.dataDir,
|
|
maxBytes: config.updateMaxBytes,
|
|
dataBackupMaxBytes: config.maxTotalBytes,
|
|
});
|
|
console.log(`更新已切换:${request.version}`);
|
|
return;
|
|
}
|
|
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
|
|
// A direct one-click request starts in queued/apply. Older clients do
|
|
// not have a separate download step, so fall through to runUpdate,
|
|
// which downloads, verifies, backs up, and switches the release in one
|
|
// transaction. A staged request still takes the branch above.
|
|
}
|
|
const result = await runUpdate({
|
|
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
|
|
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
|
|
...(effectiveVersion ? { version: effectiveVersion } : {}),
|
|
...((request ? undefined : arg("--sha256")) ? { expectedSha256: arg("--sha256") } : {}),
|
|
currentDir,
|
|
stagingDir,
|
|
...(request ? { currentLink: request.currentLink, releasesDir: request.releasesDir } : {}),
|
|
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
|
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive, dataBackupSource: config.dataDir } : {}),
|
|
...((arg("--backup-dir")) ? { backupDir: arg("--backup-dir") } : {}),
|
|
allowedHosts: allowedHosts.length ? allowedHosts : config.updateAllowedHosts,
|
|
timeoutMs: config.updateTimeoutMs,
|
|
maxBytes: config.updateMaxBytes,
|
|
dataBackupMaxBytes: config.maxTotalBytes,
|
|
currentVersion: config.appVersion,
|
|
...(deferCompletion ? { deferCompletion: true } : {}),
|
|
...(request?.operation === "download" ? { operation: "download" as const } : {}),
|
|
...(request ? { jobId: request.jobId } : {}),
|
|
publicKey: config.updatePublicKey,
|
|
requireSignature: config.updateRequireSignature,
|
|
sqlite: database.sqlite,
|
|
});
|
|
console.log(`更新完成:${result.version}`);
|
|
} finally {
|
|
database.sqlite.close();
|
|
release();
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
main().catch((error) => {
|
|
console.error(safeErrorMessage(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|