Files
MikrotikManager/backend/src/services/certificates-service.ts
T
DenozordecandCursor ec43591a99
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
feat(db): перевести хранилище с SQLite на PostgreSQL
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 01:36:48 +07:00

273 lines
9.1 KiB
TypeScript

import { desc, eq, inArray } from "drizzle-orm"
import type { CertificateDto, CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import {
acmeSettings,
certificateIssueJobs,
certificateRenewSettings,
servers,
} from "../db/schema.js"
import { mapRosCertificates } from "./certificate-parse.js"
import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1
export type ManagedCertificateTarget = {
serverId: string
certName: string
domainNames: string[]
keyType: string
trustStore: string
finishedAt: string
}
export async function listCertificatesFromServers(): Promise<{
certificates: CertificateDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}> {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const certificates: CertificateDto[] = []
const failures: Array<{ serverId: string; serverName?: string; error: string }> = []
await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const rows = await client.getCertificates()
certificates.push(...mapRosCertificates(server.id, server.name, rows))
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name,
error: e instanceof Error ? e.message : String(e),
})
}
}),
)
certificates.sort((a, b) => {
if (a.serverId !== b.serverId) return a.serverId.localeCompare(b.serverId)
return a.name.localeCompare(b.name, "ru")
})
return { certificates, failures }
}
export async function getAcmeSettingsPublic() {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
if (!row) {
return {
directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
defaultZoneId: "",
tokenConfigured: false,
updatedAt: undefined as string | undefined,
}
}
return {
directoryUrl: row.directoryUrl,
defaultZoneId: row.defaultZoneId || undefined,
tokenConfigured: Boolean(row.cloudflareApiToken?.trim()),
updatedAt: row.updatedAt,
}
}
export async function getAcmeCloudflareToken(): Promise<string> {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
return row?.cloudflareApiToken?.trim() ?? ""
}
export async function getAcmeAccountPrivateKey(): Promise<string> {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
return row?.accountPrivateKey?.trim() ?? ""
}
export async function saveAcmeAccountPrivateKey(pem: string) {
const now = new Date().toISOString()
await db.update(acmeSettings)
.set({ accountPrivateKey: pem, updatedAt: now })
.where(eq(acmeSettings.id, SETTINGS_ID))
}
export async function updateAcmeSettings(input: {
directoryUrl?: string
defaultZoneId?: string | null
cloudflareApiToken?: string | null
}) {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
const now = new Date().toISOString()
const next = {
directoryUrl: input.directoryUrl?.trim() || row?.directoryUrl || "https://acme-v02.api.letsencrypt.org/directory",
defaultZoneId:
input.defaultZoneId === null
? ""
: input.defaultZoneId?.trim() ?? row?.defaultZoneId ?? "",
cloudflareApiToken:
input.cloudflareApiToken === null
? ""
: input.cloudflareApiToken?.trim() ?? row?.cloudflareApiToken ?? "",
updatedAt: now,
}
if (row) {
await db.update(acmeSettings).set(next).where(eq(acmeSettings.id, SETTINGS_ID))
} else {
await db.insert(acmeSettings).values({ id: SETTINGS_ID, accountPrivateKey: "", ...next })
}
return await getAcmeSettingsPublic()
}
export async function createIssueJobRecord(input: {
id: string
serverId: string
certName: string
domainNames: string[]
keyType: string
trustStore: string
source?: "manual" | "scheduler"
}) {
const now = new Date().toISOString()
await db.insert(certificateIssueJobs).values({
id: input.id,
status: "queued",
step: "queued",
source: input.source ?? "manual",
serverId: Number(input.serverId),
certName: input.certName,
domainNames: input.domainNames,
keyType: input.keyType,
trustStore: input.trustStore,
requestedAt: now,
})
}
export async function updateIssueJobRecord(
id: string,
patch: Partial<{
status: "queued" | "running" | "done" | "failed"
step: string
startedAt: string
finishedAt: string
error: string | null
}>,
) {
await db.update(certificateIssueJobs).set(patch).where(eq(certificateIssueJobs.id, id))
}
export async function getIssueJobRecord(id: string) {
return (await db.select().from(certificateIssueJobs).where(eq(certificateIssueJobs.id, id)).limit(1))[0] ?? null
}
export function toIssueJobDto(row: NonNullable<Awaited<ReturnType<typeof getIssueJobRecord>>>) {
const domainNames = parseJsonArray(row.domainNames).map(String)
return {
id: row.id,
status: row.status,
step: row.step as "queued" | "acme_order" | "dns_challenge" | "finalize" | "import" | "cleanup" | "done",
serverId: row.serverId,
certName: row.certName,
domainNames,
requestedAt: row.requestedAt,
startedAt: row.startedAt ?? undefined,
finishedAt: row.finishedAt ?? undefined,
error: row.error ?? undefined,
}
}
export async function getServerRowByIdString(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
async function getCertificateRenewSettingsRow() {
return (await db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1))[0]
?? {
id: SETTINGS_ID,
enabled: true,
intervalSec: 21600,
renewBeforeDays: 30,
lastCollectedAt: null,
lastDurationMs: null,
lastError: null,
updatedAt: new Date().toISOString(),
}
}
export async function getCertificateRenewSettings(): Promise<CertificateRenewSettingsDto> {
const row = await getCertificateRenewSettingsRow()
return {
enabled: row.enabled,
intervalSec: row.intervalSec,
renewBeforeDays: row.renewBeforeDays,
lastCollectedAt: row.lastCollectedAt ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError ?? null,
updatedAt: row.updatedAt,
}
}
export async function updateCertificateRenewSettings(patch: {
enabled?: boolean
intervalSec?: number
renewBeforeDays?: number
}) {
const prev = await getCertificateRenewSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
renewBeforeDays: patch.renewBeforeDays ?? prev.renewBeforeDays,
updatedAt: now,
}
if ((await db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(certificateRenewSettings).set(next).where(eq(certificateRenewSettings.id, SETTINGS_ID))
} else {
await db.insert(certificateRenewSettings).values({ id: SETTINGS_ID, ...next })
}
return await getCertificateRenewSettings()
}
export async function touchCertificateRenewRunMeta(patch: {
lastCollectedAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = await getCertificateRenewSettingsRow()
await db.update(certificateRenewSettings).set({
lastCollectedAt: patch.lastCollectedAt ?? prev.lastCollectedAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(certificateRenewSettings.id, SETTINGS_ID))
}
export async function listManagedCertificateTargets(): Promise<ManagedCertificateTarget[]> {
const rows = await db.select().from(certificateIssueJobs)
.where(eq(certificateIssueJobs.status, "done"))
.orderBy(desc(certificateIssueJobs.finishedAt), desc(certificateIssueJobs.requestedAt))
const byKey = new Map<string, ManagedCertificateTarget>()
for (const row of rows) {
const key = `${row.serverId}::${row.certName}`
if (byKey.has(key)) continue
const domainNames = parseJsonArray(row.domainNames).map(String)
byKey.set(key, {
serverId: row.serverId == null ? "" : String(row.serverId),
certName: row.certName,
domainNames,
keyType: row.keyType,
trustStore: row.trustStore,
finishedAt: row.finishedAt ?? row.requestedAt,
})
}
return [...byKey.values()]
}
export async function hasActiveIssueJob(serverId: string, certName: string): Promise<boolean> {
const rows = await db.select().from(certificateIssueJobs)
.where(inArray(certificateIssueJobs.status, ["queued", "running"]))
.orderBy(desc(certificateIssueJobs.requestedAt))
const sid = Number(serverId)
return rows.some((r) => r.serverId === sid && r.certName === certName)
}