From bc01da1708092c7f6f70e905cd7e61b901a357ba Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 12 May 2026 20:35:07 +0700 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20?= =?UTF-8?q?=D1=81=20IP-=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B8=20DNS-=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/services/acme-cloudflare.ts | 140 +++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) 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`