Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b078fa0a03 | ||
|
|
75575a3243 | ||
|
|
5e2c301442 | ||
|
|
56cddedefe | ||
|
|
df5d5ef4ab |
@@ -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";
|
||||
@@ -64,6 +70,86 @@ 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);
|
||||
}
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -71,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,
|
||||
@@ -81,25 +168,38 @@ async function pushRecord(
|
||||
);
|
||||
|
||||
try {
|
||||
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,
|
||||
const remote = await cf.listDnsRecords(cfZoneId);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
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) {
|
||||
// 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);
|
||||
} catch (createErr) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
SYNC_ERROR,
|
||||
null,
|
||||
createErr instanceof Error ? createErr.message : String(createErr),
|
||||
);
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
@@ -188,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,
|
||||
@@ -207,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);
|
||||
@@ -235,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);
|
||||
}
|
||||
|
||||
@@ -326,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,7 +345,38 @@ async function collectKnownZones(
|
||||
return zones;
|
||||
}
|
||||
|
||||
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
|
||||
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
|
||||
const pool = repos.listServiceIps(db, serviceId);
|
||||
if (pool.length < 2) return;
|
||||
const poolSet = new Set(pool);
|
||||
|
||||
for (const binding of repos.listBindingsByService(db, serviceId)) {
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
if (current.length <= 1) continue;
|
||||
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
|
||||
|
||||
const currentSet = new Set(current.map((entry) => entry.ip));
|
||||
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
|
||||
repos.replaceBindingIpsWithMeta(
|
||||
db,
|
||||
binding.id,
|
||||
pool.map((ip) => ({
|
||||
ip,
|
||||
weight: byIp.get(ip)?.weight ?? 1,
|
||||
priority: byIp.get(ip)?.priority ?? 1,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
repairPoolSubsetBindings(db, serviceId);
|
||||
const service = repos.getService(db, serviceId);
|
||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||
const ips = ipRows.map((row) => row.ip);
|
||||
@@ -511,6 +546,34 @@ function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
|
||||
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
|
||||
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
|
||||
return true;
|
||||
}
|
||||
if (!view.service_group_id) return false;
|
||||
const group = repos.getServiceGroup(db, view.service_group_id);
|
||||
return Boolean(group.health_check_enabled);
|
||||
}
|
||||
|
||||
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
|
||||
if (!view.enabled) return false;
|
||||
if (view.ip_enabled[ip] === false) return false;
|
||||
return isServiceHealthCheckActive(db, view);
|
||||
}
|
||||
|
||||
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
|
||||
return {
|
||||
ip,
|
||||
status: "unknown",
|
||||
latency_ms: null,
|
||||
last_checked_at: null,
|
||||
last_error: null,
|
||||
provider: "local",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
|
||||
function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
@@ -532,6 +595,9 @@ function attachServiceHealth(
|
||||
),
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
if (!isIpHealthMonitored(db, view, ip)) {
|
||||
return inactiveIpHealthRow(ip);
|
||||
}
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
const live = liveByIp.get(ip);
|
||||
const status = overlayLiveHealth(row?.status, live?.status);
|
||||
@@ -549,17 +615,26 @@ function attachServiceHealth(
|
||||
colo: extras?.colo ?? null,
|
||||
};
|
||||
});
|
||||
const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
|
||||
const monitoredStatuses = ip_health
|
||||
.filter((row) => isIpHealthMonitored(db, view, row.ip))
|
||||
.map((row) => row.status);
|
||||
const displayStatus =
|
||||
monitoredStatuses.length > 0
|
||||
? bestAliveDisplayStatus(monitoredStatuses)
|
||||
: ("unknown" as const);
|
||||
const latencyRow =
|
||||
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
||||
ip_health.find((row) => row.latency_ms != null);
|
||||
return {
|
||||
...view,
|
||||
health_status: overlayLiveHealth(health?.health_status, displayStatus),
|
||||
health_status:
|
||||
monitoredStatuses.length > 0
|
||||
? overlayLiveHealth(health?.health_status, displayStatus)
|
||||
: "unknown",
|
||||
health_latency_ms:
|
||||
displayStatus !== "unknown"
|
||||
monitoredStatuses.length > 0 && displayStatus !== "unknown"
|
||||
? (latencyRow?.latency_ms ?? null)
|
||||
: (health?.health_latency_ms ?? null),
|
||||
: null,
|
||||
ip_health,
|
||||
};
|
||||
});
|
||||
@@ -662,26 +737,11 @@ async function syncBindingDns(
|
||||
desiredIps: string[],
|
||||
cnameTarget: string | null,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
let effectiveCname = cnameTarget?.trim() || null;
|
||||
|
||||
if (!effectiveCname) {
|
||||
const existingCname = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
);
|
||||
if (existingCname) {
|
||||
effectiveCname = existingCname.content;
|
||||
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
|
||||
repos.replaceBindingIps(db, bindingId, []);
|
||||
}
|
||||
}
|
||||
const effectiveCname = cnameTarget?.trim() || null;
|
||||
|
||||
// Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the
|
||||
// binding is A-mode — that wiped IPs and blocked extra FQDN publishes.
|
||||
// Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
|
||||
if (effectiveCname) {
|
||||
await syncBindingCnameDns(
|
||||
db,
|
||||
@@ -707,6 +767,72 @@ async function syncBindingDns(
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete every local+CF record for hostname that must not remain in A mode. */
|
||||
async function reconcileHostnameForA(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
zoneName: string,
|
||||
hostname: string,
|
||||
desiredIps: readonly string[],
|
||||
): Promise<void> {
|
||||
const desired = new Set(desiredIps);
|
||||
|
||||
// CNAME on the same name blocks creating A records in Cloudflare.
|
||||
for (;;) {
|
||||
const cname = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
);
|
||||
if (!cname) break;
|
||||
await dnsService.deleteRecord(db, cf, domainId, cname.id);
|
||||
}
|
||||
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const staleLocal = repos
|
||||
.listDnsByDomain(db, domainId)
|
||||
.filter(
|
||||
(record) =>
|
||||
record.record_type.toUpperCase() === "A" &&
|
||||
dnsRecordNamesMatch(record.name, hostname, zoneName) &&
|
||||
!desired.has(record.content),
|
||||
);
|
||||
for (const record of staleLocal) {
|
||||
await dnsService.deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
for (const cfRec of remote) {
|
||||
if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue;
|
||||
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
|
||||
if (desired.has(cfRec.content)) continue;
|
||||
|
||||
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
|
||||
if (existing) {
|
||||
await dnsService.deleteRecord(db, cf, domainId, existing.id);
|
||||
continue;
|
||||
}
|
||||
const imported = repos.insertDnsRecord(
|
||||
db,
|
||||
domainId,
|
||||
cfRec.type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
cfRec.id,
|
||||
);
|
||||
await dnsService.deleteRecord(db, cf, domainId, imported.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBindingCnameDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -718,6 +844,21 @@ async function syncBindingCnameDns(
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
||||
|
||||
// A/AAAA on the same name blocks CNAME create in Cloudflare.
|
||||
for (;;) {
|
||||
const conflictingA = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"A",
|
||||
);
|
||||
if (!conflictingA) break;
|
||||
await dnsService.deleteRecord(db, cf, domainId, conflictingA.id);
|
||||
}
|
||||
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
@@ -795,6 +936,10 @@ async function syncBindingADns(
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
|
||||
// Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs).
|
||||
await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps);
|
||||
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
@@ -1129,10 +1274,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 +1800,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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
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 { 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 reconcile for extra FQDN", () => {
|
||||
it("publishes extra A even when local/CF already have CNAME on same name", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-cname-nsgt",
|
||||
type: "CNAME",
|
||||
name: "nsgt.example.com",
|
||||
content: "legacy.example.com",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => remote,
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
created.push(payload);
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${created.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);
|
||||
const rec: CfRec = {
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
if (idx >= 0) remote[idx] = rec;
|
||||
else remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
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 () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
// Leftover local CNAME (previous service / manual import).
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"CNAME",
|
||||
"nsgt.example.com",
|
||||
"legacy.example.com",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
"cf-cname-nsgt",
|
||||
);
|
||||
|
||||
const service = repos.createService(db, "MSK Hip", "tg-msk-hip");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
const view = 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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com");
|
||||
expect(nsgt?.record_type).toBe("A");
|
||||
expect(nsgt?.target_ips).toEqual(["130.49.213.176"]);
|
||||
expect(nsgt?.target_cname).toBeFalsy();
|
||||
|
||||
const binding = repos
|
||||
.listBindingsByService(db, service.id)
|
||||
.find((b) => b.hostname === "nsgt")!;
|
||||
expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]);
|
||||
expect(deleted).toContain("cf-cname-nsgt");
|
||||
expect(
|
||||
created.some(
|
||||
(r) =>
|
||||
r.type === "A" &&
|
||||
(r.name === "nsgt" || r.name === "nsgt.example.com") &&
|
||||
r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
remote.some(
|
||||
(r) =>
|
||||
r.type === "A" &&
|
||||
r.name.includes("nsgt") &&
|
||||
r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces stale A content for extra FQDN on the same hostname", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-stale-a",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "1.1.1.1",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
created.push(payload);
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${created.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 },
|
||||
) => ({
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
}),
|
||||
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 () => [
|
||||
{ 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, "MSK", "msk");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(deleted).toContain("cf-stale-a");
|
||||
expect(
|
||||
created.some(
|
||||
(r) => r.type === "A" && r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]);
|
||||
});
|
||||
});
|
||||
@@ -376,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
@@ -412,6 +413,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||
{ ip: "10.0.0.2", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
@@ -450,6 +452,7 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
@@ -478,4 +481,74 @@ describe("CNAME health mapped onto service IPs", () => {
|
||||
expect(view.ip_health[0]?.status).toBe("up");
|
||||
expect(view.ip_health[0]?.latency_ms).toBe(63);
|
||||
});
|
||||
|
||||
it("getView masks stale down when health-check is disabled", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "Main TG", "main-tg");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"130.49.213.176",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("unknown");
|
||||
expect(view.ip_health).toEqual([
|
||||
expect.objectContaining({
|
||||
ip: "130.49.213.176",
|
||||
status: "unknown",
|
||||
latency_ms: null,
|
||||
last_error: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("getView masks stale down when IP is disabled in pool", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { getView } = await import("../src/services/service-config-service.js");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "Main TG", "main-tg");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"130.49.213.176",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("unknown");
|
||||
expect(view.ip_health[0]?.status).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,24 +390,117 @@ 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"]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const cf = mockCf();
|
||||
|
||||
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||
const group = repos.createServiceGroup(
|
||||
app.db,
|
||||
"VPN",
|
||||
"vpn-repair",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
);
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/services",
|
||||
headers,
|
||||
payload: {
|
||||
name: "Repair",
|
||||
slug: "panel-ip-repair",
|
||||
service_group_id: group.id,
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
service_group_id: group.id,
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 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,
|
||||
},
|
||||
{
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "gw")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
|
||||
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
||||
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
||||
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
]);
|
||||
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${created.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const view = getRes.json() as {
|
||||
domains: Array<{ fqdn: string; target_ips: string[] }>;
|
||||
};
|
||||
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
||||
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
||||
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
||||
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
"9.9.9.9",
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@ type HealthStatus =
|
||||
|
||||
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
||||
if (status === 'healthy') return 'up'
|
||||
if (status === 'unhealthy' || status === 'disabled') return 'down'
|
||||
if (status === 'checking') return 'unknown'
|
||||
if (status === 'unhealthy') return 'down'
|
||||
if (status === 'disabled' || status === 'checking') return 'unknown'
|
||||
return status
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
|
||||
interface ServiceIpListProps {
|
||||
ips: string[]
|
||||
ipHealth?: ServiceView['ip_health']
|
||||
healthCheckEnabled?: boolean
|
||||
ipEnabled?: Record<string, boolean>
|
||||
togglingIp?: string | null
|
||||
ipToggleDisabled?: boolean
|
||||
@@ -151,6 +152,7 @@ interface ServiceIpListProps {
|
||||
export function ServiceIpList({
|
||||
ips,
|
||||
ipHealth = [],
|
||||
healthCheckEnabled = true,
|
||||
ipEnabled = {},
|
||||
togglingIp = null,
|
||||
ipToggleDisabled = false,
|
||||
@@ -184,6 +186,10 @@ export function ServiceIpList({
|
||||
{visible.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
const enabled = ipEnabled[ip] !== false
|
||||
const monitored = healthCheckEnabled && enabled
|
||||
const badgeStatus = monitored
|
||||
? (health?.status ?? 'unknown')
|
||||
: 'disabled'
|
||||
return (
|
||||
<Item
|
||||
key={ip}
|
||||
@@ -192,7 +198,7 @@ export function ServiceIpList({
|
||||
>
|
||||
<ItemMedia>
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
status={badgeStatus}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
|
||||
@@ -273,6 +273,10 @@ export function ServiceUnitCard({
|
||||
alignWithMenu
|
||||
ips={service.ips ?? []}
|
||||
ipHealth={service.ip_health ?? []}
|
||||
healthCheckEnabled={
|
||||
service.enabled &&
|
||||
(service.domains ?? []).some((domain) => domain.health_check_enabled)
|
||||
}
|
||||
ipEnabled={service.ip_enabled ?? {}}
|
||||
ipToggleDisabled={togglingId === service.id}
|
||||
togglingIp={togglingIp}
|
||||
|
||||
@@ -138,6 +138,46 @@ describe('hydrateAddressBlock', () => {
|
||||
})
|
||||
expect(state.nodes[0]?.extraFqdns).toEqual(['nsgt.rkns.top'])
|
||||
})
|
||||
|
||||
it('лечит урезанный общий FQDN (legacy toggle) как common, не preserved', () => {
|
||||
const pool = ['130.49.213.153', '130.49.213.176', '93.115.203.183']
|
||||
const drafts = [
|
||||
// corrupted common — missing one pool IP
|
||||
aRecord('gw.pngs.top', ['130.49.213.153', '130.49.213.176']),
|
||||
aRecord('gt.rkns.top', pool),
|
||||
aRecord('nsgt.rkns.top', ['130.49.213.176']),
|
||||
aRecord('rutg.rkns.top', ['93.115.203.183']),
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, pool)
|
||||
|
||||
expect(state.commonFqdns).toEqual(['gw.pngs.top', 'gt.rkns.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '130.49.213.153', extraFqdns: [] },
|
||||
{ ip: '130.49.213.176', extraFqdns: ['nsgt.rkns.top'] },
|
||||
{ ip: '93.115.203.183', extraFqdns: ['rutg.rkns.top'] },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||
'gw.pngs.top',
|
||||
'gt.rkns.top',
|
||||
'nsgt.rkns.top',
|
||||
'rutg.rkns.top',
|
||||
])
|
||||
expect(payload[0]?.target_ips).toEqual(pool)
|
||||
expect(new Set(payload.map((item) => item.fqdn.toLowerCase())).size).toBe(4)
|
||||
})
|
||||
|
||||
it('не дублирует FQDN при повторном binding в drafts', () => {
|
||||
const pool = ['1.1.1.1', '2.2.2.2']
|
||||
const state = hydrateAddressBlock(
|
||||
[aRecord('gw.example.com', ['1.1.1.1']), aRecord('gw.example.com', pool)],
|
||||
pool,
|
||||
)
|
||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||
expect(state.nodes.every((node) => node.extraFqdns.length === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toDomainsPayload', () => {
|
||||
|
||||
@@ -113,6 +113,10 @@ function sameIpSet(left: string[], right: string[]): boolean {
|
||||
return right.every((ip) => set.has(ip.trim()))
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
@@ -145,14 +149,27 @@ function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||
}
|
||||
|
||||
/** UI contract: common = 2+ IPs all in pool (full or corrupted subset after old toggles). */
|
||||
function isCommonPoolA(draft: ServiceBindingDraft, poolSet: Set<string>): boolean {
|
||||
if (draft.record_type !== 'A') return false
|
||||
const ips = draft.target_ips.map((ip) => ip.trim()).filter(Boolean)
|
||||
if (ips.length < 2) return false
|
||||
return ips.every((ip) => poolSet.has(ip))
|
||||
}
|
||||
|
||||
function takeAsCommon(
|
||||
draft: ServiceBindingDraft,
|
||||
fqdn: string,
|
||||
commonFqdns: string[],
|
||||
seenCommon: Set<string>,
|
||||
weights: Record<string, number>,
|
||||
priorities: Record<string, number>,
|
||||
): { weights: Record<string, number>; priorities: Record<string, number> } {
|
||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
||||
const key = fqdnKey(fqdn)
|
||||
if (fqdn && key && !seenCommon.has(key)) {
|
||||
seenCommon.add(key)
|
||||
commonFqdns.push(draft.fqdn)
|
||||
}
|
||||
if (Object.keys(weights).length === 0) {
|
||||
return {
|
||||
weights: { ...draft.target_ip_weights },
|
||||
@@ -178,29 +195,56 @@ export function hydrateAddressBlock(
|
||||
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
||||
const poolSet = new Set(ips)
|
||||
const commonFqdns: string[] = []
|
||||
const seenCommon = new Set<string>()
|
||||
const seenExtra = new Set<string>()
|
||||
const extraByIp = new Map<string, string[]>()
|
||||
const preservedBindings: ServiceBindingDraft[] = []
|
||||
let weights: Record<string, number> = {}
|
||||
let priorities: Record<string, number> = {}
|
||||
const splitSinglePool =
|
||||
ips.length === 1 &&
|
||||
drafts.filter((draft) => isFullPoolA(draft, ips)).length > 1
|
||||
drafts.filter((draft) => isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))
|
||||
.length > 1
|
||||
let assignedFirstSinglePoolCommon = false
|
||||
|
||||
function pushExtra(ip: string, fqdn: string) {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key || seenExtra.has(key) || seenCommon.has(key)) return
|
||||
seenExtra.add(key)
|
||||
const list = extraByIp.get(ip) ?? []
|
||||
list.push(fqdn)
|
||||
extraByIp.set(ip, list)
|
||||
}
|
||||
|
||||
function promoteToCommon(draft: ServiceBindingDraft, fqdn: string) {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (key && seenExtra.has(key)) {
|
||||
seenExtra.delete(key)
|
||||
for (const [ip, list] of extraByIp) {
|
||||
extraByIp.set(
|
||||
ip,
|
||||
list.filter((item) => fqdnKey(item) !== key),
|
||||
)
|
||||
}
|
||||
}
|
||||
const next = takeAsCommon(
|
||||
draft,
|
||||
fqdn,
|
||||
commonFqdns,
|
||||
seenCommon,
|
||||
weights,
|
||||
priorities,
|
||||
)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
}
|
||||
|
||||
for (const draft of drafts) {
|
||||
const fqdn = draft.fqdn.trim()
|
||||
if (splitSinglePool && isFullPoolA(draft, ips)) {
|
||||
if (splitSinglePool && (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet))) {
|
||||
if (!assignedFirstSinglePoolCommon) {
|
||||
assignedFirstSinglePoolCommon = true
|
||||
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
promoteToCommon(draft, fqdn)
|
||||
continue
|
||||
}
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
@@ -209,10 +253,9 @@ export function hydrateAddressBlock(
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (isFullPoolA(draft, ips)) {
|
||||
const next = takeAsCommon(draft, fqdn, commonFqdns, weights, priorities)
|
||||
weights = next.weights
|
||||
priorities = next.priorities
|
||||
// Full pool OR multi-IP subset of pool → common (heals orphaned toggle damage).
|
||||
if (isFullPoolA(draft, ips) || isCommonPoolA(draft, poolSet)) {
|
||||
promoteToCommon(draft, fqdn)
|
||||
continue
|
||||
}
|
||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||
@@ -307,10 +350,6 @@ export function patchAddressIpMeta(
|
||||
}
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key) return false
|
||||
@@ -318,13 +357,26 @@ export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return state.preservedBindings.some((item) => fqdnKey(item.fqdn) === key)
|
||||
}
|
||||
|
||||
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
||||
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
|
||||
if (!trimmed) return state
|
||||
const key = fqdnKey(trimmed)
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return state
|
||||
}
|
||||
// Promote out of invisible preserved (corrupted / CNAME-adjacent duplicates).
|
||||
const preservedBindings = state.preservedBindings.filter(
|
||||
(item) => fqdnKey(item.fqdn) !== key,
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
commonFqdns: [...state.commonFqdns, trimmed],
|
||||
preservedBindings,
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
||||
@@ -351,10 +403,16 @@ export function addExtraFqdn(
|
||||
fqdn: string,
|
||||
): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
||||
if (!trimmed) return state
|
||||
const key = fqdnKey(trimmed)
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return state
|
||||
if (state.nodes.some((node) => node.extraFqdns.some((item) => fqdnKey(item) === key))) {
|
||||
return state
|
||||
}
|
||||
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||
return {
|
||||
...state,
|
||||
preservedBindings: state.preservedBindings.filter((item) => fqdnKey(item.fqdn) !== key),
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.ip === ip ? { ...node, extraFqdns: [...node.extraFqdns, trimmed] } : node,
|
||||
),
|
||||
@@ -440,7 +498,13 @@ export function toAddressBindings(
|
||||
}
|
||||
}
|
||||
|
||||
drafts.push(...state.preservedBindings)
|
||||
const seen = new Set(drafts.map((item) => fqdnKey(item.fqdn)).filter(Boolean))
|
||||
for (const preserved of state.preservedBindings) {
|
||||
const key = fqdnKey(preserved.fqdn)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
drafts.push(preserved)
|
||||
}
|
||||
return drafts
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user