Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec df5d5ef4ab 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
- 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.
2026-09-03 18:52:39 +07:00
3 changed files with 150 additions and 68 deletions
+52 -15
View File
@@ -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( async function pushRecord(
db: Db, db: Db,
cf: CloudflareClient, cf: CloudflareClient,
@@ -84,22 +119,24 @@ async function pushRecord(
const cfRec = record.cf_record_id const cfRec = record.cf_record_id
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload) ? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
: await cf.createDnsRecord(cfZoneId, payload); : await cf.createDnsRecord(cfZoneId, payload);
return markSynced(db, domainId, record, cfRec);
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);
} catch (e) { } 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( repos.setDnsSyncStatus(
db, db,
record.id, record.id,
+13 -25
View File
@@ -313,16 +313,20 @@ function desiredAIps(
scope === "binding" scope === "binding"
? getBindingLbState(db, refId) ? getBindingLbState(db, refId)
: getGroupLbState(db, refId); : getGroupLbState(db, refId);
const serviceIps = // Configured binding/group IPs stay intact; DNS publishes only enabled ones.
const enabledIps =
scope === "binding" scope === "binding"
? enabledServiceIps(db, repos.getBinding(db, refId).service_id) ? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
: fallbackIps; : 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( return resolveDesiredAIps(
state.config, state.config,
state.rows, activeRows,
fallbackIps, activeFallback,
Date.now(), Date.now(),
serviceIps, enabledIps,
); );
} }
@@ -1129,10 +1133,12 @@ async function collectGroupDnsIps(
const ips: string[] = []; const ips: string[] = [];
for (const service of services) { for (const service of services) {
if (!service.enabled) continue; if (!service.enabled) continue;
const enabled = new Set(enabledServiceIps(db, service.id));
const bindings = repos.listBindingsByService(db, service.id); const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) { for (const binding of bindings) {
for (const ip of repos.listBindingIps(db, binding.id)) { 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 }); repos.updateNode(db, node.id, { enabled });
} }
const bindings = repos.listBindingsByService(db, serviceId); // Keep binding IP membership stable (common FQDN = full pool). DNS sync
for (const binding of bindings) { // filters by enabledServiceIps via desiredAIps — do not reshuffle 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),
);
}
}
const service = repos.getService(db, serviceId); const service = repos.getService(db, serviceId);
if (shouldPushDns(db, service)) { if (shouldPushDns(db, service)) {
+85 -28
View File
@@ -6,6 +6,7 @@ import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js"; import { loadConfig } from "../src/config.js";
import { import {
listGroupViews, listGroupViews,
toggleServiceIp,
updateConfig, updateConfig,
} from "../src/services/service-config-service.js"; } from "../src/services/service-config-service.js";
@@ -250,7 +251,7 @@ describe("create service then list groups", () => {
await app.close(); 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({ const app = await buildApp({
config: { ...loadConfig(), staticDir: null }, config: { ...loadConfig(), staticDir: null },
memory: true, memory: true,
@@ -280,6 +281,18 @@ describe("create service then list groups", () => {
expect(createRes.statusCode).toBe(200); expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number }; 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, { await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8"], ips: ["1.2.3.4", "5.6.7.8"],
service_group_id: group.id, 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_ips: ["1.2.3.4", "5.6.7.8"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 }, 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 }, target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
lb_mode: "round_robin", ...domainPayload,
health_check_enabled: false, },
health_check_type: "tcp", {
health_check_port: 443, fqdn: "extra.example.com",
health_check_path: null, target_ips: ["1.2.3.4"],
health_check_expected_status: null, target_ip_weights: { "1.2.3.4": 1 },
health_check_interval_sec: 30, target_ip_priorities: { "1.2.3.4": 1 },
health_check_timeout_ms: 3000, ...domainPayload,
health_check_verify_tls: false,
}, },
], ],
}); });
// HTTP toggle uses request.server.cf; disable DNS push so the test const commonBinding = repos
// does not call the real Cloudflare client. .listBindingsByService(app.db, created.id)
repos.setServiceEnabled(app.db, created.id, false); .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({ const offRes = await app.inject({
method: "PATCH", method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`, 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.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["1.2.3.4"]).toBe(false);
expect(offView.ip_enabled["5.6.7.8"]).toBe(true); expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
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.arrayContaining(["1.2.3.4", "5.6.7.8"]), expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
); );