feat: интеграция с VPS Tracker
Build, Test, and Push CFDM Docker Image / test (push) Failing after 3m36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

Исходящий sync bindings после updateConfig, настройки в app_settings, страница Интеграции, приём событий vps_down для DNS failover.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 16:45:17 +07:00
co-authored by Cursor
parent 859918fae2
commit 96783386e0
32 changed files with 2116 additions and 243 deletions
@@ -23,6 +23,7 @@ import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import * as dnsService from "./dns-service.js";
import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
export interface ServiceDomainInput {
fqdn: string;
@@ -1100,6 +1101,7 @@ export async function updateConfig(
const keptBindingIds: number[] = [];
let service = repos.getService(db, id);
const pushDns = shouldPushDns(db, service);
let removedBindingIds: number[] = [];
if (req.domains) {
if (req.domains.length > 0) {
@@ -1179,6 +1181,7 @@ export async function updateConfig(
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
removedBindingIds = removed.map((binding) => binding.id);
for (const binding of removed) {
await cleanupBindingDns(
db,
@@ -1219,6 +1222,8 @@ export async function updateConfig(
await syncGroupDomainForService(db, cf, id);
}
void syncServiceToVpsTracker(db, id, removedBindingIds);
return buildView(db, id);
}
@@ -0,0 +1,52 @@
import type { Db } from "@cfdm/db";
import type { CloudflareClient } from "../lib/cf-client.js";
import type { VpsTrackerEvent } from "@cfdm/shared";
import * as repos from "@cfdm/db/repos";
import { reconcileDnsForTarget } from "./service-config-service.js";
function collectIps(event: VpsTrackerEvent): Set<string> {
const ips = new Set<string>();
for (const v of event.vps) {
if (v.ip?.trim()) ips.add(v.ip.trim());
}
return ips;
}
function bindingUsesIps(
db: Db,
serviceId: number,
bindingId: number,
ips: Set<string>,
): boolean {
const targetIps = repos.listBindingIps(db, bindingId);
const poolIps = repos.listServiceIps(db, serviceId);
const effective = targetIps.length > 0 ? targetIps : poolIps;
return effective.some((ip) => ips.has(ip));
}
export async function reconcileForVpsDown(
db: Db,
cf: CloudflareClient,
event: VpsTrackerEvent,
): Promise<number> {
const ips = collectIps(event);
if (ips.size === 0) return 0;
const seen = new Set<string>();
let reconciled = 0;
for (const service of repos.listServices(db)) {
if (!service.enabled) continue;
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
if (!bindingUsesIps(db, service.id, binding.id, ips)) continue;
const key = `binding:${binding.id}`;
if (seen.has(key)) continue;
seen.add(key);
await reconcileDnsForTarget(db, cf, "binding", binding.id);
reconciled += 1;
}
}
return reconciled;
}
+126
View File
@@ -0,0 +1,126 @@
import type { CfdmBindingSyncItem } from "@cfdm/shared";
import type { Db } from "@cfdm/db";
import * as repos from "@cfdm/db/repos";
import {
getAppSettingsSecrets,
touchVpsTrackerSync,
} from "@cfdm/db/settings-repo";
function fqdnToDisplay(hostname: string, zoneName: string): string {
if (hostname === "@" || !hostname.trim()) return zoneName;
return `${hostname}.${zoneName}`;
}
export function buildServiceSyncBindings(
db: Db,
serviceId: number,
deletedBindingIds: number[] = [],
): CfdmBindingSyncItem[] {
const service = repos.getService(db, serviceId);
const serviceIps = repos.listServiceIps(db, serviceId);
const bindings = repos.listBindingsByService(db, serviceId);
const items: CfdmBindingSyncItem[] = bindings.map((binding) => {
const targetIps = repos.listBindingIps(db, binding.id);
const ips =
targetIps.length > 0
? targetIps
: serviceIps.length > 0
? serviceIps
: [];
return {
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,
};
});
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 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 = buildServiceSyncBindings(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 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 : "Ошибка сети",
};
}
}