feat(dns): add functions to handle missing DNS records and mark records as synced
CD / quality (push) Successful in 1m16s
quality / changes (push) Successful in 9s
quality / web (push) Skipped
quality / api (push) Successful in 1m3s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
CD / publish (push) Successful in 1m24s
CD / quality (push) Successful in 1m16s
quality / changes (push) Successful in 9s
quality / web (push) Skipped
quality / api (push) Successful in 1m3s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
CD / publish (push) Successful in 1m24s
- Introduced `isMissingCfDnsRecord` to identify missing Cloudflare DNS records based on error messages. - Added `markSynced` function to update DNS record fields in the database and return the updated record. - Refactored `pushRecord` to utilize `markSynced` for better code organization and clarity. - Enhanced error handling for cases where DNS records need to be recreated after manual edits.
This commit is contained in:
@@ -64,6 +64,41 @@ function toCfPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingCfDnsRecord(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /record does not exist|81044/i.test(message);
|
||||
}
|
||||
|
||||
async function markSynced(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
record: DnsRecord,
|
||||
cfRec: {
|
||||
id?: string | null;
|
||||
type?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied?: boolean | null;
|
||||
priority?: number | null;
|
||||
},
|
||||
): Promise<DnsRecord> {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
cfRec.type ?? record.record_type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
}
|
||||
|
||||
async function pushRecord(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -84,22 +119,24 @@ async function pushRecord(
|
||||
const cfRec = record.cf_record_id
|
||||
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
|
||||
: await cf.createDnsRecord(cfZoneId, payload);
|
||||
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
cfRec.type ?? record.record_type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
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)) {
|
||||
try {
|
||||
const created = await cf.createDnsRecord(cfZoneId, payload);
|
||||
return markSynced(db, domainId, record, created);
|
||||
} catch (createErr) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
SYNC_ERROR,
|
||||
null,
|
||||
createErr instanceof Error ? createErr.message : String(createErr),
|
||||
);
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
|
||||
@@ -313,16 +313,20 @@ function desiredAIps(
|
||||
scope === "binding"
|
||||
? getBindingLbState(db, refId)
|
||||
: getGroupLbState(db, refId);
|
||||
const serviceIps =
|
||||
// Configured binding/group IPs stay intact; DNS publishes only enabled ones.
|
||||
const enabledIps =
|
||||
scope === "binding"
|
||||
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
|
||||
: fallbackIps;
|
||||
const enabledSet = new Set(enabledIps);
|
||||
const activeFallback = fallbackIps.filter((ip) => enabledSet.has(ip));
|
||||
const activeRows = state.rows.filter((row) => enabledSet.has(row.ip));
|
||||
return resolveDesiredAIps(
|
||||
state.config,
|
||||
state.rows,
|
||||
fallbackIps,
|
||||
activeRows,
|
||||
activeFallback,
|
||||
Date.now(),
|
||||
serviceIps,
|
||||
enabledIps,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1129,10 +1133,12 @@ async function collectGroupDnsIps(
|
||||
const ips: string[] = [];
|
||||
for (const service of services) {
|
||||
if (!service.enabled) continue;
|
||||
const enabled = new Set(enabledServiceIps(db, service.id));
|
||||
const bindings = repos.listBindingsByService(db, service.id);
|
||||
for (const binding of bindings) {
|
||||
for (const ip of repos.listBindingIps(db, binding.id)) {
|
||||
if (!ips.includes(ip)) ips.push(ip);
|
||||
if (!enabled.has(ip) || ips.includes(ip)) continue;
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1653,26 +1659,8 @@ export async function toggleServiceIp(
|
||||
repos.updateNode(db, node.id, { enabled });
|
||||
}
|
||||
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
for (const binding of bindings) {
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
const hasIp = current.some((entry) => entry.ip === ip);
|
||||
if (enabled && !hasIp) {
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
...current,
|
||||
{ ip, weight: 1, priority: 1 },
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (!enabled && hasIp) {
|
||||
repos.replaceBindingIpsWithMeta(
|
||||
db,
|
||||
binding.id,
|
||||
current.filter((entry) => entry.ip !== ip),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Keep binding IP membership stable (common FQDN = full pool). DNS sync
|
||||
// filters by enabledServiceIps via desiredAIps — do not reshuffle bindings.
|
||||
|
||||
const service = repos.getService(db, serviceId);
|
||||
if (shouldPushDns(db, service)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import {
|
||||
listGroupViews,
|
||||
toggleServiceIp,
|
||||
updateConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
|
||||
@@ -250,7 +251,7 @@ describe("create service then list groups", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -280,6 +281,18 @@ describe("create service then list groups", () => {
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
const domainPayload = {
|
||||
lb_mode: "round_robin" as const,
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp" as const,
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
};
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8"],
|
||||
service_group_id: group.id,
|
||||
@@ -289,23 +302,81 @@ describe("create service then list groups", () => {
|
||||
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
health_check_path: null,
|
||||
health_check_expected_status: null,
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_verify_tls: false,
|
||||
...domainPayload,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1 },
|
||||
...domainPayload,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
||||
// does not call the real Cloudflare client.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "panel")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
|
||||
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
|
||||
|
||||
expect(repos.listServiceIps(app.db, created.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
// DNS for common FQDN drops the disabled IP only.
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["5.6.7.8"]);
|
||||
// Per-IP extra FQDN has no enabled targets → A records removed.
|
||||
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
|
||||
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, extraBinding.id)
|
||||
.map((r) => r.content),
|
||||
).toEqual(["1.2.3.4"]);
|
||||
|
||||
// HTTP toggle still updates ip_enabled without mutating bindings.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const offRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
@@ -319,21 +390,7 @@ describe("create service then list groups", () => {
|
||||
};
|
||||
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
||||
|
||||
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
||||
|
||||
const onRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
headers,
|
||||
payload: { ip: "1.2.3.4", enabled: true },
|
||||
});
|
||||
expect(onRes.statusCode).toBe(200);
|
||||
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
||||
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user