diff --git a/apps/api/src/services/dns-service.ts b/apps/api/src/services/dns-service.ts index d202019..16ae4aa 100644 --- a/apps/api/src/services/dns-service.ts +++ b/apps/api/src/services/dns-service.ts @@ -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 { + 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, diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index abdf7e5..b0757e8 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -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)) { diff --git a/apps/api/test/services-create-list.test.ts b/apps/api/test/services-create-list.test.ts index 7f46aa5..8611bd8 100644 --- a/apps/api/test/services-create-list.test.ts +++ b/apps/api/test/services-create-list.test.ts @@ -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 }; - 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"]), );