diff --git a/backend/src/services/acme-cloudflare.ts b/backend/src/services/acme-cloudflare.ts index 5aab41d..d1e26a3 100644 --- a/backend/src/services/acme-cloudflare.ts +++ b/backend/src/services/acme-cloudflare.ts @@ -1,3 +1,4 @@ +import { lookup } from "node:dns/promises" import * as acme from "acme-client" import type { Server } from "../db/schema.js" import { @@ -10,6 +11,18 @@ import { MikrotikClient } from "./mikrotik.js" type CfResponse = { success: boolean; errors?: Array<{ message?: string }>; result?: T } +type CfDnsRecord = { + id: string + type: string + name: string + content: string +} + +type WanUplinkRow = { + iface?: string + ip?: string +} + async function cloudflareRequest( token: string, path: string, @@ -72,6 +85,128 @@ async function deleteTxtRecord(token: string, zoneId: string, recordId: string): await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" }) } +function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] { + if (!raw?.trim()) return [] + try { + const data = JSON.parse(raw) as unknown + if (!Array.isArray(data)) return [] + return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object") + } catch { + return [] + } +} + +function normalizeIpv4(raw: string | undefined): string | null { + const value = raw?.trim() + if (!value) return null + const host = value.split("/")[0]?.trim() ?? "" + if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return null + return host +} + +function isPrivateIpv4(ip: string): boolean { + const [a, b] = ip.split(".").map((part) => Number.parseInt(part, 10)) + if (a === 10) return true + if (a === 127) return true + if (a === 192 && b === 168) return true + if (a === 172 && b >= 16 && b <= 31) return true + return false +} + +async function resolveHostIpv4(host: string): Promise { + const literal = normalizeIpv4(host) + if (literal) return literal + const value = host.trim() + if (!value) return null + try { + const result = await lookup(value, { family: 4 }) + return normalizeIpv4(result.address) + } catch { + return null + } +} + +async function resolveServerPublicIp(server: Server, client?: MikrotikClient): Promise { + const uplinks = parseWanUplinks(server.wanUplinks) + for (const uplink of uplinks) { + const configuredIp = normalizeIpv4(uplink.ip) + if (configuredIp) return configuredIp + } + + const hostIp = await resolveHostIpv4(server.host) + if (hostIp) return hostIp + + const rosClient = client ?? MikrotikClient.fromServer(server) + const addresses = await rosClient.getIpAddresses() + const ifaceNames = new Set(uplinks.map((u) => u.iface?.trim()).filter(Boolean)) + const preferred = addresses.filter((row) => ifaceNames.has(String(row.interface ?? "").trim())) + const ordered = [...preferred, ...addresses] + + for (const row of ordered) { + const ip = normalizeIpv4(String(row.address ?? "")) + if (ip && !isPrivateIpv4(ip)) return ip + } + for (const row of ordered) { + const ip = normalizeIpv4(String(row.address ?? "")) + if (ip) return ip + } + + throw new Error("Не удалось определить IP сервера для A-записи Cloudflare") +} + +async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string): Promise { + return cloudflareRequest( + token, + `/zones/${zoneId}/dns_records?name=${encodeURIComponent(fqdn)}`, + ) +} + +async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise { + const records = await listDnsRecordsByName(token, zoneId, fqdn) + const existingA = records.find((record) => record.type === "A") + if (existingA) { + if (existingA.content === ip) return + await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existingA.id}`, { + method: "PATCH", + body: JSON.stringify({ + type: "A", + name: fqdn, + content: ip, + ttl: 120, + proxied: false, + }), + }) + return + } + + if (records.some((record) => record.type === "CNAME")) { + throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`) + } + + await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, { + method: "POST", + body: JSON.stringify({ + type: "A", + name: fqdn, + content: ip, + ttl: 120, + proxied: false, + }), + }) +} + +async function syncCertificateDomainRecords( + token: string, + domains: string[], + serverIp: string, + defaultZoneId?: string, +): Promise { + for (const domain of domains) { + const zoneId = await resolveZoneId(token, domain, defaultZoneId) + await upsertARecord(token, zoneId, domain, serverIp) + } +} + async function sleep(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)) } @@ -155,8 +290,11 @@ export async function issueCertificateWithCloudflareDns(params: { const finalized = await client.finalizeOrder(order, csr) const certPem = await client.getCertificate(finalized) - params.onStep?.("import") const clientRos = MikrotikClient.fromServer(params.server) + const serverIp = await resolveServerPublicIp(params.server, clientRos) + await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId) + + params.onStep?.("import") const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_") const certFile = `${safeBase}.crt` const keyFile = `${safeBase}.key`