Files
MikrotikManager/backend/src/services/certificate-issue-execution.ts
T

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