Files
cloudflare-domain-manager/apps/api/src/services/vps-tracker-sync.ts
T
Denozordec d63c86065c
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m43s
CD / publish (push) Successful in 1m38s
feat(health-checks): implement health check IP toggling and configuration updates
- Added functionality to toggle individual IPs for services, allowing for dynamic management of IP health status.
- Enhanced the health check configuration in the UI, enabling users to set parameters directly from the settings page.
- Updated service views to include IP health tracking, improving visibility into the status of each IP associated with a service.
- Refactored relevant components to support the new IP toggling feature, ensuring a seamless user experience.

This commit significantly enhances the health management capabilities of services, providing users with more control over IP configurations and health monitoring.
2026-08-19 15:33:47 +07:00

315 lines
9.3 KiB
TypeScript

import { resolve4 } from "node:dns/promises";
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
import { isIpLiteral } from "@cfdm/shared";
import type { Db } from "@cfdm/db";
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
function fqdnToDisplay(hostname: string, zoneName: string): string {
if (hostname === "@" || !hostname.trim()) return zoneName;
return `${hostname}.${zoneName}`;
}
function normalizeCnameHost(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase().replace(/\.$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
return `${trimmed}.${zoneName.toLowerCase()}`;
}
type BindingIpIndex = {
byFqdn: Map<string, ServiceBindingView>;
};
function buildBindingIndex(bindings: ServiceBindingView[]): BindingIpIndex {
const byFqdn = new Map<string, ServiceBindingView>();
for (const b of bindings) {
const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase();
byFqdn.set(fqdn, b);
}
return { byFqdn };
}
/** Локальная цепочка CNAME → A (по bindings в CFDM), без внешнего DNS. */
function resolveIpsLocally(
index: BindingIpIndex,
startFqdn: string,
depth = 0,
seen = new Set<string>(),
): string[] {
const key = startFqdn.toLowerCase().replace(/\.$/, "");
if (!key || depth > 8 || seen.has(key)) return [];
seen.add(key);
const binding = index.byFqdn.get(key);
if (!binding) return [];
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
if (ips.length > 0) {
return ips;
}
const cname = binding.cname_target?.trim();
if (!cname) return [];
const next = normalizeCnameHost(cname, binding.zone_name);
return resolveIpsLocally(index, next, depth + 1, seen);
}
async function resolveIpsViaDns(hostname: string): Promise<string[]> {
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
if (!host) return [];
try {
// resolve4 следует по CNAME до A-записей
return await resolve4(host);
} catch {
return [];
}
}
/**
* IP для матчинга в VPS Tracker:
* 1) A/AAAA binding (только литералы IP — не CNAME hostname из dns_record.content)
* 2) локальная CNAME-цепочка по другим bindings
* 3) origin A/AAAA из dns_records CFDM
* 4) IP сервиса
* 5) публичный DNS — последний и обычно вреден (CF anycast при proxied)
*/
export async function resolveBindingIpsForSync(
binding: ServiceBindingView,
serviceIps: string[],
index: BindingIpIndex,
db?: Db,
): Promise<string[]> {
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
if (directIps.length > 0) {
return [...directIps];
}
const cname = binding.cname_target?.trim();
if (cname) {
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral);
if (local.length > 0) return local;
if (db) {
const fromTable = repos.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral);
if (fromTable.length > 0) return fromTable;
}
}
const fromService = serviceIps.filter(isIpLiteral);
if (fromService.length > 0) return [...fromService];
if (cname) {
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral);
if (viaDns.length > 0) return viaDns;
}
return [];
}
function cnameTargetForSync(binding: ServiceBindingView): string | undefined {
const raw = binding.cname_target?.trim();
if (!raw) return undefined;
const normalized = normalizeCnameHost(raw, binding.zone_name);
return normalized || undefined;
}
export async function buildServiceSyncBindingsAsync(
db: Db,
serviceId: number,
deletedBindingIds: number[] = [],
): Promise<CfdmBindingSyncItem[]> {
const service = repos.getService(db, serviceId);
const serviceIps = repos.listServiceIps(db, serviceId);
const allBindings = repos.listAllBindings(db);
const index = buildBindingIndex(allBindings);
const bindings = allBindings.filter((row) => row.service_id === serviceId);
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
items.push({
bindingId: binding.id,
serviceId: service.id,
serviceName: service.name,
serviceSlug: service.slug,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
zoneName: binding.zone_name,
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
});
}
for (const bindingId of deletedBindingIds) {
items.push({
bindingId,
serviceId: service.id,
serviceName: service.name,
serviceSlug: service.slug,
fqdn: "",
zoneName: "",
hostname: "",
ips: [],
deleted: true,
});
}
return items;
}
export async function buildAllSyncBindings(
db: Db,
): Promise<CfdmBindingSyncItem[]> {
const bindings = repos.listAllBindings(db);
const index = buildBindingIndex(bindings);
const serviceIpCache = new Map<number, string[]>();
const items: CfdmBindingSyncItem[] = [];
for (const binding of bindings) {
let serviceIps = serviceIpCache.get(binding.service_id);
if (!serviceIps) {
serviceIps = repos.listServiceIps(db, binding.service_id);
serviceIpCache.set(binding.service_id, serviceIps);
}
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
items.push({
bindingId: binding.id,
serviceId: binding.service_id,
serviceName: binding.service_name,
serviceSlug: binding.service_slug,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
zoneName: binding.zone_name,
hostname: binding.hostname,
ips,
cnameTarget: cnameTargetForSync(binding),
});
}
return items;
}
export async function syncServiceToVpsTracker(
db: Db,
serviceId: number,
deletedBindingIds: number[] = [],
): Promise<void> {
const config = getAppSettingsSecrets(db);
if (!config.vpsTrackerSyncEnabled) return;
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
const token = config.vpsTrackerIntegrationToken;
if (!baseUrl || !token) return;
const bindings = await buildServiceSyncBindingsAsync(
db,
serviceId,
deletedBindingIds,
);
if (bindings.length === 0) return;
try {
const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ bindings }),
});
if (res.ok) {
touchVpsTrackerSync(db);
} else {
console.warn(
`VPS Tracker sync failed (${res.status}): ${await res.text()}`,
);
}
} catch (err) {
console.warn(
"VPS Tracker sync error:",
err instanceof Error ? err.message : err,
);
}
}
export async function syncAllToVpsTracker(db: Db): Promise<{
ok: boolean;
count: number;
error?: string;
}> {
const config = getAppSettingsSecrets(db);
if (!config.vpsTrackerSyncEnabled) {
return { ok: false, count: 0, error: "Синхронизация выключена в CFDM" };
}
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
const token = config.vpsTrackerIntegrationToken;
if (!baseUrl) {
return { ok: false, count: 0, error: "Укажите URL VPS Tracker" };
}
if (!token) {
return { ok: false, count: 0, error: "Укажите integration token" };
}
const bindings = await buildAllSyncBindings(db);
try {
const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ bindings, fullSync: true }),
});
if (!res.ok) {
const detail = await res.text();
return {
ok: false,
count: 0,
error: `VPS Tracker ${baseUrl} ответил HTTP ${res.status}: ${detail}`,
};
}
touchVpsTrackerSync(db);
return { ok: true, count: bindings.length };
} catch (err) {
const raw = err instanceof Error ? err.message : "Ошибка сети";
return {
ok: false,
count: 0,
error: `Не удалось отправить данные в VPS Tracker (${baseUrl}): ${raw}`,
};
}
}
export async function pingVpsTracker(db: Db): Promise<{
ok: boolean;
error?: string;
}> {
const config = getAppSettingsSecrets(db);
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
const token = config.vpsTrackerIntegrationToken;
if (!baseUrl) return { ok: false, error: "Укажите URL VPS Tracker" };
if (!token) return { ok: false, error: "Укажите integration token" };
try {
const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
return {
ok: false,
error: `HTTP ${res.status}: ${await res.text()}`,
};
}
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : "Ошибка сети",
};
}
}