Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df5d5ef4ab |
@@ -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,
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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"]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user