fix(dns): искать записи в Cloudflare по имени и содержимому
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 7s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 48s
CD / quality (push) Successful in 58s
CD / publish (push) Successful in 1m35s

cf_record_id только как кэш-подсказка: при удалении/обновлении
сначала list + match type/name/content, иначе create или no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-09-03 20:19:16 +07:00
co-authored by Cursor
parent 5e2c301442
commit 75575a3243
2 changed files with 359 additions and 41 deletions
+139 -41
View File
@@ -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<DnsRecord> {
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<DnsRecord> {
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);
}
+220
View File
@@ -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);
});
});