From 75575a3243b9c7bdb3be6b285a990d7e6da17137 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 3 Sep 2026 20:19:16 +0700 Subject: [PATCH] =?UTF-8?q?fix(dns):=20=D0=B8=D1=81=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B2=20Cloudf?= =?UTF-8?q?lare=20=D0=BF=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=BC=D0=BE=D0=BC?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cf_record_id только как кэш-подсказка: при удалении/обновлении сначала list + match type/name/content, иначе create или no-op. Co-authored-by: Cursor --- apps/api/src/services/dns-service.ts | 180 +++++++++++++++++----- apps/api/test/dns-identity.test.ts | 220 +++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 41 deletions(-) create mode 100644 apps/api/test/dns-identity.test.ts diff --git a/apps/api/src/services/dns-service.ts b/apps/api/src/services/dns-service.ts index 16ae4aa..5e3d819 100644 --- a/apps/api/src/services/dns-service.ts +++ b/apps/api/src/services/dns-service.ts @@ -1,11 +1,17 @@ import type { Db } from "@cfdm/db"; import { repos, type DnsListFilter } from "@cfdm/db"; -import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared"; +import type { + CfDnsRecord, + CreateDnsRecordPayload, + DnsRecord, + PatchDnsRecordPayload, +} from "@cfdm/shared"; import { SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_PUSH, SYNC_SYNCED, + dnsRecordNamesMatch, normalizeDnsRecordName, } from "@cfdm/shared"; import type { CloudflareClient } from "../lib/cf-client.js"; @@ -69,6 +75,46 @@ function isMissingCfDnsRecord(error: unknown): boolean { return /record does not exist|81044/i.test(message); } +function dnsContentMatches( + recordType: string, + left: string, + right: string, +): boolean { + if (recordType.toUpperCase() === "CNAME") { + return ( + left.trim().replace(/\.+$/, "").toLowerCase() === + right.trim().replace(/\.+$/, "").toLowerCase() + ); + } + return left === right; +} + +/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */ +function findRemoteByIdentity( + remote: readonly CfDnsRecord[], + zoneName: string, + recordType: string, + name: string, + content: string, +): CfDnsRecord | undefined { + const type = recordType.toUpperCase(); + return remote.find( + (record) => + Boolean(record.id) && + (record.type ?? "").toUpperCase() === type && + dnsRecordNamesMatch(record.name, name, zoneName) && + dnsContentMatches(type, record.content, content), + ); +} + +function findRemoteByCfId( + remote: readonly CfDnsRecord[], + cfRecordId: string | null | undefined, +): CfDnsRecord | undefined { + if (!cfRecordId) return undefined; + return remote.find((record) => record.id === cfRecordId); +} + async function markSynced( db: Db, domainId: number, @@ -99,6 +145,11 @@ async function markSynced( return repos.getDnsRecord(db, domainId, record.id); } +/** + * Push local desired state to Cloudflare. + * Identity is name + type + content; cf_record_id is only a cache hint + * (records may be deleted/recreated outside CFDM). + */ async function pushRecord( db: Db, cf: CloudflareClient, @@ -106,6 +157,7 @@ async function pushRecord( cfZoneId: string, record: DnsRecord, ): Promise { + const domain = repos.getDomain(db, domainId); const payload = toCfPayload( record.record_type, record.name, @@ -116,13 +168,24 @@ async function pushRecord( ); try { - const cfRec = record.cf_record_id - ? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload) + const remote = await cf.listDnsRecords(cfZoneId); + const byIdentity = findRemoteByIdentity( + remote, + domain.zone_name, + record.record_type, + record.name, + record.content, + ); + const byCachedId = findRemoteByCfId(remote, record.cf_record_id); + const targetId = byIdentity?.id ?? byCachedId?.id ?? null; + + const cfRec = targetId + ? await cf.updateDnsRecord(cfZoneId, targetId, payload) : await cf.createDnsRecord(cfZoneId, payload); return markSynced(db, domainId, record, cfRec); } catch (e) { - // Stale cf_record_id after manual CF edits / prior buggy sync — recreate. - if (record.cf_record_id && isMissingCfDnsRecord(e)) { + // Race: cached id vanished mid-flight — recreate by identity. + if (isMissingCfDnsRecord(e)) { try { const created = await cf.createDnsRecord(cfZoneId, payload); return markSynced(db, domainId, record, created); @@ -225,15 +288,23 @@ export async function patchContent( ): Promise { const domain = repos.getDomain(db, domainId); const existing = repos.getDnsRecord(db, domainId, recordId); - if (!existing.cf_record_id) { - throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare"); - } try { - const cfRec = await cf.patchDnsRecord( - domain.cf_zone_id, - existing.cf_record_id, - payload, + const remote = await cf.listDnsRecords(domain.cf_zone_id); + const byIdentity = findRemoteByIdentity( + remote, + domain.zone_name, + existing.record_type, + existing.name, + existing.content, ); + const byCachedId = findRemoteByCfId(remote, existing.cf_record_id); + const targetId = byIdentity?.id ?? byCachedId?.id ?? null; + if (!targetId) { + throw AppError.dnsUpdateFailed( + "DNS-запись не найдена в Cloudflare по имени и содержимому", + ); + } + const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload); repos.updateDnsFields( db, existing.id, @@ -244,7 +315,7 @@ export async function patchContent( cfRec.proxied ?? existing.proxied, cfRec.priority ?? existing.priority, SYNC_SYNCED, - cfRec.id ?? existing.cf_record_id, + cfRec.id ?? targetId, null, ); return repos.getDnsRecord(db, domainId, existing.id); @@ -272,20 +343,41 @@ export async function deleteRecord( const record = repos.getDnsRecord(db, domainId, recordId); repos.markDnsPendingDelete(db, recordId); - if (record.cf_record_id) { + let targetId: string | null = null; + try { + const remote = await cf.listDnsRecords(domain.cf_zone_id); + const byIdentity = findRemoteByIdentity( + remote, + domain.zone_name, + record.record_type, + record.name, + record.content, + ); + const byCachedId = findRemoteByCfId(remote, record.cf_record_id); + targetId = byIdentity?.id ?? byCachedId?.id ?? null; + } catch { + // Zone list failed — fall back to cached id only. + targetId = record.cf_record_id; + } + + if (targetId) { try { - await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id); + await cf.deleteDnsRecord(domain.cf_zone_id, targetId); } catch (e) { - repos.setDnsSyncStatus( - db, - recordId, - SYNC_ERROR, - record.cf_record_id, - e instanceof Error ? e.message : String(e), - ); - throw e; + // Already gone in Cloudflare (manual delete) — drop local row. + if (!isMissingCfDnsRecord(e)) { + repos.setDnsSyncStatus( + db, + recordId, + SYNC_ERROR, + targetId, + e instanceof Error ? e.message : String(e), + ); + throw e; + } } } + repos.deleteDnsRecord(db, recordId); } @@ -363,24 +455,30 @@ export async function resolveConflict( } if (req.source === "cloudflare") { - if (record.cf_record_id) { - const remote = await cf.listDnsRecords(domain.cf_zone_id); - const r = remote.find((x) => x.id === record.cf_record_id); - if (r) { - repos.updateDnsFields( - db, - recordId, - r.type, - r.name, - r.content, - r.ttl, - r.proxied ?? false, - r.priority ?? null, - SYNC_SYNCED, - r.id ?? null, - null, - ); - } + const remote = await cf.listDnsRecords(domain.cf_zone_id); + const byIdentity = findRemoteByIdentity( + remote, + domain.zone_name, + record.record_type, + record.name, + record.content, + ); + const byCachedId = findRemoteByCfId(remote, record.cf_record_id); + const r = byIdentity ?? byCachedId; + if (r) { + repos.updateDnsFields( + db, + recordId, + r.type, + r.name, + r.content, + r.ttl, + r.proxied ?? false, + r.priority ?? null, + SYNC_SYNCED, + r.id ?? null, + null, + ); } return repos.getDnsRecord(db, domainId, recordId); } diff --git a/apps/api/test/dns-identity.test.ts b/apps/api/test/dns-identity.test.ts new file mode 100644 index 0000000..21b1084 --- /dev/null +++ b/apps/api/test/dns-identity.test.ts @@ -0,0 +1,220 @@ +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 * as dnsService from "../src/services/dns-service.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 identity by name + content", () => { + it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => { + const db = setupDb(); + const remote: CfRec[] = [ + { + id: "cf-live", + type: "A", + name: "nsgt.example.com", + content: "130.49.213.176", + ttl: 1, + proxied: false, + }, + ]; + const deleted: string[] = []; + + const cf = { + listDnsRecords: async () => [...remote], + createDnsRecord: async () => { + throw new Error("create should not run"); + }, + updateDnsRecord: async () => { + throw new Error("update should not run"); + }, + 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 () => [], + } as unknown as CloudflareClient; + + const domain = repos.createDomain(db, null, "example.com", "zone-1"); + const local = repos.insertDnsRecord( + db, + domain.id, + "A", + "nsgt.example.com", + "130.49.213.176", + 1, + false, + null, + SYNC_SYNCED, + "local", + "cf-stale-gone", // not present in Cloudflare + ); + + await dnsService.deleteRecord(db, cf, domain.id, local.id); + + expect(deleted).toEqual(["cf-live"]); + expect(repos.listDnsByDomain(db, domain.id)).toEqual([]); + expect(remote).toEqual([]); + }); + + it("delete is no-op success when record already removed outside CFDM", async () => { + const db = setupDb(); + const cf = { + listDnsRecords: async () => [], + deleteDnsRecord: async () => { + throw new Error("Record does not exist"); + }, + verifyToken: async () => true, + listZones: async () => [], + } as unknown as CloudflareClient; + + const domain = repos.createDomain(db, null, "example.com", "zone-1"); + const local = repos.insertDnsRecord( + db, + domain.id, + "A", + "nsgt.example.com", + "130.49.213.176", + 1, + false, + null, + SYNC_SYNCED, + "local", + "cf-already-gone", + ); + + await expect( + dnsService.deleteRecord(db, cf, domain.id, local.id), + ).resolves.toBeUndefined(); + expect(repos.listDnsByDomain(db, domain.id)).toEqual([]); + }); + + it("updateConfig can remove extra FQDN when CF id is stale", async () => { + const db = setupDb(); + const remote: CfRec[] = [ + { + id: "cf-gt", + type: "A", + name: "gt.example.com", + content: "130.49.213.176", + ttl: 1, + proxied: false, + }, + { + id: "cf-nsgt-live", + type: "A", + name: "nsgt.example.com", + content: "130.49.213.176", + ttl: 1, + proxied: false, + }, + ]; + const deleted: string[] = []; + + const cf = { + listDnsRecords: async () => [...remote], + createDnsRecord: async ( + _zoneId: string, + payload: { type: string; name: string; content: string }, + ) => { + const rec: CfRec = { + id: `cf-new-${remote.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); + if (idx < 0) throw new Error("Record does not exist"); + const rec: CfRec = { + id, + type: payload.type, + name: payload.name, + content: payload.content, + ttl: 1, + proxied: false, + }; + remote[idx] = rec; + return rec; + }, + deleteDnsRecord: async (_zoneId: string, id: string) => { + const idx = remote.findIndex((r) => r.id === id); + if (idx < 0) throw new Error("Record does not exist"); + deleted.push(id); + 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, "Main TG", "tg-pr"); + repos.setServiceEnabled(db, service.id, true); + + 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"] }, + ], + }); + + // Poison cached id on extra binding's DNS row. + const nsgtBinding = repos + .listBindingsByService(db, service.id) + .find((b) => b.hostname === "nsgt")!; + const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id); + for (const row of nsgtRecords) { + repos.updateDnsFields( + db, + row.id, + row.record_type, + row.name, + row.content, + row.ttl, + row.proxied, + row.priority, + SYNC_SYNCED, + "cf-stale-nsgt", + null, + ); + } + + const view = await updateConfig(db, cf, service.id, { + ips: ["130.49.213.176"], + domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }], + }); + + expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]); + expect(deleted).toContain("cf-nsgt-live"); + expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false); + }); +});