Files
TallyNote/server/cli/update.ts
T
Qiufeng ae8966baf6 fix: 修复在线更新暂存链路并增加全局 API 限流备底
- 新增 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
2026-09-17 13:12:20 +08:00

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;
});
}