chore(backend): добавить поддержку ACME и управление сертификатами

This commit is contained in:
Denozordec committed 2026-05-12 20:00:25 +07:00
1 parent bfceb07a23
commit d62d212c86
21 files changed
+2183 -301

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
import { randomUUID } from "node:crypto"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
certificateIssueRequestSchema,
putAcmeCloudflareSettingsSchema,
testAcmeCloudflareSettingsSchema,
} from "@mmapp/contracts/certificates"
import { appendEvent } from "../modules/events/service/events-service.js"
import { issueCertificateWithCloudflareDns, testCloudflareToken } from "../services/acme-cloudflare.js"
import {
createIssueJobRecord,
getAcmeCloudflareToken,
getAcmeSettingsPublic,
getIssueJobRecord,
getServerRowByIdString,
listCertificatesFromServers,
toIssueJobDto,
updateAcmeSettings,
updateIssueJobRecord,
} from "../services/certificates-service.js"
const runningJobs = new Set<string>()
async function runIssueJob(jobId: string) {
if (runningJobs.has(jobId)) return
runningJobs.add(jobId)
const row = getIssueJobRecord(jobId)
if (!row) {
runningJobs.delete(jobId)
return
}
const server = getServerRowByIdString(row.serverId)
if (!server) {
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: "Сервер не найден",
})
runningJobs.delete(jobId)
return
}
let domainNames: string[] = []
try {
domainNames = JSON.parse(row.domainNames) as string[]
} catch {
domainNames = []
}
const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean)
const startedAt = new Date().toISOString()
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
try {
await issueCertificateWithCloudflareDns({
server,
certName: row.certName,
domainNames,
keyType: row.keyType === "ec256" ? "ec256" : "rsa2048",
trustStore: trustStore.length > 0 ? trustStore : ["www", "api"],
onStep: (step) => updateIssueJobRecord(jobId, { step }),
})
updateIssueJobRecord(jobId, {
status: "done",
step: "done",
finishedAt: new Date().toISOString(),
error: null,
})
appendEvent({
level: "info",
eventType: "certificates.issue.done",
sourceModule: "certificates",
title: "Сертификат выпущен",
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
entityType: "server",
entityId: String(server.id),
})
} catch (e) {
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: e instanceof Error ? e.message : String(e),
})
appendEvent({
level: "warning",
eventType: "certificates.issue.failed",
sourceModule: "certificates",
title: "Ошибка выпуска сертификата",
message: e instanceof Error ? e.message : String(e),
entityType: "server",
entityId: String(server.id),
})
} finally {
runningJobs.delete(jobId)
}
}
const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/certificates", async (_req, reply) => {
return reply.send(await listCertificatesFromServers())
})
app.post("/certificates/refresh", async (_req, reply) => {
return reply.send(await listCertificatesFromServers())
})
app.get("/certificates/acme-settings", async (_req, reply) => {
return reply.send(getAcmeSettingsPublic())
})
app.put("/certificates/acme-settings", async (req, reply) => {
const parsed = putAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
return reply.send(updateAcmeSettings(parsed.data))
})
app.post("/certificates/acme-settings/test", async (req, reply) => {
const parsed = testAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken()
if (!effective) {
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
}
try {
await testCloudflareToken(effective)
return reply.send({ ok: true, message: "Cloudflare API доступен" })
} catch (e) {
return reply.status(400).send({
ok: false,
message: e instanceof Error ? e.message : String(e),
})
}
})
app.post("/certificates/issue", async (req, reply) => {
const parsed = certificateIssueRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const serverId = String(parsed.data.serverId)
const server = getServerRowByIdString(serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const jobId = randomUUID()
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
createIssueJobRecord({
id: jobId,
serverId,
certName: parsed.data.certName.trim(),
domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean),
keyType: parsed.data.keyType ?? "rsa2048",
trustStore,
})
queueMicrotask(() => { void runIssueJob(jobId) })
return reply.send({ jobId })
})
app.get("/certificates/issue/:jobId", async (req, reply) => {
const jobId = String((req.params as { jobId: string }).jobId)
const row = getIssueJobRecord(jobId)
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
return reply.send(toIssueJobDto(row))
})
}
export default certificatesRoutes
+11 -7
View File
@@ -1,4 +1,5 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { db } from "../db/index.js"
import {
filterRules,
@@ -16,13 +17,15 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeProbesTotal,
uptimeSpeedProbesTotal,
recursiveRoutesTotal,
] = [
db.select().from(servers).all().length,
db.select().from(filterRules).all().length,
db.select().from(uptimeProbes).all().length,
db.select().from(uptimeSpeedProbes).all().length,
db.select().from(recursiveRoutes).all().length,
]
certificatesTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
Promise.resolve(db.select().from(uptimeProbes).all().length),
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
])
return reply.send({
servers: serversTotal,
@@ -31,6 +34,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeSpeedProbes: uptimeSpeedProbesTotal,
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
})
})
}