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:
+139
-13
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user