- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入 - server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After - 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断 - 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务 - cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise - server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑 - 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
864 lines
49 KiB
TypeScript
864 lines
49 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, 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,
|
|
compareSemver,
|
|
createSafeArchive,
|
|
detectPlatform,
|
|
downloadReleaseAsset,
|
|
extractSafeArchive,
|
|
fetchReleaseMetadata,
|
|
isNewerVersion,
|
|
normalizeReleasePermissions,
|
|
parseSemver,
|
|
selectReleaseAsset,
|
|
sanitizeAssetName,
|
|
validateHttpsUrl,
|
|
verifySha256,
|
|
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 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);
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Ownership expectation for a directory consumed by the privileged updater.
|
|
*
|
|
* `-1` disables the uid comparison while keeping the symlink and mode checks.
|
|
* Production always passes a concrete uid (0 for the root-owned
|
|
* `<installPrefix>/.update-work`), so the check never depends on the effective
|
|
* uid of the current process and remains runnable from a non-root test.
|
|
*/
|
|
export type DirectoryOwnerUid = number;
|
|
|
|
/** The staging area owned by the unprivileged web process and the private
|
|
* root-owned workspace are deliberately separate trust domains. */
|
|
export class StagedWorkspaceError extends Error {
|
|
readonly reason: string;
|
|
constructor(message: string, reason: string) {
|
|
super(message);
|
|
this.name = "StagedWorkspaceError";
|
|
this.reason = reason;
|
|
}
|
|
}
|
|
|
|
/** Owner uid of an existing path, or -1 when it cannot be inspected. */
|
|
export async function directoryOwnerUid(targetPath: string): Promise<DirectoryOwnerUid> {
|
|
const info = await lstat(path.resolve(targetPath)).catch(() => null);
|
|
return info?.uid ?? -1;
|
|
}
|
|
|
|
/**
|
|
* Resolve a privileged workspace root to its canonical path.
|
|
*
|
|
* The root itself may be reached through a symlinked ancestor (for example
|
|
* `/tmp` on macOS), so only the final component is required to be a real,
|
|
* non-symlink directory with private permissions and the expected owner.
|
|
*/
|
|
async function canonicalizePrivilegedRoot(directory: string, expectedUid: DirectoryOwnerUid, message: string): Promise<string> {
|
|
const resolved = path.resolve(directory);
|
|
await mkdir(resolved, { recursive: true, mode: 0o700 });
|
|
const info = await lstat(resolved).catch(() => null);
|
|
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || (expectedUid >= 0 && info.uid !== expectedUid)) {
|
|
throw new Error(message);
|
|
}
|
|
const real = await realpath(resolved).catch(() => { throw new Error(message); });
|
|
const realInfo = await lstat(real).catch(() => null);
|
|
if (!realInfo?.isDirectory() || realInfo.isSymbolicLink() || (realInfo.mode & 0o077) !== 0 || (expectedUid >= 0 && realInfo.uid !== expectedUid)) {
|
|
throw new Error(message);
|
|
}
|
|
return real;
|
|
}
|
|
|
|
/** Assert that `candidate` is a real, private, expected-owner directory below `root`. */
|
|
async function assertStagedDirectory(candidate: string, root: string, expectedUid: DirectoryOwnerUid): Promise<string> {
|
|
const resolved = path.resolve(candidate);
|
|
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
|
|
const info = await lstat(resolved).catch(() => null);
|
|
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) throw new StagedWorkspaceError("更新暂存目录权限无效", "staged_workspace_insecure");
|
|
if (expectedUid >= 0 && info.uid !== expectedUid) throw new StagedWorkspaceError("更新暂存目录属主无效", "staged_workspace_insecure");
|
|
const real = await realpath(resolved).catch(() => { throw new StagedWorkspaceError("更新暂存目录无效", "staged_workspace_invalid"); });
|
|
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
|
|
return real;
|
|
}
|
|
|
|
async function ensurePrivilegedWorkspace(directory: string, expectedUid: DirectoryOwnerUid): Promise<string> {
|
|
return canonicalizePrivilegedRoot(directory, expectedUid, "更新工作目录必须是 root 拥有且权限为 0700");
|
|
}
|
|
|
|
/**
|
|
* Rebuild the staged workspace location for `jobId` instead of trusting the
|
|
* `download_path` column: the runner clears that column whenever it releases
|
|
* a workspace (`clearTransientJobPath`), and a nulled column cannot be used to
|
|
* find a payload that is still on disk waiting for the apply step.
|
|
*
|
|
* Candidate order:
|
|
* 1. `<stagingRoot>/update-<jobId>` (web download workspace)
|
|
* 2. `<stagingRoot>/update-<jobId>-*` (mkdtemp variant)
|
|
* 3. the recorded `download_path`, but only while it stays inside the root
|
|
*/
|
|
export async function locateStagedWorkspace(options: {
|
|
jobId: string;
|
|
downloadPath?: string | null | undefined;
|
|
stagingRoot: string;
|
|
expectedUid: DirectoryOwnerUid;
|
|
}): Promise<string> {
|
|
const root = await canonicalizePrivilegedRoot(options.stagingRoot, options.expectedUid, "更新暂存根目录权限无效");
|
|
const prefix = `update-${options.jobId}`;
|
|
const candidates = [path.join(root, prefix)];
|
|
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries.filter((candidate) => candidate.name.startsWith(`${prefix}-`)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
candidates.push(path.join(root, entry.name));
|
|
}
|
|
const recorded = options.downloadPath?.trim();
|
|
if (recorded && path.isAbsolute(recorded)) {
|
|
const resolvedRecorded = path.resolve(recorded);
|
|
if (resolvedRecorded.startsWith(`${root}${path.sep}`)) candidates.push(resolvedRecorded);
|
|
// A recorded path outside the staging root is never consumed. Reject it
|
|
// loudly when it exists so the operator sees the real cause instead of a
|
|
// generic "re-download" message.
|
|
else if (await lstat(resolvedRecorded).catch(() => null)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid");
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const candidate of candidates) {
|
|
const resolved = path.resolve(candidate);
|
|
if (seen.has(resolved)) continue;
|
|
seen.add(resolved);
|
|
// An existing candidate must satisfy every constraint: skipping it would
|
|
// hand the root process whatever else happens to sit in the staging area.
|
|
if (!(await lstat(resolved).catch(() => null))) continue;
|
|
return assertStagedDirectory(resolved, root, options.expectedUid);
|
|
}
|
|
throw new StagedWorkspaceError("暂存目录已不存在,请重新下载", "staged_workspace_missing");
|
|
}
|
|
|
|
/** Copy a verified payload tree without following or preserving symlinks. */
|
|
async function copyReleaseTree(source: string, target: string): Promise<void> {
|
|
await mkdir(target, { recursive: false, mode: 0o700 });
|
|
const entries = await readdir(source, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const from = path.join(source, entry.name);
|
|
const to = path.join(target, entry.name);
|
|
if (entry.isSymbolicLink()) throw new Error("更新暂存内容包含符号链接");
|
|
if (entry.isDirectory()) await copyReleaseTree(from, to);
|
|
else if (entry.isFile()) await copyFile(from, to);
|
|
else throw new Error("更新暂存内容包含不受支持的文件类型");
|
|
}
|
|
}
|
|
|
|
const STAGED_ARCHIVE_PATTERN = /\.(?:tar\.gz|tgz|tar|zip)$/i;
|
|
|
|
async function assertStagedArchiveIntegrity(source: string, expectedSha256: string | null | undefined): Promise<void> {
|
|
const expected = expectedSha256?.trim().toLowerCase();
|
|
if (!expected) return;
|
|
if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error("更新暂存校验值无效");
|
|
const entries = await readdir(source, { withFileTypes: true }).catch(() => []);
|
|
const archive = entries
|
|
.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && STAGED_ARCHIVE_PATTERN.test(entry.name))
|
|
.sort((a, b) => a.name.localeCompare(b.name))[0];
|
|
if (!archive) throw new Error("更新暂存归档缺失,无法校验完整性");
|
|
const archivePath = path.join(source, archive.name);
|
|
const info = await lstat(archivePath).catch(() => null);
|
|
if (!info?.isFile() || info.isSymbolicLink()) throw new Error("更新暂存归档无效");
|
|
if (!(await verifySha256(archivePath, expected))) throw new Error("更新文件 SHA-256 校验失败");
|
|
}
|
|
|
|
/**
|
|
* Snapshot the web-staged payload into a root-owned workspace before the apply
|
|
* flow touches it. The copy is what closes the TOCTOU window: the unprivileged
|
|
* web user keeps write access to its own staging directory, so the privileged
|
|
* process must never execute content that lives there.
|
|
*
|
|
* A copy (not a rename) is required because the data directory and the install
|
|
* prefix are frequently separate mounts, where `rename` fails with EXDEV.
|
|
*/
|
|
export async function preparePrivateApplyWorkspace(options: {
|
|
jobId: string;
|
|
source: string;
|
|
privateRoot: string;
|
|
expectedUid: DirectoryOwnerUid;
|
|
expectedSha256?: string | null | undefined;
|
|
}): Promise<string> {
|
|
const root = await canonicalizePrivilegedRoot(options.privateRoot, options.expectedUid, "更新工作目录必须是 root 拥有且权限为 0700");
|
|
const source = path.resolve(options.source);
|
|
const sourcePayload = path.join(source, "payload");
|
|
const sourcePayloadInfo = await lstat(sourcePayload).catch(() => null);
|
|
if (!sourcePayloadInfo?.isDirectory() || sourcePayloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
|
// Second integrity check right before the copy, so a payload swapped after
|
|
// the download verification is rejected instead of promoted to a release.
|
|
await assertStagedArchiveIntegrity(source, options.expectedSha256);
|
|
const target = path.join(root, `apply-${options.jobId}`);
|
|
const existing = await lstat(target).catch(() => null);
|
|
if (existing) await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
try {
|
|
// Create the container explicitly: `copyReleaseTree` intentionally uses a
|
|
// non-recursive mkdir so a pre-existing/symlinked target can never be
|
|
// silently reused, and the parent must therefore already exist.
|
|
await mkdir(target, { recursive: false, mode: 0o700 });
|
|
await copyReleaseTree(sourcePayload, path.join(target, "payload"));
|
|
await normalizeReleasePermissions(path.join(target, "payload"));
|
|
const copied = await lstat(path.join(target, "payload")).catch(() => null);
|
|
if (!copied?.isDirectory() || copied.isSymbolicLink()) throw new Error("更新暂存内容复制失败");
|
|
return target;
|
|
} catch (error) {
|
|
await rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Reason code attached to failures that must reach the UI verbatim. */
|
|
function failureReason(error: unknown): string {
|
|
const reason = (error as { reason?: unknown } | null)?.reason;
|
|
return typeof reason === "string" && /^[a-z0-9_]{1,64}$/.test(reason) ? reason : "apply_precheck_failed";
|
|
}
|
|
|
|
/**
|
|
* Persist a real failure reason from the privileged apply path.
|
|
*
|
|
* `writeJob`/`updateJob` cannot be used here: their final-state guard
|
|
* (`WHERE update_jobs.status NOT IN (...)`) protects terminal rows, and it also
|
|
* makes the runner's own progress writes a no-op once a row is terminal. This
|
|
* helper issues an independent, guarded UPDATE so the true cause is visible in
|
|
* the UI instead of the runner's generic health-check message.
|
|
*/
|
|
export function failUpdateJobWithReason(
|
|
sqlite: Database.Database | undefined,
|
|
jobId: string,
|
|
message: string,
|
|
options: { reason?: string } = {},
|
|
): boolean {
|
|
if (!sqlite) return false;
|
|
const safe = safeErrorMessage(message.length ? new Error(message) : new Error("更新失败"));
|
|
try {
|
|
return sqlite.transaction(() => {
|
|
const row = sqlite.prepare("SELECT status, version, request_id AS requestId, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as {
|
|
status: UpdateJobStatus; version: string; requestId: string | null; adminId: string | null;
|
|
} | undefined;
|
|
if (!row || row.status === "completed" || row.status === "cancelled" || row.status === "failed") return false;
|
|
const now = Date.now();
|
|
const updated = sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=COALESCE(completed_at, ?), updated_at=? WHERE id=? AND status=?").run(safe, now, now, jobId, row.status);
|
|
if (updated.changes !== 1) return false;
|
|
writeAudit(sqlite, {
|
|
requestId: row.requestId || randomUUID(),
|
|
actorAdminId: row.adminId,
|
|
action: "update.failed",
|
|
targetType: "update",
|
|
targetId: jobId,
|
|
outcome: "failure",
|
|
before: { status: row.status, version: row.version },
|
|
after: { status: "failed", version: row.version, reason: options.reason ?? "apply_precheck_failed", error: safe },
|
|
});
|
|
return true;
|
|
})();
|
|
} catch {
|
|
// The database may not be open (or the row may not exist) when a request is
|
|
// rejected during preflight. Losing the diagnostic write must never turn a
|
|
// clean rejection into a crash.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 });
|
|
const embeddedRuntime = await lstat(path.join(stagedDir, "runtime")).catch(() => null);
|
|
if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime");
|
|
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;
|
|
/** Expected owner of `workspaceRoot`. Defaults to uid 0 (the installer
|
|
* provisions `<installPrefix>/.update-work` as root-owned 0700). Tests inject
|
|
* the current user so the check never depends on `process.getuid()`. */
|
|
workspaceOwnerUid?: DirectoryOwnerUid;
|
|
}): 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 canonicalizePrivilegedRoot(options.workspaceRoot, options.workspaceOwnerUid ?? 0, "更新工作目录权限无效")
|
|
: path.resolve(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;
|
|
}
|
|
|
|
/**
|
|
* Ownership expectations the privileged entry point uses for the two trust
|
|
* domains it consumes. They are injectable so the apply flow can be exercised
|
|
* end-to-end from a non-root test process: production always uses the defaults
|
|
* (root-owned `<installPrefix>/.update-work` and the `dataDir` owner for the
|
|
* unprivileged staging area) and never consults `process.getuid()`.
|
|
*/
|
|
export type UpdateMainOverrides = {
|
|
/** Expected owner of the unprivileged staging root (`config.stagingDir`). */
|
|
stagingOwnerUid?: DirectoryOwnerUid;
|
|
/** Expected owner of the root-only private workspace (`config.updateWorkspaceDir`). */
|
|
workspaceOwnerUid?: DirectoryOwnerUid;
|
|
};
|
|
|
|
export async function main(config: AppConfig = loadConfig(), overrides: UpdateMainOverrides = {}): 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);
|
|
const workspaceOwnerUid = overrides.workspaceOwnerUid ?? 0;
|
|
const stagingOwnerUid = overrides.stagingOwnerUid ?? await directoryOwnerUid(config.dataDir);
|
|
if (request) await ensurePrivilegedWorkspace(stagingDir, workspaceOwnerUid);
|
|
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, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string; expectedSha256: string | null } | undefined;
|
|
if (staged?.status === "staged" && staged.operation === "apply") {
|
|
// `download_path` is intentionally NOT required here. The runner NULLs
|
|
// that column as soon as it releases a workspace, so it can never be the
|
|
// source of truth for a payload that still exists on disk. The job id is
|
|
// the stable key; the column survives only as a last-resort candidate in
|
|
// `locateStagedWorkspace`.
|
|
if (staged.version !== request.version) throw new Error("更新暂存任务无效");
|
|
let privateWorkspace: string | undefined;
|
|
try {
|
|
const source = await locateStagedWorkspace({
|
|
jobId: request.jobId,
|
|
downloadPath: staged.downloadPath,
|
|
stagingRoot: config.stagingDir,
|
|
expectedUid: stagingOwnerUid,
|
|
});
|
|
// Snapshot into the root-only workspace before applying. The web user
|
|
// keeps write access to the staging tree, so content that is executed
|
|
// by the privileged process must never live there (TOCTOU).
|
|
privateWorkspace = await preparePrivateApplyWorkspace({
|
|
jobId: request.jobId,
|
|
source,
|
|
privateRoot: config.updateWorkspaceDir,
|
|
expectedUid: workspaceOwnerUid,
|
|
expectedSha256: staged.expectedSha256,
|
|
});
|
|
await applyStagedUpdate({
|
|
sqlite: database.sqlite,
|
|
jobId: request.jobId,
|
|
version: request.version,
|
|
stagedPath: privateWorkspace,
|
|
workspaceRoot: privateWorkspace,
|
|
workspaceOwnerUid,
|
|
currentDir,
|
|
currentLink: request.currentLink,
|
|
releasesDir: request.releasesDir,
|
|
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
|
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
|
dataBackupSource: config.dataDir,
|
|
maxBytes: config.updateMaxBytes,
|
|
dataBackupMaxBytes: config.maxTotalBytes,
|
|
});
|
|
// The private copy has been consumed by the release switch and the
|
|
// payload is now the live release, so the web-owned source tree is
|
|
// redundant. Best-effort cleanup must not fail an applied update.
|
|
await rm(source, { recursive: true, force: true }).catch(() => undefined);
|
|
console.log(`更新已切换:${request.version}`);
|
|
return;
|
|
} catch (error) {
|
|
// Covers failures raised before `applyStagedUpdate` took ownership of
|
|
// the private copy, and the "already committed" case where the failing
|
|
// path deliberately skips its own cleanup.
|
|
if (privateWorkspace) await rm(privateWorkspace, { recursive: true, force: true }).catch(() => undefined);
|
|
// The runner can only report its fixed health-check message. Record the
|
|
// real pre-flight cause so the UI and the audit trail show why the
|
|
// update was rejected. A terminal row is only reachable through an
|
|
// independent guarded UPDATE (`writeJob` refuses to mutate terminal
|
|
// rows), which is exactly what this helper issues.
|
|
failUpdateJobWithReason(database.sqlite, request.jobId, safeErrorMessage(error), { reason: failureReason(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
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;
|
|
});
|
|
}
|