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
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
9.1 KiB
TypeScript
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)
|
|
}
|