From 5e2c301442a8e4dd1a545603266cf13d8c260819 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 3 Sep 2026 20:05:35 +0700 Subject: [PATCH] =?UTF-8?q?fix(dns):=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BF.=20FQ?= =?UTF-8?q?DN=20=D0=BF=D1=80=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D1=82=D0=B5=20=D1=81=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B9/CF=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Больше не авто-подхватывать чужой CNAME в A-режиме (это стирало IP). Перед публикацией A удаляем конфликтующий CNAME и устаревшие A на том же имени. Co-authored-by: Cursor --- .../src/services/service-config-service.ts | 108 +++++++-- apps/api/test/dns-reconcile-extra.test.ts | 224 ++++++++++++++++++ 2 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 apps/api/test/dns-reconcile-extra.test.ts diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 760c12b..dee5480 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -697,26 +697,11 @@ async function syncBindingDns( desiredIps: string[], cnameTarget: string | null, ): Promise { - const domain = repos.getDomain(db, domainId); - const zoneName = domain.zone_name; - let effectiveCname = cnameTarget?.trim() || null; - - if (!effectiveCname) { - const existingCname = await findOrImportDnsRecord( - db, - cf, - domainId, - zoneName, - hostname, - "CNAME", - ); - if (existingCname) { - effectiveCname = existingCname.content; - repos.setBindingCnameTarget(db, bindingId, effectiveCname); - repos.replaceBindingIps(db, bindingId, []); - } - } + const effectiveCname = cnameTarget?.trim() || null; + // Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the + // binding is A-mode — that wiped IPs and blocked extra FQDN publishes. + // Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/ if (effectiveCname) { await syncBindingCnameDns( db, @@ -742,6 +727,72 @@ async function syncBindingDns( ); } +/** Delete every local+CF record for hostname that must not remain in A mode. */ +async function reconcileHostnameForA( + db: Db, + cf: CloudflareClient, + domainId: number, + zoneName: string, + hostname: string, + desiredIps: readonly string[], +): Promise { + const desired = new Set(desiredIps); + + // CNAME on the same name blocks creating A records in Cloudflare. + for (;;) { + const cname = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "CNAME", + ); + if (!cname) break; + await dnsService.deleteRecord(db, cf, domainId, cname.id); + } + + const domain = repos.getDomain(db, domainId); + const staleLocal = repos + .listDnsByDomain(db, domainId) + .filter( + (record) => + record.record_type.toUpperCase() === "A" && + dnsRecordNamesMatch(record.name, hostname, zoneName) && + !desired.has(record.content), + ); + for (const record of staleLocal) { + await dnsService.deleteRecord(db, cf, domainId, record.id); + } + + const remote = await cf.listDnsRecords(domain.cf_zone_id); + for (const cfRec of remote) { + if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue; + if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue; + if (desired.has(cfRec.content)) continue; + + const existing = repos.findDnsByCfId(db, domainId, cfRec.id); + if (existing) { + await dnsService.deleteRecord(db, cf, domainId, existing.id); + continue; + } + const imported = repos.insertDnsRecord( + db, + domainId, + cfRec.type, + cfRec.name, + cfRec.content, + cfRec.ttl, + cfRec.proxied ?? false, + cfRec.priority ?? null, + SYNC_SYNCED, + "cloudflare", + cfRec.id, + ); + await dnsService.deleteRecord(db, cf, domainId, imported.id); + } +} + async function syncBindingCnameDns( db: Db, cf: CloudflareClient, @@ -753,6 +804,21 @@ async function syncBindingCnameDns( const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; const normalized = normalizeCnameTarget(cnameTarget, zoneName); + + // A/AAAA on the same name blocks CNAME create in Cloudflare. + for (;;) { + const conflictingA = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "A", + ); + if (!conflictingA) break; + await dnsService.deleteRecord(db, cf, domainId, conflictingA.id); + } + const existingRecords = repos.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { @@ -830,6 +896,10 @@ async function syncBindingADns( ): Promise { const domain = repos.getDomain(db, domainId); const zoneName = domain.zone_name; + + // Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs). + await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps); + const existingRecords = repos.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { diff --git a/apps/api/test/dns-reconcile-extra.test.ts b/apps/api/test/dns-reconcile-extra.test.ts new file mode 100644 index 0000000..f663746 --- /dev/null +++ b/apps/api/test/dns-reconcile-extra.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryDb, repos, runMigrations } from "@cfdm/db"; +import { SYNC_SYNCED } from "@cfdm/shared"; +import type { CloudflareClient } from "../src/lib/cf-client.js"; +import { updateConfig } from "../src/services/service-config-service.js"; + +type CfRec = { + id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied: boolean; +}; + +function setupDb() { + const { db, sqlite } = createMemoryDb(); + runMigrations(sqlite); + return db; +} + +describe("DNS reconcile for extra FQDN", () => { + it("publishes extra A even when local/CF already have CNAME on same name", async () => { + const db = setupDb(); + const remote: CfRec[] = [ + { + id: "cf-cname-nsgt", + type: "CNAME", + name: "nsgt.example.com", + content: "legacy.example.com", + ttl: 1, + proxied: false, + }, + ]; + const created: Array<{ type: string; name: string; content: string }> = []; + const deleted: string[] = []; + + const cf = { + listDnsRecords: async () => remote, + createDnsRecord: async ( + _zoneId: string, + payload: { type: string; name: string; content: string }, + ) => { + created.push(payload); + const rec: CfRec = { + id: `cf-new-${created.length}`, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }; + remote.push(rec); + return rec; + }, + updateDnsRecord: async ( + _zoneId: string, + id: string, + payload: { type: string; name: string; content: string }, + ) => { + const idx = remote.findIndex((r) => r.id === id); + const rec: CfRec = { + id, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }; + if (idx >= 0) remote[idx] = rec; + else remote.push(rec); + return rec; + }, + deleteDnsRecord: async (_zoneId: string, id: string) => { + deleted.push(id); + const idx = remote.findIndex((r) => r.id === id); + if (idx >= 0) remote.splice(idx, 1); + }, + verifyToken: async () => true, + listZones: async () => [ + { id: "zone-1", name: "example.com", status: "active" }, + ], + } as unknown as CloudflareClient; + + const domain = repos.createDomain(db, null, "example.com", "zone-1"); + // Leftover local CNAME (previous service / manual import). + repos.insertDnsRecord( + db, + domain.id, + "CNAME", + "nsgt.example.com", + "legacy.example.com", + 1, + false, + null, + SYNC_SYNCED, + "cloudflare", + "cf-cname-nsgt", + ); + + const service = repos.createService(db, "MSK Hip", "tg-msk-hip"); + repos.setServiceEnabled(db, service.id, true); + + const view = await updateConfig(db, cf, service.id, { + ips: ["130.49.213.176"], + domains: [ + { + fqdn: "gt.example.com", + target_ips: ["130.49.213.176"], + }, + { + fqdn: "nsgt.example.com", + target_ips: ["130.49.213.176"], + }, + ], + }); + + const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com"); + expect(nsgt?.record_type).toBe("A"); + expect(nsgt?.target_ips).toEqual(["130.49.213.176"]); + expect(nsgt?.target_cname).toBeFalsy(); + + const binding = repos + .listBindingsByService(db, service.id) + .find((b) => b.hostname === "nsgt")!; + expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]); + expect(deleted).toContain("cf-cname-nsgt"); + expect( + created.some( + (r) => + r.type === "A" && + (r.name === "nsgt" || r.name === "nsgt.example.com") && + r.content === "130.49.213.176", + ), + ).toBe(true); + expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe( + false, + ); + expect( + remote.some( + (r) => + r.type === "A" && + r.name.includes("nsgt") && + r.content === "130.49.213.176", + ), + ).toBe(true); + }); + + it("replaces stale A content for extra FQDN on the same hostname", async () => { + const db = setupDb(); + const remote: CfRec[] = [ + { + id: "cf-stale-a", + type: "A", + name: "nsgt.example.com", + content: "1.1.1.1", + ttl: 1, + proxied: false, + }, + ]; + const deleted: string[] = []; + const created: Array<{ type: string; name: string; content: string }> = []; + + const cf = { + listDnsRecords: async () => [...remote], + createDnsRecord: async ( + _zoneId: string, + payload: { type: string; name: string; content: string }, + ) => { + created.push(payload); + const rec: CfRec = { + id: `cf-new-${created.length}`, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }; + remote.push(rec); + return rec; + }, + updateDnsRecord: async ( + _zoneId: string, + id: string, + payload: { type: string; name: string; content: string }, + ) => ({ + id, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }), + deleteDnsRecord: async (_zoneId: string, id: string) => { + deleted.push(id); + const idx = remote.findIndex((r) => r.id === id); + if (idx >= 0) remote.splice(idx, 1); + }, + verifyToken: async () => true, + listZones: async () => [ + { id: "zone-1", name: "example.com", status: "active" }, + ], + } as unknown as CloudflareClient; + + repos.createDomain(db, null, "example.com", "zone-1"); + const service = repos.createService(db, "MSK", "msk"); + repos.setServiceEnabled(db, service.id, true); + + await updateConfig(db, cf, service.id, { + ips: ["130.49.213.176"], + domains: [ + { fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] }, + ], + }); + + expect(deleted).toContain("cf-stale-a"); + expect( + created.some( + (r) => r.type === "A" && r.content === "130.49.213.176", + ), + ).toBe(true); + expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]); + }); +});