feat(api): enhance health check and DNS management
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 40s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 2m38s

- Added batch endpoint for health status queries to reduce the number of requests.
- Improved DNS record management with caching and invalidation mechanisms.
- Updated health check service to handle new configurations and improve performance.
- Refactored certificate service to utilize concurrency for checks, enhancing efficiency.
- Removed unused dependencies and optimized package configurations.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-03 16:16:41 +07:00
co-authored by Cursor
parent de3dbe8521
commit 1e9cc04a59
42 changed files with 6036 additions and 2836 deletions
+13 -3
View File
@@ -32,6 +32,7 @@ import {
import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import { repos, walCheckpointTruncate } from "@cfdm/db";
import * as certificateService from "./services/certificate-service.js";
import {
createHealthCheckTask,
@@ -43,7 +44,7 @@ import {
scheduleWeightedDnsJob,
} from "./services/weighted-dns-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
import { AsyncTask, CronJob, ToadScheduler } from "toad-scheduler";
export interface BuildAppOptions {
config?: AppConfig;
@@ -111,11 +112,20 @@ export async function buildApp(opts: BuildAppOptions = {}) {
}
if (!opts.memory) {
await app.register(import("@fastify/schedule"));
const scheduler = new ToadScheduler();
app.decorate("scheduler", scheduler);
app.addHook("onClose", async () => {
scheduler.stop();
});
const certTask = new AsyncTask(
"certificate-check",
async () => {
const pruned = repos.pruneLogs(app.db);
if (pruned > 0) {
app.log.info({ pruned }, "log retention pruned");
}
walCheckpointTruncate(app.sqlite);
const n = await certificateService.runAllChecks(app.db);
app.log.info({ checked: n }, "certificate check completed");
},
@@ -124,7 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
},
);
app.scheduler.addCronJob(
scheduler.addCronJob(
new CronJob(
{ cronExpression: config.certCheckCron },
certTask,
+9 -2
View File
@@ -38,12 +38,19 @@ export class CloudflareClient {
return this.zones.listZones();
}
invalidateZonesCache(): void {
this.zones.invalidateZonesCache();
}
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
listDnsRecords(
zoneId: string,
cache?: Map<string, CfDnsRecord[]>,
): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId, cache);
}
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
+12 -5
View File
@@ -4,9 +4,14 @@ import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
export function createDnsAdapter(token: string) {
return {
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
async listDnsRecords(
zoneId: string,
cache?: Map<string, CfDnsRecord[]>,
): Promise<CfDnsRecord[]> {
const cached = cache?.get(zoneId);
if (cached) return cached;
const all = await withRetry(async () => {
const records: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
@@ -28,11 +33,13 @@ export function createDnsAdapter(token: string) {
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
records.push(...batch);
page += 1;
}
return all;
return records;
});
cache?.set(zoneId, all);
return all;
},
async createDnsRecord(
+3
View File
@@ -1,6 +1,7 @@
import type { CfDnsRecord } from "@cfdm/shared";
import { AppError } from "../../errors.js";
import { parseRetryAfter } from "../cf-retry.js";
import { notifyZoneCacheInvalidated } from "./zone-cache-events.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
@@ -17,6 +18,7 @@ export function mapCloudflareFailure(
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
notifyZoneCacheInvalidated();
if (
operation.includes("workers") ||
operation.includes("kv_") ||
@@ -31,6 +33,7 @@ export function mapCloudflareFailure(
);
}
if (status === 429 || lower.includes("rate limit")) {
notifyZoneCacheInvalidated();
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
@@ -0,0 +1,21 @@
/**
* Tiny module-level hook registry: http.ts signals CF auth/rate-limit failures,
* zone-service subscribes to drop its TTL cache. Kept separate to avoid a
* circular import between http.ts and zone-service.ts.
*/
const subscribers = new Set<() => void>();
export function subscribeZoneCacheInvalidation(cb: () => void): () => void {
subscribers.add(cb);
return () => subscribers.delete(cb);
}
export function notifyZoneCacheInvalidated(): void {
for (const cb of subscribers) {
try {
cb();
} catch {
// subscriber cleanup must never break the CF response path
}
}
}
+56 -21
View File
@@ -1,34 +1,69 @@
import type { CfZone } from "@cfdm/shared";
import { withRetry } from "../cf-retry.js";
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
import { subscribeZoneCacheInvalidation } from "./zone-cache-events.js";
const ZONE_CACHE_TTL_MS = 5 * 60_000;
/** TTL cache for the zones list: /ready, collectKnownZones, weighted-reconcile
* poll it on a hot schedule; zones rarely change, so one CF API call per 5 min. */
export function createZoneAdapter(token: string) {
let cachedAt = 0;
let cachedZones: CfZone[] | null = null;
let inflight: Promise<CfZone[]> | null = null;
async function fetchZones(): Promise<CfZone[]> {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
}
function invalidate(): void {
cachedAt = 0;
cachedZones = null;
}
subscribeZoneCacheInvalidation(invalidate);
return {
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
const now = Date.now();
if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {
return cachedZones;
}
if (!inflight) {
inflight = withRetry(fetchZones)
.then((zones) => {
cachedAt = Date.now();
cachedZones = zones;
return zones;
})
.finally(() => {
inflight = null;
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
}
return inflight;
},
invalidateZonesCache: invalidate,
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
+26
View File
@@ -1,4 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { IpHealthStatus } from "@cfdm/shared";
import { healthStatusQuerySchema } from "@cfdm/shared";
import { getAppSettings, repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
@@ -19,6 +21,30 @@ export async function healthCheckRoutes(app: FastifyInstance) {
);
});
// Batch endpoint for lists: one request instead of N per-binding polls.
app.get("/health-status/batch", async (request) => {
const raw = (request.query as Record<string, unknown>) ?? {};
const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : "";
const refIds = z
.array(z.coerce.number().int().positive())
.max(200)
.parse(
idsParam
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
);
const grouped = repos.listIpHealthStatusByBindingIds(
request.server.db,
refIds,
);
const items: { ref_id: number; rows: IpHealthStatus[] }[] = [];
for (const id of refIds) {
items.push({ ref_id: id, rows: grouped.get(id) ?? [] });
}
return { items };
});
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const fallbacks = healthEngineFallbacksFromConfig(config);
+15 -19
View File
@@ -1,5 +1,6 @@
import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import pLimit from "p-limit";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
@@ -21,19 +22,10 @@ export interface CertificateTarget {
hostname: string;
}
function pruneStaleCertificates(db: Db): void {
const targets = resolveCertificateTargets(db);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
}
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
pruneStaleCertificates(db);
return repos.listCertificates(db, status);
}
@@ -231,15 +223,20 @@ export function resolveCertificateTargets(db: Db): CertificateTarget[] {
export async function runAllChecks(db: Db): Promise<number> {
const targets = resolveCertificateTargets(db);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
const limit = pLimit(5);
await Promise.all(
targets.map((target) =>
limit(() =>
checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
),
),
),
);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
@@ -268,6 +265,5 @@ export async function runServiceChecks(
}
export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
}
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import { AsyncTask, CronJob, type ToadScheduler } from "toad-scheduler";
import {
getAppSettings,
getAppSettingsSecrets,
@@ -18,6 +18,7 @@ import { cronStaleAfterMs } from "./health/mailbox.js";
declare module "fastify" {
interface FastifyInstance {
scheduler?: ToadScheduler;
reloadHealthCheckJob?: () => void;
}
}
@@ -168,6 +168,8 @@ async function httpProbe(
dispatcher,
});
const latency = Date.now() - started;
// Consume body so the socket is released before agent teardown.
await response.body?.cancel().catch(() => {});
if (target.expected_status != null) {
if (response.status !== target.expected_status) {
return {
@@ -192,6 +194,9 @@ async function httpProbe(
latencyMs: Date.now() - started,
error: err instanceof Error ? err.message : String(err),
};
} finally {
// Per-probe agents must not leak sockets/fds; probes run every ~2 min × N targets.
await dispatcher?.destroy().catch(() => {});
}
}
+198 -121
View File
@@ -1,6 +1,7 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
CfDnsRecord,
DnsRecord,
HealthCheckAggregate,
HealthCheckProvider,
@@ -9,6 +10,7 @@ import type {
IpHealthState,
LbMode,
Service,
ServiceBinding,
ServiceGroup,
ServiceGroupsResponse,
ServiceView,
@@ -341,101 +343,148 @@ async function collectKnownZones(
return zones;
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ipRows = repos.listServiceIpRows(db, serviceId);
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = repos.listBindingsByService(db, serviceId);
interface BindingLbContext {
ipMetaByBinding: Map<number, repos.BindingIpMeta[]>;
healthByBinding: Map<number, Map<string, { status: string; latency_ms: number | null }>>;
}
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
function bindingLbStateFromBatch(
binding: ServiceBinding,
ctx: BindingLbContext,
): { config: LbTargetConfig; rows: LbIpRow[] } {
const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];
const healthByIp = ctx.healthByBinding.get(binding.id);
const rows: LbIpRow[] = ipMetas.map((entry) => ({
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
health: (healthByIp?.get(entry.ip)?.status as IpHealthState) ?? "unknown",
}));
return {
config: {
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
},
rows,
};
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const [view] = await buildViews(db, [serviceId]);
return view!;
}
/** Batch view builder: 6 fixed queries for N services instead of O(services×bindings×ips). */
async function buildViews(db: Db, serviceIds: number[]): Promise<ServiceView[]> {
const services = serviceIds.map((id) => repos.getService(db, id));
const ipRowsByService = repos.listServiceIpRowsByServiceIds(db, serviceIds);
const bindingsByService = repos.listBindingsByServiceIds(db, serviceIds);
const allBindings = [...bindingsByService.values()].flat();
const bindingIds = allBindings.map((b) => b.id);
const recordsByBinding = repos.listRecordsByBindingIds(db, bindingIds);
const ipMetaByBinding = repos.listBindingIpsWithMetaByBindingIds(db, bindingIds);
const healthByBinding = repos.listBindingIpHealthByBindingIds(db, bindingIds);
const lbCtx: BindingLbContext = { ipMetaByBinding, healthByBinding };
const now = Date.now();
return services.map((service) => {
const ipRows = ipRowsByService.get(service.id) ?? [];
const ips = ipRows.map((row) => row.ip);
const ip_enabled = Object.fromEntries(
ipRows.map((row) => [row.ip, row.enabled]),
);
const bindings = bindingsByService.get(service.id) ?? [];
const domainViews = bindings.map((binding) => {
const records = recordsByBinding.get(binding.id) ?? [];
const statuses = records.map((r) => r.sync_status);
const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? [];
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of targetIpsWithMeta) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of targetIps) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const { config, rows } = bindingLbStateFromBatch(binding, lbCtx);
const bindingActiveIps = targetCname
? []
: resolveDesiredAIps(config, rows, targetIps, now, ips);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_ip_weights,
target_ip_priorities,
target_cname: targetCname,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health_check_enabled,
health_check_type: binding.health_check_type,
health_check_port: binding.health_check_port,
health_check_path: binding.health_check_path,
health_check_expected_status: binding.health_check_expected_status,
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
});
const activeIps = new Set<string>();
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id ?? null,
subdomain: service.subdomain ?? "",
enabled: Boolean(service.enabled),
computed_fqdn: null,
lb_weight: service.lb_weight,
lb_priority: service.lb_priority,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
ip_enabled,
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
}
const HEALTH_RANK: Record<string, number> = {
@@ -566,9 +615,8 @@ function attachServiceHealth(
}
export async function listViews(db: Db): Promise<ServiceView[]> {
const views = await Promise.all(
repos.listServices(db).map((s) => buildView(db, s.id)),
);
const ids = repos.listServices(db).map((s) => s.id);
const views = await buildViews(db, ids);
return attachServiceHealth(db, views);
}
@@ -580,25 +628,23 @@ export async function getView(db: Db, id: number): Promise<ServiceView> {
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groups = repos.listServiceGroups(db);
const groupViewsRaw = await Promise.all(
groups.map(async (group) => {
const services = repos.listServicesByGroup(db, group.id);
const serviceViews = await Promise.all(
services.map((s) => buildView(db, s.id)),
);
return { ...group, services: serviceViews };
}),
);
const servicesByGroup = repos.listServicesByGroupIds(db, groups.map((g) => g.id));
const ungroupedServices = repos.listUngroupedServices(db);
const ungroupedRaw = await Promise.all(
ungroupedServices.map((s) => buildView(db, s.id)),
);
const allServiceViews = [
...groupViewsRaw.flatMap((g) => g.services),
...ungroupedRaw,
const allIds = [
...[...servicesByGroup.values()].flat().map((s) => s.id),
...ungroupedServices.map((s) => s.id),
];
const allServiceViews = await buildViews(db, allIds);
const viewsById = new Map(allServiceViews.map((v) => [v.id, v]));
const groupViewsRaw = groups.map((group) => ({
...group,
services: (servicesByGroup.get(group.id) ?? [])
.map((s) => viewsById.get(s.id))
.filter((v): v is ServiceView => v !== undefined),
}));
const withHealth = attachServiceHealth(db, allServiceViews);
const healthById = new Map(withHealth.map((v) => [v.id, v]));
@@ -632,16 +678,21 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
};
});
const ungrouped = ungroupedRaw.map(
(s) =>
healthById.get(s.id) ?? {
...s,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
},
);
const ungrouped = ungroupedServices.map((s) => {
const view = viewsById.get(s.id);
return (
healthById.get(s.id) ??
(view
? {
...view,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
ip_enabled: {},
}
: view!)
);
});
return { groups: groupViews, ungrouped };
}
@@ -661,6 +712,7 @@ async function syncBindingDns(
hostname: string,
desiredIps: string[],
cnameTarget: string | null,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -674,6 +726,8 @@ async function syncBindingDns(
zoneName,
hostname,
"CNAME",
undefined,
listingCache,
);
if (existingCname) {
effectiveCname = existingCname.content;
@@ -690,6 +744,7 @@ async function syncBindingDns(
domainId,
hostname,
effectiveCname,
listingCache,
);
return;
}
@@ -704,6 +759,7 @@ async function syncBindingDns(
hostname,
desiredIps,
ttlForBinding(binding.lb_mode, configuredIps),
listingCache,
);
}
@@ -714,6 +770,7 @@ async function syncBindingCnameDns(
domainId: number,
hostname: string,
cnameTarget: string,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -755,6 +812,7 @@ async function syncBindingCnameDns(
hostname,
"CNAME",
normalized,
listingCache,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
@@ -792,6 +850,7 @@ async function syncBindingADns(
hostname: string,
desiredIps: string[],
ttl: number,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -849,6 +908,7 @@ async function syncBindingADns(
hostname,
"A",
ip,
listingCache,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
@@ -991,6 +1051,9 @@ function findLocalDnsRecord(
);
}
/** Per-reconcile DNS listing cache: one zone = one CF API listing per pass. */
export type ZoneDnsListingCache = Map<string, CfDnsRecord[]>;
async function findOrImportDnsRecord(
db: Db,
cf: CloudflareClient,
@@ -999,6 +1062,7 @@ async function findOrImportDnsRecord(
hostname: string,
recordType: "A" | "CNAME",
content?: string,
listingCache?: ZoneDnsListingCache,
): Promise<DnsRecord | null> {
const local = findLocalDnsRecord(
db,
@@ -1011,7 +1075,7 @@ async function findOrImportDnsRecord(
if (local) return local;
const domain = repos.getDomain(db, domainId);
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache);
for (const cfRec of remote) {
if (cfRec.type.toUpperCase() !== recordType) continue;
if (content != null) {
@@ -1052,6 +1116,7 @@ async function findOrImportDnsARecord(
zoneName: string,
hostname: string,
content: string,
listingCache?: ZoneDnsListingCache,
): Promise<DnsRecord | null> {
return findOrImportDnsRecord(
db,
@@ -1061,6 +1126,7 @@ async function findOrImportDnsARecord(
hostname,
"A",
content,
listingCache,
);
}
@@ -1084,6 +1150,7 @@ async function syncServiceBindingsToDns(
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
const listingCache: ZoneDnsListingCache = new Map();
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
@@ -1095,6 +1162,7 @@ async function syncServiceBindingsToDns(
binding.hostname,
[],
cnameTarget,
listingCache,
);
continue;
}
@@ -1117,6 +1185,7 @@ async function syncServiceBindingsToDns(
binding.hostname,
desiredIps,
null,
listingCache,
);
}
}
@@ -1148,6 +1217,7 @@ async function syncGroupDomainDnsRecords(
hostname: string,
desiredIps: string[],
ttl: number = AUTO_DNS_TTL,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -1185,6 +1255,7 @@ async function syncGroupDomainDnsRecords(
zoneName,
hostname,
ip,
listingCache,
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
@@ -1245,6 +1316,7 @@ async function syncGroupDomainDns(
db: Db,
cf: CloudflareClient,
groupId: number,
listingCache?: ZoneDnsListingCache,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
if (!group.enabled) {
@@ -1267,6 +1339,7 @@ async function syncGroupDomainDns(
hostname,
desiredIps,
ttlForBinding(group.lb_mode, fallbackIps),
listingCache,
);
}
@@ -1792,7 +1865,10 @@ export async function reconcileWeightedDns(
db: Db,
cf: CloudflareClient,
): Promise<number> {
// Cheap predicate first: skip the full binding walk when nothing is weighted.
if (!repos.hasWeightedBindings(db)) return 0;
let n = 0;
const listingCache: ZoneDnsListingCache = new Map();
for (const binding of repos.listAllBindings(db)) {
if (binding.lb_mode !== "weighted") continue;
if (binding.cname_target?.trim()) continue;
@@ -1818,6 +1894,7 @@ export async function reconcileWeightedDns(
latest.hostname,
desiredIps,
null,
listingCache,
);
n += 1;
});
@@ -1829,7 +1906,7 @@ export async function reconcileWeightedDns(
if (group.lb_mode !== "weighted") continue;
if (!group.enabled || !group.domain?.trim()) continue;
try {
await syncGroupDomainDns(db, cf, group.id);
await syncGroupDomainDns(db, cf, group.id, listingCache);
n += 1;
} catch {
continue;