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
- 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.
315 lines
9.3 KiB
TypeScript
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 : "Ошибка сети",
|
|
};
|
|
}
|
|
}
|