fix(backend): добавить функции для работы с IP-адресами и DNS-записями
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { lookup } from "node:dns/promises"
|
||||||
import * as acme from "acme-client"
|
import * as acme from "acme-client"
|
||||||
import type { Server } from "../db/schema.js"
|
import type { Server } from "../db/schema.js"
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,18 @@ import { MikrotikClient } from "./mikrotik.js"
|
|||||||
|
|
||||||
type CfResponse<T> = { success: boolean; errors?: Array<{ message?: string }>; result?: T }
|
type CfResponse<T> = { 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<T>(
|
async function cloudflareRequest<T>(
|
||||||
token: string,
|
token: string,
|
||||||
path: 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" })
|
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<string | null> {
|
||||||
|
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<string> {
|
||||||
|
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<CfDnsRecord[]> {
|
||||||
|
return cloudflareRequest<CfDnsRecord[]>(
|
||||||
|
token,
|
||||||
|
`/zones/${zoneId}/dns_records?name=${encodeURIComponent(fqdn)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
|
||||||
|
const records = await listDnsRecordsByName(token, zoneId, fqdn)
|
||||||
|
const existingA = records.find((record) => record.type === "A")
|
||||||
|
if (existingA) {
|
||||||
|
if (existingA.content === ip) return
|
||||||
|
await cloudflareRequest<CfDnsRecord>(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<void> {
|
||||||
|
for (const domain of domains) {
|
||||||
|
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
||||||
|
await upsertARecord(token, zoneId, domain, serverIp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sleep(ms: number) {
|
async function sleep(ms: number) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -155,8 +290,11 @@ export async function issueCertificateWithCloudflareDns(params: {
|
|||||||
const finalized = await client.finalizeOrder(order, csr)
|
const finalized = await client.finalizeOrder(order, csr)
|
||||||
const certPem = await client.getCertificate(finalized)
|
const certPem = await client.getCertificate(finalized)
|
||||||
|
|
||||||
params.onStep?.("import")
|
|
||||||
const clientRos = MikrotikClient.fromServer(params.server)
|
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 safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||||
const certFile = `${safeBase}.crt`
|
const certFile = `${safeBase}.crt`
|
||||||
const keyFile = `${safeBase}.key`
|
const keyFile = `${safeBase}.key`
|
||||||
|
|||||||
Reference in New Issue
Block a user