chore(backend): добавить поддержку ACME и управление сертификатами
This commit is contained in:
1 parent
bfceb07a23
commit
d62d212c86
21 files changed
+2183
-301
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user