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
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user