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
This commit is contained in:
Qiufeng
2026-09-17 13:12:20 +08:00
parent 5afcd98ebd
commit ae8966baf6
12 changed files with 1145 additions and 71 deletions
+139 -13
View File
@@ -1,4 +1,4 @@
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { lstatSync, readdirSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
@@ -40,6 +40,20 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
// after the service health check. The runner refreshes its recovery marker as
// a lease while doing long downloads/backups; only an expired lease permits
// the server to reclaim an active row.
/**
* Conflict predicate for "another update is already running".
*
* A row that is `staged` with `operation='download'` is a finished artifact
* waiting for an explicit apply, not a running task: the privileged runner only
* starts working after the apply request is written. It must therefore not
* block a new download. Real in-flight work (queued/downloading/verifying and
* the apply phases) remains protected, which is what keeps the apply path's
* concurrency guard intact.
*
* The SQL fragment expects ACTIVE_UPDATE_STATUSES bound as positional params.
*/
export const ACTIVE_UPDATE_CONFLICT_SQL = `status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND NOT (status='staged' AND operation='download')`;
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export const QUEUED_UPDATE_TIMEOUT_MS = 25 * 1000;
@@ -447,6 +461,61 @@ export function currentReleaseVersion(config: AppConfig): string | null {
}
}
/**
* Absolute paths that can hold a job's staging workspace. The web download flow
* always creates `update-<jobId>`; the privileged runner may additionally use a
* `mkdtemp` variant named `update-<jobId>-XXXXXX`.
*
* `update_jobs.download_path` is deliberately NOT used to rebuild these paths:
* it held a bare basename while a download was in flight (rows written by older
* versions still store that basename) and the privileged runner NULLs the column
* after finalizing a row. Rebuilding from it could delete an unrelated staging
* entry that merely shares the basename.
*/
function jobWorkspaceCandidates(stagingDir: string, jobId: string): string[] {
// Job ids are UUIDs; reject anything that could escape the staging root.
if (!jobId || jobId !== path.basename(jobId) || jobId.includes("..")) return [];
const stagingRoot = path.resolve(stagingDir);
const prefix = `update-${jobId}`;
const names = [prefix];
try {
for (const entry of readdirSync(stagingRoot)) {
if (entry.startsWith(`${prefix}-`)) names.push(entry);
}
} catch {
// A missing or unreadable staging directory still leaves the fixed-name
// candidate, which is what the web download path uses.
}
return names.map((name) => path.join(stagingRoot, name));
}
function isDirectoryNotSymlink(target: string): boolean {
try {
const info = lstatSync(target);
return info.isDirectory() && !info.isSymbolicLink();
} catch {
return false;
}
}
/** True while at least one staging workspace for the job still exists. */
export function jobWorkspaceExists(stagingDir: string, jobId: string): boolean {
return jobWorkspaceCandidates(stagingDir, jobId).some(isDirectoryNotSymlink);
}
/**
* Remove every staging workspace owned by a job. Deletion is awaited so callers
* (and tests) observe a settled filesystem when they return.
*/
async function removeJobWorkspaces(stagingDir: string, jobId: string): Promise<void> {
for (const candidate of jobWorkspaceCandidates(stagingDir, jobId)) {
const info = await lstat(candidate).catch(() => null);
// Only real directories are removed; a symlink is never followed.
if (!info?.isDirectory() || info.isSymbolicLink()) continue;
await rm(candidate, { recursive: true, force: true }).catch(() => undefined);
}
}
/**
* Release an update row left behind after its privileged runner lease expired.
* This is deliberately conservative: staged downloads remain available for an
@@ -558,6 +627,36 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
// A stale request/state marker therefore no longer protects an orphaned
// row forever, while a fresh marker remains owned by the runner.
if (row.status === "staged") {
// A staged row that lost its payload (the staging janitor removes
// `update-*` entries after 24h, and a manual cleanup has the same effect)
// can never be applied or completed. Report it instead of leaving a
// permanently actionable row that fails at apply time.
if (!matchingFreshRequest && !matchingFreshState && !jobWorkspaceExists(config.stagingDir, row.id)) {
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status='failed', error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status='staged' AND updated_at=?
`).run("暂存的更新文件已不存在,请重新下载更新包", now, now, row.id, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: "failure",
before: { status: row.status, operation: row.operation, version: row.version },
after: { status: "failed", version: row.version, reason: "staged_workspace_missing" },
});
return true;
})();
if (changed) {
reconciled += 1;
reconciledIds.add(row.id);
}
continue;
}
if (row.operation !== "apply" || matchingFreshRequest || matchingFreshState) continue;
const changed = database.transaction(() => {
const result = database.prepare(`
@@ -633,23 +732,39 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
return reconciled;
}
export function cancelUpdateJob(
/**
* Rows an administrator may cancel from the web UI.
*
* `staged` is cancellable only while the row still belongs to the download
* stage. A staged row whose operation is already `apply` has been handed to the
* privileged runner (stop/backup/switch) and must not be interrupted here.
*/
const CANCELLABLE_JOB_SQL = "(status IN ('queued', 'downloading') OR (status='staged' AND operation='download'))";
export function isCancellableUpdateJob(status: UpdateJobStatus, operation: string): boolean {
if (status === "queued" || status === "downloading") return true;
return status === "staged" && operation === "download";
}
export async function cancelUpdateJob(
database: Database.Database,
config: AppConfig,
adminId: string,
requestId: string,
jobId?: string,
): { cancelled: boolean; message?: string } {
): Promise<{ cancelled: boolean; message?: string }> {
type CancelRow = { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null };
const columns = "id, status, operation, version, admin_id AS adminId";
const job = jobId
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=? AND admin_id=?").get(jobId, adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE admin_id=? AND status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get(adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
? database.prepare(`SELECT ${columns} FROM update_jobs WHERE id=? AND admin_id=?`).get(jobId, adminId) as CancelRow | undefined
: database.prepare(`SELECT ${columns} FROM update_jobs WHERE admin_id=? AND ${CANCELLABLE_JOB_SQL} ORDER BY created_at DESC LIMIT 1`).get(adminId) as CancelRow | undefined;
if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" };
if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
if (!isCancellableUpdateJob(job.status, job.operation)) return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
const now = Date.now();
const changed = database.transaction(() => {
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId);
const result = database.prepare(`UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND ${CANCELLABLE_JOB_SQL}`).run(now, now, job.id, adminId);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId,
@@ -668,10 +783,11 @@ export function cancelUpdateJob(
// The request marker is shared by the privileged runner. Never remove a
// newer/different administrator's request while cancelling this row.
if (requestJobId(config.updateRequestPath) === job.id) forceRemoveRequest(config.updateRequestPath);
if (job.downloadPath) {
const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath);
import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {});
}
// Locate the workspace by job id. `download_path` is not a reliable source
// (older rows hold a bare archive basename and the runner NULLs the column
// after finalizing), and a basename lookup could delete an unrelated entry.
// The await keeps the caller from racing a still-running download writer.
await removeJobWorkspaces(config.stagingDir, job.id);
return { cancelled: true };
}
return { cancelled: false, message: "取消失败,任务状态可能已改变" };
@@ -706,9 +822,13 @@ export async function downloadAndStageUpdate(
// Claim the job: transition queued -> downloading. If the job was
// cancelled or claimed by another caller, abort immediately.
// `download_path` always holds an absolute workspace path, both while the
// download runs and after the job is staged. Callers must not derive paths
// from it (the privileged runner NULLs it once it finalizes the row), but a
// single semantic keeps the column debuggable.
const claim = database.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);
).run(Date.now(), Date.now(), workspace, Date.now(), jobId);
if (claim.changes !== 1) return;
const progressStartedAt = Date.now();
@@ -764,7 +884,13 @@ export async function downloadAndStageUpdate(
const staged = database.prepare(
"UPDATE update_jobs SET status='staged', operation='download', 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) return; // cancelled
if (staged.changes !== 1) {
// The row was cancelled or claimed elsewhere (status no longer
// verifying/downloading). This process owns the workspace it created, so
// remove it instead of leaking the payload into the staging directory.
await rm(workspace, { recursive: true, force: true }).catch(() => undefined);
return;
}
writeAudit(database, {
requestId: `download:${jobId}`,