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
+4
View File
@@ -24,6 +24,8 @@ import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js";
import { healthCheckRoutes } from "./routes/health-check.js";
import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
@@ -58,6 +60,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(healthRoutes);
await app.register(authRoutes, { prefix: "/api/v1" });
await app.register(integrationsVpsTrackerRoutes, { prefix: "/api/v1" });
await app.register(
async (protectedApi) => {
@@ -72,6 +75,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes);
await protectedApi.register(healthCheckRoutes);
await protectedApi.register(settingsRoutes);
},
{ prefix: "/api/v1" },
);
+5
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { healthCheck } from "../plugins/db.js";
import { getAppSwitcher } from "@cfdm/db/settings-repo";
import * as authService from "../services/auth.js";
export async function healthRoutes(app: FastifyInstance) {
@@ -31,6 +32,10 @@ export async function healthRoutes(app: FastifyInstance) {
}
export async function authRoutes(app: FastifyInstance) {
app.get("/settings/app-switcher", async (request) => {
return getAppSwitcher(request.server.db);
});
const loginSchema = z.object({
username: z.string(),
password: z.string(),
@@ -0,0 +1,44 @@
import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { vpsTrackerEventSchema } from "@cfdm/shared";
import { getAppSettingsSecrets } from "@cfdm/db/settings-repo";
import * as vpsTrackerEvents from "../services/vps-tracker-events.js";
function verifyBearer(authHeader: string | undefined, expected: string): boolean {
if (!authHeader?.startsWith("Bearer ")) return false;
const token = authHeader.slice(7);
if (!token || !expected) return false;
const a = Buffer.from(token);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export async function integrationsVpsTrackerRoutes(app: FastifyInstance) {
app.post("/integrations/vps-tracker/events", async (req, reply) => {
const secrets = getAppSettingsSecrets(app.db);
const token = secrets.vpsTrackerIntegrationToken;
if (!token) {
return reply.status(503).send({ error: "Integration not configured" });
}
if (!verifyBearer(req.headers.authorization, token)) {
return reply.status(401).send({ error: "Unauthorized" });
}
const parsed = vpsTrackerEventSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: parsed.error.flatten() });
}
if (parsed.data.event !== "vps_down") {
return { ok: true, reconciled: 0 };
}
const reconciled = await vpsTrackerEvents.reconcileForVpsDown(
app.db,
app.cf,
parsed.data,
);
return { ok: true, reconciled };
});
}
+22
View File
@@ -0,0 +1,22 @@
import type { FastifyInstance } from "fastify";
import { appSettingsPatchSchema } from "@cfdm/shared";
import {
getAppSettings,
updateAppSettings,
} from "@cfdm/db/settings-repo";
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
});
app.patch("/settings", async (request) => {
const body = appSettingsPatchSchema.parse(request.body);
return updateAppSettings(request.server.db, body);
});
app.post("/settings/vps-tracker/test", async (request) => {
return pingVpsTracker(request.server.db);
});
}
@@ -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 : "Ошибка сети",
};
}
}