384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
import { lookup } from "node:dns/promises"
|
|
import { X509Certificate, randomBytes } from "node:crypto"
|
|
import { assertLogicalCertificateName, rotateCertificateGeneration } from "./certificate-installation.js"
|
|
import * as acme from "acme-client"
|
|
import type { Server } from "../db/schema.js"
|
|
import {
|
|
getAcmeAccountPrivateKey,
|
|
getAcmeCloudflareToken,
|
|
getAcmeSettingsPublic,
|
|
saveAcmeAccountPrivateKey,
|
|
} from "./certificates-service.js"
|
|
import { MikrotikClient } from "./mikrotik.js"
|
|
|
|
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>(
|
|
token: string,
|
|
path: string,
|
|
init?: RequestInit,
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
}
|
|
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
...init,
|
|
headers,
|
|
})
|
|
const json = (await res.json()) as CfResponse<T>
|
|
if (!res.ok || !json.success) {
|
|
const msg = json.errors?.map((e) => e.message).filter(Boolean).join("; ")
|
|
|| `Cloudflare API HTTP ${res.status}`
|
|
throw new Error(msg)
|
|
}
|
|
return json.result as T
|
|
}
|
|
|
|
export async function testCloudflareToken(token: string): Promise<void> {
|
|
await cloudflareRequest<Array<{ id: string; name: string }>>(token, "/zones?per_page=1")
|
|
}
|
|
|
|
async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise<string> {
|
|
const labels = domain.split(".").filter(Boolean)
|
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
const guess = labels.slice(i).join(".")
|
|
const zones = await cloudflareRequest<Array<{ id: string; name: string }>>(
|
|
token,
|
|
`/zones?name=${encodeURIComponent(guess)}`,
|
|
)
|
|
if (zones[0]?.id) return zones[0].id
|
|
}
|
|
if (defaultZoneId?.trim()) return defaultZoneId.trim()
|
|
throw new Error(`Не удалось определить зону Cloudflare для ${domain}`)
|
|
}
|
|
|
|
async function createTxtRecord(
|
|
token: string,
|
|
zoneId: string,
|
|
recordName: string,
|
|
value: string,
|
|
): Promise<string> {
|
|
const created = await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
type: "TXT",
|
|
name: recordName,
|
|
content: value,
|
|
ttl: 120,
|
|
}),
|
|
})
|
|
return created.id
|
|
}
|
|
|
|
async function deleteTxtRecord(token: string, zoneId: string, recordId: string): Promise<void> {
|
|
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
|
|
}
|
|
|
|
function parseWanUplinks(raw: unknown): WanUplinkRow[] {
|
|
const data = Array.isArray(raw) ? raw : typeof raw === "string" && raw.trim()
|
|
? (() => { try { const p = JSON.parse(raw) as unknown; return Array.isArray(p) ? p : [] } catch { return [] } })()
|
|
: []
|
|
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
|
|
}
|
|
|
|
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<"updated" | "created" | "skipped_cname"> {
|
|
const records = await listDnsRecordsByName(token, zoneId, fqdn)
|
|
const existingA = records.find((record) => record.type === "A")
|
|
if (existingA) {
|
|
if (existingA.content === ip) return "updated"
|
|
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 "updated"
|
|
}
|
|
|
|
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
|
|
if (records.some((record) => record.type === "CNAME")) {
|
|
return "skipped_cname"
|
|
}
|
|
|
|
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
type: "A",
|
|
name: fqdn,
|
|
content: ip,
|
|
ttl: 120,
|
|
proxied: false,
|
|
}),
|
|
})
|
|
return "created"
|
|
}
|
|
|
|
async function syncCertificateDomainRecords(
|
|
token: string,
|
|
domains: string[],
|
|
serverIp: string,
|
|
defaultZoneId?: string,
|
|
): Promise<{ skippedCname: string[] }> {
|
|
const skippedCname: string[] = []
|
|
for (const domain of domains) {
|
|
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
|
const result = await upsertARecord(token, zoneId, domain, serverIp)
|
|
if (result === "skipped_cname") skippedCname.push(domain)
|
|
}
|
|
return { skippedCname }
|
|
}
|
|
|
|
async function sleep(ms: number) {
|
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async function getOrCreateAccountKey(): Promise<Buffer> {
|
|
const existing = await getAcmeAccountPrivateKey()
|
|
if (existing) return Buffer.from(existing)
|
|
const key = await acme.crypto.createPrivateKey()
|
|
await saveAcmeAccountPrivateKey(key.toString("utf8"))
|
|
return key
|
|
}
|
|
|
|
async function ensureAcmeAccount(client: acme.Client): Promise<void> {
|
|
try {
|
|
client.getAccountUrl()
|
|
return
|
|
} catch {
|
|
await client.createAccount({ termsOfServiceAgreed: true })
|
|
}
|
|
}
|
|
|
|
function splitPemCertificates(pem: string): string[] {
|
|
return [...pem.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)]
|
|
.map((match) => match[0].trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
export async function issueCertificateWithCloudflareDns(params: {
|
|
server: Server
|
|
certName: string
|
|
domainNames: string[]
|
|
keyType: "rsa2048" | "ec256"
|
|
trustStore: string[]
|
|
onStep?: (step: string) => void
|
|
}): Promise<void> {
|
|
const settings = await getAcmeSettingsPublic()
|
|
const token = await getAcmeCloudflareToken()
|
|
if (!token) throw new Error("Не настроен Cloudflare API token")
|
|
|
|
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
|
|
if (domains.length === 0) throw new Error("Нужен хотя бы один домен")
|
|
|
|
const clientRos = MikrotikClient.fromServer(params.server)
|
|
assertLogicalCertificateName(await clientRos.get<unknown>("/certificate"),params.certName)
|
|
|
|
params.onStep?.("acme_order")
|
|
const accountKey = await getOrCreateAccountKey()
|
|
const client = new acme.Client({
|
|
directoryUrl: settings.directoryUrl,
|
|
accountKey,
|
|
})
|
|
await ensureAcmeAccount(client)
|
|
|
|
const altNames = domains.slice(1)
|
|
const privateKey = params.keyType === "ec256"
|
|
? await acme.crypto.createPrivateEcdsaKey("P-256")
|
|
: await acme.crypto.createPrivateKey(2048)
|
|
const [, csr] = await acme.crypto.createCsr({ commonName: domains[0], altNames }, privateKey)
|
|
|
|
const order = await client.createOrder({
|
|
identifiers: domains.map((value) => ({ type: "dns", value })),
|
|
})
|
|
|
|
const authorizations = await client.getAuthorizations(order)
|
|
const txtCleanups: Array<{ zoneId: string; recordId: string }> = []
|
|
|
|
try {
|
|
params.onStep?.("dns_challenge")
|
|
for (const authz of authorizations) {
|
|
const challenge = authz.challenges.find((c) => c.type === "dns-01")
|
|
if (!challenge) throw new Error(`Нет DNS-01 challenge для ${authz.identifier.value}`)
|
|
const txtValue = await client.getChallengeKeyAuthorization(challenge)
|
|
const zoneId = await resolveZoneId(token, authz.identifier.value, settings.defaultZoneId)
|
|
const recordName = `_acme-challenge.${authz.identifier.value}`
|
|
const recordId = await createTxtRecord(token, zoneId, recordName, txtValue)
|
|
txtCleanups.push({ zoneId, recordId })
|
|
}
|
|
|
|
await sleep(15_000)
|
|
|
|
for (const authz of authorizations) {
|
|
const challenge = authz.challenges.find((c) => c.type === "dns-01")
|
|
if (!challenge) continue
|
|
await client.verifyChallenge(authz, challenge)
|
|
await client.completeChallenge(challenge)
|
|
await client.waitForValidStatus(authz)
|
|
}
|
|
|
|
params.onStep?.("finalize")
|
|
const finalized = await client.finalizeOrder(order, csr)
|
|
const certPem = await client.getCertificate(finalized)
|
|
|
|
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
|
|
try {
|
|
params.onStep?.("dns_a_sync")
|
|
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
|
const { skippedCname } = await syncCertificateDomainRecords(
|
|
token,
|
|
domains,
|
|
serverIp,
|
|
settings.defaultZoneId,
|
|
)
|
|
if (skippedCname.length > 0) {
|
|
params.onStep?.(
|
|
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
|
|
)
|
|
}
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
|
|
params.onStep?.(`dns_a_sync_warn:${msg}`)
|
|
}
|
|
|
|
const trustStores = params.trustStore.filter(Boolean)
|
|
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
|
|
const trustStoreCsv = effectiveTrustStores.join(",")
|
|
|
|
params.onStep?.("import")
|
|
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
const chain = splitPemCertificates(certPem)
|
|
const leafPem = chain[0] ?? certPem
|
|
const expectedFingerprint = new X509Certificate(leafPem).fingerprint256
|
|
const generationName = `${safeBase.slice(0,40)}_new_${randomBytes(6).toString("hex")}`
|
|
const archiveName = `${safeBase.slice(0,40)}_old_${randomBytes(6).toString("hex")}`
|
|
const chainRest = chain.slice(1)
|
|
const certFile = `${safeBase}.crt`
|
|
const keyFile = `${safeBase}.key`
|
|
const certRouterFile = await clientRos.uploadTextFile(certFile, leafPem)
|
|
const keyRouterFile = await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
|
|
await clientRos.importCertificate({
|
|
fileName: certRouterFile,
|
|
name: generationName,
|
|
trusted: true,
|
|
trustStore: trustStoreCsv,
|
|
})
|
|
await clientRos.importCertificate({
|
|
fileName: keyRouterFile,
|
|
name: generationName,
|
|
trusted: true,
|
|
trustStore: trustStoreCsv,
|
|
})
|
|
|
|
for (let index = 0; index < chainRest.length; index += 1) {
|
|
const isRoot = index === chainRest.length - 1
|
|
const caName = isRoot ? `root_${params.certName}` : `root_${params.certName}_${index + 1}`
|
|
const caFile = `${safeBase}_ca_${index + 1}.crt`
|
|
const caRouterFile = await clientRos.uploadTextFile(caFile, chainRest[index]!)
|
|
await clientRos.importCertificate({
|
|
fileName: caRouterFile,
|
|
name: caName,
|
|
trusted: true,
|
|
trustStore: trustStoreCsv,
|
|
})
|
|
}
|
|
|
|
await rotateCertificateGeneration({
|
|
certificates: () => clientRos.get<unknown>("/certificate"),
|
|
apply: name => clientRos.applyCertificateToServices(name),
|
|
services: () => clientRos.get<unknown>("/ip/service"),
|
|
rename: (id,name) => clientRos.patch(`/certificate/${id}`,{name}).then(() => undefined),
|
|
}, params.certName, generationName, archiveName, expectedFingerprint)
|
|
} finally {
|
|
params.onStep?.("cleanup")
|
|
for (const item of txtCleanups) {
|
|
try {
|
|
await deleteTxtRecord(token, item.zoneId, item.recordId)
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
}
|
|
}
|
|
}
|