78 lines
4.9 KiB
TypeScript
78 lines
4.9 KiB
TypeScript
import { databaseBackgroundWork } from "./database-background-work.js"
|
|
import { certificateIssueStepSchema } from "@mmapp/contracts/certificates"
|
|
import type { CertificateIssueJobRow } from "../db/schema.js"
|
|
import type { appendEvent } from "../modules/events/service/events-service.js"
|
|
import type { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
|
|
import type { updateIssueJobRecord } from "./certificates-service.js"
|
|
import { parseJsonArray } from "../db/json.js"
|
|
|
|
type IssueInput<S> = Omit<Parameters<typeof issueCertificateWithCloudflareDns>[0], "server"> & { server: S }
|
|
type Dependencies<S> = {
|
|
getJob: (id: string) => Promise<CertificateIssueJobRow | null>
|
|
getServer: (id: number) => Promise<S | null>
|
|
updateJob: typeof updateIssueJobRecord
|
|
issue: (input: IssueInput<S>) => Promise<void>
|
|
appendEvent: typeof appendEvent
|
|
warn: (error: unknown) => void
|
|
}
|
|
|
|
/** Separate the network outcome from progress/event persistence failures. */
|
|
export function createCertificateIssueRunner<S extends { id: number; name: string }>(deps: Dependencies<S>) {
|
|
const runningJobs = new Set<string>()
|
|
const report = (error: unknown) => { deps.warn(error) }
|
|
const event = async (input: Parameters<typeof appendEvent>[0]) => {
|
|
try { await deps.appendEvent(input) } catch (error) { report(error) }
|
|
}
|
|
async function run(jobId: string): Promise<void> {
|
|
if (runningJobs.has(jobId)) return
|
|
runningJobs.add(jobId)
|
|
try {
|
|
const row = await deps.getJob(jobId)
|
|
// A running record from a previous process has an unknown outcome: never replay it.
|
|
if (!row || row.status !== "queued") return
|
|
const server = await deps.getServer(row.serverId)
|
|
if (!server) {
|
|
await deps.updateJob(jobId, { status: "failed", step: "failed", finishedAt: new Date().toISOString(), error: "Сервер не найден" })
|
|
return
|
|
}
|
|
const domainNames = parseJsonArray(row.domainNames).map(String)
|
|
const trustStore = row.trustStore.split(",").map(value => value.trim()).filter(Boolean)
|
|
const scheduler = row.source === "scheduler"
|
|
const context = { sourceModule: "certificates" as const, entityType: "server", entityId: String(server.id) }
|
|
const target = [row.certName, domainNames.join(", "), server.name].join(" · ")
|
|
await deps.updateJob(jobId, { status: "running", step: "acme_order", startedAt: new Date().toISOString(), error: null })
|
|
if (scheduler) await event({ ...context, level: "info", eventType: "certificates.renew.started", title: "Запущено автообновление сертификата", message: target })
|
|
let progress = Promise.resolve()
|
|
const onStep = (step: string) => {
|
|
progress = progress.then(async () => {
|
|
// Older ACME callbacks encode non-fatal DNS diagnostics in the step string.
|
|
if (step.startsWith("dns_a_sync_warn:") || step.startsWith("dns_a_sync_skip_cname:")) {
|
|
await event({ ...context, level: step.startsWith("dns_a_sync_warn:") ? "warning" : "info", eventType: "certificates.issue.dns_a_sync", title: "Синхронизация DNS сертификата", message: step.slice(step.indexOf(":") + 1) })
|
|
return
|
|
}
|
|
const parsed = certificateIssueStepSchema.safeParse(step)
|
|
if (!parsed.success || ["queued", "done", "failed"].includes(step)) {
|
|
report(new Error("Неизвестный шаг выпуска: " + step))
|
|
return
|
|
}
|
|
await deps.updateJob(jobId, { step: parsed.data })
|
|
}).catch(report)
|
|
}
|
|
let operationError: unknown
|
|
let failed = false
|
|
try {
|
|
await deps.issue({ server, certName: row.certName, domainNames, keyType: row.keyType === "ec256" ? "ec256" : "rsa2048", trustStore: trustStore.length ? trustStore : ["www", "api"], onStep })
|
|
} catch (error) { failed = true; operationError = error }
|
|
await progress
|
|
const message = failed ? (operationError instanceof Error ? operationError.message : String(operationError)) : null
|
|
// Persistence errors propagate to the caller; a successful issue must never be relabelled failed.
|
|
await deps.updateJob(jobId, { status: failed ? "failed" : "done", step: failed ? "failed" : "done", finishedAt: new Date().toISOString(), error: message })
|
|
await event({ ...context, level: failed ? "warning" : "info", eventType: "certificates." + (scheduler ? "renew." : "issue.") + (failed ? "failed" : "done"), title: failed ? (scheduler ? "Ошибка автообновления сертификата" : "Ошибка выпуска сертификата") : (scheduler ? "Сертификат обновлён" : "Сертификат выпущен"), message: message ?? target })
|
|
} finally { runningJobs.delete(jobId) }
|
|
}
|
|
function queue(jobId: string): void {
|
|
void databaseBackgroundWork.track(Promise.resolve().then(() => run(jobId))).catch(report)
|
|
}
|
|
return { run, queue }
|
|
}
|