JOIN dns_record клал content CNAME в target_ips и блокировал разворот до origin IP / service IPs. Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
9.2 KiB
TypeScript
314 lines
9.2 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 [];
|
|
|
|
if (binding.target_ips.some(isIpLiteral)) {
|
|
return binding.target_ips.filter(isIpLiteral);
|
|
}
|
|
|
|
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 = repos.listBindingsByService(db, 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 : "Ошибка сети",
|
|
};
|
|
}
|
|
}
|