Enhance CDN Manager: Add Cloudflare API token support, update documentation, and introduce new routes for managing nodes, aliases, and topology. Improve UI components and status badges for better user experience.
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 27s

This commit is contained in:
Denozordec
2026-09-04 12:49:51 +07:00
parent d480325357
commit 8700dc2957
47 changed files with 10550 additions and 69 deletions
+1037 -8
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -10,10 +10,12 @@ import { loadConfig } from "./config.js";
import authPlugin from "./plugins/auth.js";
import { requireAuth } from "./plugins/auth.js";
import corsPlugin from "./plugins/cors.js";
import cfClientPlugin from "./plugins/cf-client.js";
import dbPlugin from "./plugins/db.js";
import errorHandlerPlugin from "./plugins/error-handler.js";
import { authRoutes, healthRoutes } from "./routes/health.js";
import { settingsRoutes } from "./routes/settings.js";
import { fleetRoutes } from "./routes/fleet.js";
export interface BuildAppOptions {
config?: AppConfig;
@@ -31,7 +33,9 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.setSerializerCompiler(serializerCompiler);
await app.register(import("@fastify/sensible"));
await app.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
await app.register(import("@fastify/helmet"), {
contentSecurityPolicy: false,
});
await app.register(import("@fastify/rate-limit"), {
max: 300,
timeWindow: "1 minute",
@@ -39,6 +43,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(corsPlugin);
await app.register(errorHandlerPlugin);
await app.register(dbPlugin, { config, memory: opts.memory });
await app.register(cfClientPlugin, { config });
await app.register(authPlugin, { config });
await app.register(healthRoutes);
@@ -48,6 +53,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
async (protectedApi) => {
protectedApi.addHook("onRequest", requireAuth);
await protectedApi.register(settingsRoutes);
await protectedApi.register(fleetRoutes);
},
{ prefix: "/api/v1" },
);
+3
View File
@@ -15,6 +15,8 @@ export interface AppConfig {
authPortalUrl: string;
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
authAuditIngestSecret: string | null;
/** Cloudflare API token (Zone DNS Edit + Zone Read) */
cloudflareApiToken: string;
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -52,5 +54,6 @@ export function loadConfig(): AppConfig {
authAuditIngestSecret:
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
(!isProd ? "dev-audit-ingest-secret" : null),
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
};
}
+63
View File
@@ -0,0 +1,63 @@
import type {
CfDnsRecord,
CfZone,
CreateDnsRecordPayload,
PatchDnsRecordPayload,
} from "@cdnmanager/shared";
import { createDnsAdapter } from "./cloudflare/dns-service.js";
import { createZoneAdapter } from "./cloudflare/zone-service.js";
export class CloudflareClient {
private readonly zones;
private readonly dns;
readonly token: string;
constructor(token: string) {
this.token = token.trim();
this.zones = createZoneAdapter(this.token);
this.dns = createDnsAdapter(this.token);
}
get isConfigured(): boolean {
return this.token.length > 0;
}
listZones(): Promise<CfZone[]> {
return this.zones.listZones();
}
getZone(zoneId: string): Promise<CfZone> {
return this.zones.getZone(zoneId);
}
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return this.dns.listDnsRecords(zoneId);
}
createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.createDnsRecord(zoneId, payload);
}
updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.updateDnsRecord(zoneId, recordId, payload);
}
patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
return this.dns.patchDnsRecord(zoneId, recordId, payload);
}
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
return this.dns.deleteDnsRecord(zoneId, recordId);
}
}
+114
View File
@@ -0,0 +1,114 @@
import type {
CfDnsRecord,
CreateDnsRecordPayload,
PatchDnsRecordPayload,
} from "@cdnmanager/shared";
import {
CF_API_BASE,
handleCfResponse,
mapCloudflareFailure,
withRetry,
} from "./http.js";
export function createDnsAdapter(token: string) {
return {
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(
"list_dns_records",
response.status,
String(response.status),
);
}
const batch = await handleCfResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
},
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "create_dns_record");
},
async updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "update_dns_record");
},
async patchDnsRecord(
zoneId: string,
recordId: string,
payload: PatchDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return handleCfResponse(response, "patch_dns_record");
},
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
await handleCfResponse(response, "delete_dns_record");
},
};
}
+83
View File
@@ -0,0 +1,83 @@
import { AppError } from "../../errors.js";
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
export interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export function parseRetryAfter(headers: Headers): number | null {
const value = headers.get("retry-after");
if (!value) return null;
const seconds = Number(value);
return Number.isFinite(seconds) ? seconds * 1000 : null;
}
export async function withRetry<T>(
operation: () => Promise<T>,
maxAttempts = 3,
): Promise<T> {
let delay = 500;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
await new Promise((r) => setTimeout(r, delay));
delay *= 2;
}
}
}
throw lastError;
}
export function mapCloudflareFailure(
operation: string,
status: number,
message: string,
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
return AppError.cloudflareAuthFailed(
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
);
}
if (status === 429 || lower.includes("rate limit")) {
return AppError.rateLimited();
}
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
return AppError.zoneNotFound();
}
if (operation.includes("dns") || operation.includes("dns_record")) {
return AppError.dnsUpdateFailed(
`Не удалось обновить DNS в Cloudflare: ${message}`,
);
}
return AppError.cloudflare(`${operation}: ${message}`);
}
export async function handleCfResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.rateLimited(
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
throw mapCloudflareFailure(operation, response.status, msg);
}
if (body.result === undefined) {
throw mapCloudflareFailure(operation, response.status, "empty result");
}
return body.result;
}
@@ -0,0 +1,48 @@
import type { CfZone } from "@cdnmanager/shared";
import {
CF_API_BASE,
handleCfResponse,
mapCloudflareFailure,
withRetry,
} from "./http.js";
export function createZoneAdapter(token: string) {
return {
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${CF_API_BASE}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure(
"list_zones",
response.status,
String(response.status),
);
}
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
},
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
return handleCfResponse(response, "get_zone");
},
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyPluginAsync } from "fastify";
import fp from "fastify-plugin";
import type { AppConfig } from "../config.js";
import { CloudflareClient } from "../lib/cf-client.js";
declare module "fastify" {
interface FastifyInstance {
cf: CloudflareClient;
}
}
const cfClientPlugin: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const cf = new CloudflareClient(opts.config.cloudflareApiToken);
app.decorate("cf", cf);
};
export default fp(cfClientPlugin, { name: "cf-client" });
+336
View File
@@ -0,0 +1,336 @@
import type { FastifyPluginAsync } from "fastify";
import {
aliasCreateSchema,
aliasPatchSchema,
aliasRetargetSchema,
nodeCreateSchema,
nodePatchSchema,
orphanIgnoreSchema,
syncApplySchema,
zoneCreateSchema,
zonePatchSchema,
} from "@cdnmanager/shared";
import {
createAlias,
createNode,
createZone,
dashboardCounts,
deleteAlias,
deleteNode,
deleteZone,
getAlias,
getNode,
getZone,
ignoreOrphan,
listAliases,
listIgnoredOrphans,
listLocations,
listNodes,
listSyncJobs,
listZones,
unignoreOrphan,
updateAlias,
updateNode,
updateZone,
} from "@cdnmanager/db";
import { AppError } from "../errors.js";
import {
buildHostname,
isValidIpv4,
isValidIpv6,
normalizeFqdn,
} from "../services/naming.js";
import {
applyZoneDiff,
exportBindZone,
retargetAlias,
syncZonePull,
} from "../services/sync.js";
export const fleetRoutes: FastifyPluginAsync = async (app) => {
app.get("/locations", async () => listLocations(app.db));
app.get("/zones", async () => listZones(app.db));
app.post("/zones", async (req) => {
const body = zoneCreateSchema.parse(req.body);
return createZone(app.db, body);
});
app.get<{ Params: { zoneId: string } }>("/zones/:zoneId", async (req) =>
getZone(app.db, req.params.zoneId),
);
app.patch<{ Params: { zoneId: string } }>("/zones/:zoneId", async (req) => {
const body = zonePatchSchema.parse(req.body);
return updateZone(app.db, req.params.zoneId, body);
});
app.delete<{ Params: { zoneId: string } }>("/zones/:zoneId", async (req) => {
deleteZone(app.db, req.params.zoneId);
return { ok: true };
});
app.get("/cloudflare/zones", async () => {
if (!app.cf.isConfigured) {
throw AppError.cloudflareAuthFailed("CLOUDFLARE_API_TOKEN не задан");
}
return app.cf.listZones();
});
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/sync",
async (req) => syncZonePull(app.db, app.cf, req.params.zoneId),
);
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/apply",
async (req) => {
const body = syncApplySchema.parse(req.body ?? {});
return applyZoneDiff(app.db, app.cf, req.params.zoneId, body.opIds);
},
);
app.get<{ Params: { zoneId: string } }>(
"/zones/:zoneId/sync-jobs",
async (req) => listSyncJobs(app.db, req.params.zoneId),
);
app.get<{ Params: { zoneId: string } }>(
"/zones/:zoneId/export/bind",
async (req) => ({
zoneName: getZone(app.db, req.params.zoneId).name,
content: exportBindZone(app.db, req.params.zoneId),
}),
);
app.get<{ Params: { zoneId: string } }>(
"/zones/:zoneId/orphans/ignored",
async (req) => listIgnoredOrphans(app.db, req.params.zoneId),
);
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/orphans/ignore",
async (req) => {
const body = orphanIgnoreSchema.parse(req.body);
ignoreOrphan(
app.db,
req.params.zoneId,
normalizeFqdn(body.recordName),
body.recordType.toUpperCase(),
);
return { ok: true };
},
);
app.post<{ Params: { zoneId: string } }>(
"/zones/:zoneId/orphans/unignore",
async (req) => {
const body = orphanIgnoreSchema.parse(req.body);
unignoreOrphan(
app.db,
req.params.zoneId,
normalizeFqdn(body.recordName),
body.recordType.toUpperCase(),
);
return { ok: true };
},
);
app.get("/nodes", async (req) => {
const q = req.query as Record<string, string | undefined>;
return listNodes(app.db, {
zoneId: q.zoneId,
locationId: q.locationId,
role: q.role,
syncStatus: q.syncStatus,
q: q.q,
});
});
app.post("/nodes", async (req) => {
const body = nodeCreateSchema.parse(req.body);
if (!isValidIpv4(body.ipv4)) throw AppError.invalidIp("Некорректный IPv4");
if (body.ipv6 && !isValidIpv6(body.ipv6)) {
throw AppError.invalidIp("Некорректный IPv6");
}
const zone = getZone(app.db, body.zoneId);
const loc = listLocations(app.db).find((l) => l.id === body.locationId);
if (!loc) throw AppError.notFound("location not found");
const hostname =
body.hostname?.trim() ||
buildHostname({
template: zone.namingTemplate,
locationCode: loc.code,
role: body.role,
indexNum: body.indexNum,
zoneName: zone.name,
providerTag: body.providerTag,
});
return createNode(app.db, {
zoneId: body.zoneId,
locationId: body.locationId,
hostname: normalizeFqdn(hostname),
role: body.role,
indexNum: body.indexNum,
providerTag: body.providerTag,
notes: body.notes,
ipv4: body.ipv4,
ipv6: body.ipv6,
});
});
app.get<{ Params: { nodeId: string } }>("/nodes/:nodeId", async (req) =>
getNode(app.db, req.params.nodeId),
);
app.patch<{ Params: { nodeId: string } }>("/nodes/:nodeId", async (req) => {
const body = nodePatchSchema.parse(req.body);
if (body.ipv4 && !isValidIpv4(body.ipv4)) {
throw AppError.invalidIp("Некорректный IPv4");
}
if (body.ipv6 && !isValidIpv6(body.ipv6)) {
throw AppError.invalidIp("Некорректный IPv6");
}
return updateNode(app.db, req.params.nodeId, {
...body,
hostname: body.hostname ? normalizeFqdn(body.hostname) : undefined,
});
});
app.delete<{ Params: { nodeId: string } }>("/nodes/:nodeId", async (req) => {
deleteNode(app.db, req.params.nodeId);
return { ok: true };
});
app.get("/aliases", async (req) => {
const q = req.query as Record<string, string | undefined>;
return listAliases(app.db, {
zoneId: q.zoneId,
purpose: q.purpose,
syncStatus: q.syncStatus,
q: q.q,
});
});
app.post("/aliases", async (req) => {
const body = aliasCreateSchema.parse(req.body);
return createAlias(app.db, {
zoneId: body.zoneId,
name: normalizeFqdn(body.name),
purpose: body.purpose,
mode: body.mode,
targetNodeId: body.targetNodeId,
});
});
app.get<{ Params: { aliasId: string } }>("/aliases/:aliasId", async (req) =>
getAlias(app.db, req.params.aliasId),
);
app.patch<{ Params: { aliasId: string } }>(
"/aliases/:aliasId",
async (req) => {
const body = aliasPatchSchema.parse(req.body);
return updateAlias(app.db, req.params.aliasId, {
...body,
name: body.name ? normalizeFqdn(body.name) : undefined,
});
},
);
app.post<{ Params: { aliasId: string } }>(
"/aliases/:aliasId/retarget",
async (req) => {
const body = aliasRetargetSchema.parse(req.body);
return retargetAlias(
app.db,
app.cf,
req.params.aliasId,
body.targetNodeId,
);
},
);
app.delete<{ Params: { aliasId: string } }>(
"/aliases/:aliasId",
async (req) => {
deleteAlias(app.db, req.params.aliasId);
return { ok: true };
},
);
app.get("/dashboard/stats", async () => {
const base = dashboardCounts(app.db);
// proxy/orphan from last sync jobs — approximate via sync_status
const nodes = listNodes(app.db);
const aliases = listAliases(app.db);
const proxyViolations = [...nodes, ...aliases].filter(
(x) => x.syncStatus === "drift" && (x.lastError ?? "").includes("Proxy"),
).length;
const orphans = listZones(app.db).length
? listSyncJobs(app.db, listZones(app.db)[0]!.id)
.flatMap((j) => (j.diff as Array<{ kind: string }>) ?? [])
.filter((o) => o.kind === "orphan").length
: 0;
return {
nodes: base.nodes,
aliases: base.aliases,
syncOk: base.syncOk,
drift: base.drift,
proxyViolations,
orphans,
lastSyncAt: base.lastSyncAt,
nodesWithoutIp: base.nodesWithoutIp,
brokenAliases: base.brokenAliases,
};
});
app.get("/topology", async (req) => {
const q = req.query as Record<string, string | undefined>;
const zoneId = q.zoneId;
const locs = listLocations(app.db);
const nodeList = listNodes(app.db, zoneId ? { zoneId } : {});
const aliasList = listAliases(app.db, zoneId ? { zoneId } : {});
return {
locations: locs,
nodes: nodeList.map((n) => ({
id: n.id,
hostname: n.hostname,
role: n.role,
locationCode: n.locationCode ?? "",
ipv4: n.addresses.find((a) => a.family === "v4")?.ip ?? null,
syncStatus: n.syncStatus,
})),
edges: aliasList.map((a) => ({
id: a.id,
aliasName: a.name,
purpose: a.purpose,
fromNodeId: a.targetNodeId,
toHostname: a.targetHostname ?? "",
})),
};
});
app.get("/naming/preview", async (req) => {
const q = req.query as Record<string, string | undefined>;
if (!q.zoneId || !q.locationId || !q.role) {
throw AppError.validation("zoneId, locationId, role обязательны");
}
const zone = getZone(app.db, q.zoneId);
const loc = listLocations(app.db).find((l) => l.id === q.locationId);
if (!loc) throw AppError.notFound("location not found");
const indexNum = Number(q.indexNum ?? "1") || 1;
return {
hostname: buildHostname({
template: q.template || zone.namingTemplate,
locationCode: loc.code,
role: q.role,
indexNum,
zoneName: zone.name,
providerTag: q.providerTag,
}),
};
});
};
+10 -2
View File
@@ -5,7 +5,11 @@ import { AppError } from "../errors.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
return getAppSettings(request.server.db);
const settings = getAppSettings(request.server.db);
return {
...settings,
cloudflareConfigured: request.server.cf.isConfigured,
};
});
app.patch("/settings", async (request) => {
@@ -15,6 +19,10 @@ export async function settingsRoutes(app: FastifyInstance) {
parsed.error.issues[0]?.message ?? "некорректные настройки",
);
}
return updateAppSettings(request.server.db, parsed.data);
const settings = updateAppSettings(request.server.db, parsed.data);
return {
...settings,
cloudflareConfigured: request.server.cf.isConfigured,
};
});
}
+43
View File
@@ -0,0 +1,43 @@
/** Build canonical hostname: {loc}-{role}{nn}.{zone} */
export function buildHostname(opts: {
template?: string;
locationCode: string;
role: string;
indexNum: number;
zoneName: string;
providerTag?: string | null;
}): string {
const nn = String(opts.indexNum).padStart(2, "0");
const template = opts.template ?? "{loc}-{role}{nn}.{zone}";
let host = template
.replaceAll("{loc}", opts.locationCode.toLowerCase())
.replaceAll("{role}", opts.role.toLowerCase())
.replaceAll("{nn}", nn)
.replaceAll("{zone}", opts.zoneName.toLowerCase());
if (opts.providerTag) {
// optional: msk-ih-gw01 if template has {provider}
host = host.replaceAll("{provider}", opts.providerTag.toLowerCase());
} else {
host = host.replaceAll("-{provider}", "").replaceAll("{provider}", "");
}
return host.replace(/\.$/, "");
}
export function normalizeFqdn(name: string): string {
return name.trim().toLowerCase().replace(/\.$/, "");
}
export function isValidIpv4(ip: string): boolean {
const parts = ip.split(".");
if (parts.length !== 4) return false;
return parts.every((p) => {
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === p;
});
}
export function isValidIpv6(ip: string): boolean {
// lightweight check — enough for UI validation
return /^[0-9a-f:]+$/i.test(ip) && ip.includes(":");
}
+546
View File
@@ -0,0 +1,546 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@cdnmanager/db";
import type { SyncDiffOp } from "@cdnmanager/shared";
import {
getAlias,
getNode,
getZone,
listAliases,
listIgnoredOrphans,
listNodes,
updateAlias,
updateNode,
updateZone,
createSyncJob,
updateSyncJob,
getSyncJob,
addSyncEvent,
} from "@cdnmanager/db";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { normalizeFqdn } from "./naming.js";
function opId() {
return `op-${randomUUID().slice(0, 8)}`;
}
function findObserved(
records: Array<{
id?: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
}>,
type: string,
name: string,
) {
const n = normalizeFqdn(name);
return records.find(
(r) => r.type === type && normalizeFqdn(r.name) === n,
);
}
export async function buildZoneDiff(
db: Db,
cf: CloudflareClient,
zoneId: string,
): Promise<SyncDiffOp[]> {
const zone = getZone(db, zoneId);
if (!zone.cfZoneId) {
throw AppError.validation("У зоны не задан cfZoneId Cloudflare");
}
if (!cf.isConfigured) {
throw AppError.cloudflareAuthFailed(
"CLOUDFLARE_API_TOKEN не задан. Добавьте токен в окружение API.",
);
}
const observed = await cf.listDnsRecords(zone.cfZoneId);
const nodeList = listNodes(db, { zoneId });
const aliasList = listAliases(db, { zoneId });
const ignored = new Set(
listIgnoredOrphans(db, zoneId).map(
(o) => `${o.recordType}:${normalizeFqdn(o.recordName)}`,
),
);
const ops: SyncDiffOp[] = [];
const managedKeys = new Set<string>();
for (const node of nodeList) {
const ttl = zone.defaultTtl;
const v4 = node.addresses.find((a) => a.family === "v4");
const v6 = node.addresses.find((a) => a.family === "v6");
if (v4) {
const key = `A:${normalizeFqdn(node.hostname)}`;
managedKeys.add(key);
const obs = findObserved(observed, "A", node.hostname);
const desired = {
type: "A",
name: node.hostname,
content: v4.ip,
ttl,
proxied: false,
};
if (!obs) {
ops.push({
id: opId(),
kind: "create",
entityType: "node_a",
entityId: node.id,
recordName: node.hostname,
recordType: "A",
desired,
observed: null,
detail: "A-запись отсутствует в Cloudflare",
});
} else if (
obs.content !== v4.ip ||
obs.proxied === true ||
(obs.ttl !== 1 && obs.ttl !== ttl)
) {
ops.push({
id: opId(),
kind: obs.proxied === true ? "proxy_violation" : "update",
entityType: "node_a",
entityId: node.id,
recordName: node.hostname,
recordType: "A",
desired,
observed: {
id: obs.id,
type: obs.type,
name: obs.name,
content: obs.content,
ttl: obs.ttl,
proxied: obs.proxied ?? false,
},
detail:
obs.proxied === true
? "Proxy включён — для туннелей нужен DNS-only"
: "Содержимое/TTL отличается от desired",
});
} else {
ops.push({
id: opId(),
kind: "noop",
entityType: "node_a",
entityId: node.id,
recordName: node.hostname,
recordType: "A",
desired,
observed: { id: obs.id, content: obs.content },
});
}
}
if (v6) {
const key = `AAAA:${normalizeFqdn(node.hostname)}`;
managedKeys.add(key);
const obs = findObserved(observed, "AAAA", node.hostname);
const desired = {
type: "AAAA",
name: node.hostname,
content: v6.ip,
ttl,
proxied: false,
};
if (!obs) {
ops.push({
id: opId(),
kind: "create",
entityType: "node_aaaa",
entityId: node.id,
recordName: node.hostname,
recordType: "AAAA",
desired,
observed: null,
});
} else if (obs.content !== v6.ip || obs.proxied === true) {
ops.push({
id: opId(),
kind: obs.proxied === true ? "proxy_violation" : "update",
entityType: "node_aaaa",
entityId: node.id,
recordName: node.hostname,
recordType: "AAAA",
desired,
observed: {
id: obs.id,
content: obs.content,
proxied: obs.proxied ?? false,
},
});
}
}
}
for (const alias of aliasList) {
const target = getNode(db, alias.targetNodeId);
const key = `CNAME:${normalizeFqdn(alias.name)}`;
managedKeys.add(key);
const obs = findObserved(observed, "CNAME", alias.name);
const desired = {
type: "CNAME",
name: alias.name,
content: target.hostname,
ttl: zone.defaultTtl,
proxied: false,
};
if (!obs) {
ops.push({
id: opId(),
kind: "create",
entityType: "alias",
entityId: alias.id,
recordName: alias.name,
recordType: "CNAME",
desired,
observed: null,
});
} else if (
normalizeFqdn(obs.content) !== normalizeFqdn(target.hostname) ||
obs.proxied === true
) {
ops.push({
id: opId(),
kind: obs.proxied === true ? "proxy_violation" : "update",
entityType: "alias",
entityId: alias.id,
recordName: alias.name,
recordType: "CNAME",
desired,
observed: {
id: obs.id,
content: obs.content,
proxied: obs.proxied ?? false,
},
});
} else {
ops.push({
id: opId(),
kind: "noop",
entityType: "alias",
entityId: alias.id,
recordName: alias.name,
recordType: "CNAME",
desired,
observed: { id: obs.id, content: obs.content },
});
}
}
for (const rec of observed) {
if (!["A", "AAAA", "CNAME"].includes(rec.type)) continue;
const key = `${rec.type}:${normalizeFqdn(rec.name)}`;
if (managedKeys.has(key)) continue;
if (ignored.has(key)) continue;
ops.push({
id: opId(),
kind: "orphan",
entityType: "orphan",
entityId: null,
recordName: rec.name,
recordType: rec.type,
desired: null,
observed: {
id: rec.id,
type: rec.type,
name: rec.name,
content: rec.content,
proxied: rec.proxied ?? false,
},
detail: "Запись в Cloudflare вне inventory",
});
}
return ops;
}
export async function syncZonePull(
db: Db,
cf: CloudflareClient,
zoneId: string,
) {
const jobId = createSyncJob(db, zoneId);
updateSyncJob(db, jobId, { status: "running" });
try {
const diff = await buildZoneDiff(db, cf, zoneId);
for (const op of diff) {
if (op.kind === "noop") continue;
addSyncEvent(db, jobId, {
kind: op.kind,
recordName: op.recordName,
recordType: op.recordType,
detail: op.detail,
});
}
// Update local sync_status from diff
for (const op of diff) {
if (!op.entityId) continue;
const status =
op.kind === "noop"
? "ok"
: op.kind === "create"
? "missing"
: op.kind === "proxy_violation" || op.kind === "update"
? "drift"
: "error";
if (op.entityType === "alias") {
updateAlias(db, op.entityId, {
syncStatus: status,
cfRecordId:
(op.observed?.id as string | undefined) ??
getAlias(db, op.entityId).cfRecordId,
});
} else if (op.entityType === "node_a") {
updateNode(db, op.entityId, {
syncStatus: status === "ok" ? status : status,
cfARecordId:
(op.observed?.id as string | undefined) ??
getNode(db, op.entityId).cfARecordId,
});
} else if (op.entityType === "node_aaaa") {
updateNode(db, op.entityId, {
cfAaaaRecordId:
(op.observed?.id as string | undefined) ??
getNode(db, op.entityId).cfAaaaRecordId,
syncStatus: status,
});
}
}
// Mark fully ok nodes/aliases that only had noop
const byEntity = new Map<string, SyncDiffOp[]>();
for (const op of diff) {
if (!op.entityId) continue;
const list = byEntity.get(op.entityId) ?? [];
list.push(op);
byEntity.set(op.entityId, list);
}
for (const [entityId, list] of byEntity) {
const actionable = list.filter((o) => o.kind !== "noop");
if (actionable.length === 0) {
const sample = list[0];
if (sample?.entityType === "alias") {
updateAlias(db, entityId, {
syncStatus: "ok",
cfRecordId: (sample.observed?.id as string) ?? undefined,
lastError: null,
});
} else if (sample?.entityType.startsWith("node")) {
updateNode(db, entityId, {
syncStatus: "ok",
lastError: null,
});
}
}
}
updateSyncJob(db, jobId, {
status: "done",
diffJson: JSON.stringify(diff),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
updateZone(db, zoneId, {
lastSyncAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
return getSyncJob(db, jobId);
} catch (err) {
updateSyncJob(db, jobId, {
status: "failed",
error: err instanceof Error ? err.message : String(err),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
throw err;
}
}
export async function applyZoneDiff(
db: Db,
cf: CloudflareClient,
zoneId: string,
opIds?: string[],
) {
const zone = getZone(db, zoneId);
if (!zone.cfZoneId) {
throw AppError.validation("У зоны не задан cfZoneId Cloudflare");
}
const diff = await buildZoneDiff(db, cf, zoneId);
const selected = opIds?.length
? diff.filter((o) => opIds.includes(o.id))
: diff.filter((o) =>
["create", "update", "proxy_violation", "delete"].includes(o.kind),
);
const jobId = createSyncJob(db, zoneId);
updateSyncJob(db, jobId, { status: "running" });
try {
for (const op of selected) {
if (op.kind === "orphan" || op.kind === "noop") continue;
if (!op.desired) continue;
const payload = {
type: String(op.desired.type),
name: String(op.desired.name),
content: String(op.desired.content),
ttl: Number(op.desired.ttl ?? zone.defaultTtl),
proxied: false,
};
if (op.kind === "create") {
const created = await cf.createDnsRecord(zone.cfZoneId, payload);
if (op.entityType === "alias" && op.entityId) {
updateAlias(db, op.entityId, {
syncStatus: "ok",
cfRecordId: created.id ?? null,
lastError: null,
});
} else if (op.entityType === "node_a" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfARecordId: created.id ?? null,
lastError: null,
});
} else if (op.entityType === "node_aaaa" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfAaaaRecordId: created.id ?? null,
lastError: null,
});
}
} else if (
(op.kind === "update" || op.kind === "proxy_violation") &&
op.observed?.id
) {
await cf.updateDnsRecord(
zone.cfZoneId,
String(op.observed.id),
payload,
);
if (op.entityType === "alias" && op.entityId) {
updateAlias(db, op.entityId, {
syncStatus: "ok",
cfRecordId: String(op.observed.id),
lastError: null,
});
} else if (op.entityType === "node_a" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfARecordId: String(op.observed.id),
lastError: null,
});
} else if (op.entityType === "node_aaaa" && op.entityId) {
updateNode(db, op.entityId, {
syncStatus: "ok",
cfAaaaRecordId: String(op.observed.id),
lastError: null,
});
}
} else if (op.kind === "delete" && op.observed?.id) {
await cf.deleteDnsRecord(zone.cfZoneId, String(op.observed.id));
}
addSyncEvent(db, jobId, {
kind: `applied_${op.kind}`,
recordName: op.recordName,
recordType: op.recordType,
});
}
updateSyncJob(db, jobId, {
status: "done",
diffJson: JSON.stringify(selected),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
updateZone(db, zoneId, {
lastSyncAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
return getSyncJob(db, jobId);
} catch (err) {
updateSyncJob(db, jobId, {
status: "failed",
error: err instanceof Error ? err.message : String(err),
finishedAt: new Date().toISOString().replace("T", " ").slice(0, 19),
});
throw err;
}
}
export async function retargetAlias(
db: Db,
cf: CloudflareClient,
aliasId: string,
targetNodeId: string,
) {
const alias = updateAlias(db, aliasId, { targetNodeId });
const zone = getZone(db, alias.zoneId);
const target = getNode(db, targetNodeId);
if (cf.isConfigured && zone.cfZoneId) {
const payload = {
type: "CNAME",
name: alias.name,
content: target.hostname,
ttl: zone.defaultTtl,
proxied: false,
};
if (alias.cfRecordId) {
await cf.patchDnsRecord(zone.cfZoneId, alias.cfRecordId, {
content: target.hostname,
proxied: false,
});
updateAlias(db, aliasId, { syncStatus: "ok", lastError: null });
} else {
const created = await cf.createDnsRecord(zone.cfZoneId, payload);
updateAlias(db, aliasId, {
syncStatus: "ok",
cfRecordId: created.id ?? null,
lastError: null,
});
}
} else {
updateAlias(db, aliasId, { syncStatus: "pending" });
}
return getAlias(db, aliasId);
}
export function exportBindZone(db: Db, zoneId: string): string {
const zone = getZone(db, zoneId);
const nodeList = listNodes(db, { zoneId });
const aliasList = listAliases(db, { zoneId });
const lines: string[] = [
`;; CDNManager export — ${zone.name}`,
`;; TTL default ${zone.defaultTtl}; all records DNS-only (proxied:false)`,
"",
";; Canonical A/AAAA",
];
for (const node of nodeList) {
const v4 = node.addresses.find((a) => a.family === "v4");
const v6 = node.addresses.find((a) => a.family === "v6");
if (v4) {
lines.push(
`${node.hostname}. ${zone.defaultTtl} IN A ${v4.ip} ; cf_tags=cf-proxied:false`,
);
}
if (v6) {
lines.push(
`${node.hostname}. ${zone.defaultTtl} IN AAAA ${v6.ip} ; cf_tags=cf-proxied:false`,
);
}
}
lines.push("", ";; Service CNAME aliases");
for (const alias of aliasList) {
lines.push(
`${alias.name}. ${zone.defaultTtl} IN CNAME ${alias.targetHostname}. ; purpose=${alias.purpose}`,
);
}
lines.push("");
return lines.join("\n");
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import type { FastifyInstance } from "fastify";
describe("fleet api", () => {
let app: FastifyInstance;
let authHeader: { authorization: string };
beforeAll(async () => {
app = await buildApp({
config: { ...loadConfig(), staticDir: null, authRequired: false },
memory: true,
});
const login = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(login.statusCode).toBe(200);
const token = login.json().token as string;
authHeader = { authorization: `Bearer ${token}` };
});
afterAll(async () => {
await app.close();
});
it("lists seeded locations", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/locations",
headers: authHeader,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.length).toBeGreaterThanOrEqual(5);
expect(body.some((l: { code: string }) => l.code === "msk")).toBe(true);
});
it("creates zone, node, alias and exports bind", async () => {
const zoneRes = await app.inject({
method: "POST",
url: "/api/v1/zones",
headers: authHeader,
payload: { name: "rtnt.top" },
});
expect(zoneRes.statusCode).toBe(200);
const zone = zoneRes.json();
const locs = (
await app.inject({
method: "GET",
url: "/api/v1/locations",
headers: authHeader,
})
).json();
const msk = locs.find((l: { code: string }) => l.code === "msk");
const nodeRes = await app.inject({
method: "POST",
url: "/api/v1/nodes",
headers: authHeader,
payload: {
zoneId: zone.id,
locationId: msk.id,
role: "hub",
indexNum: 1,
ipv4: "94.142.140.141",
},
});
expect(nodeRes.statusCode).toBe(200);
const node = nodeRes.json();
expect(node.hostname).toBe("msk-hub01.rtnt.top");
const aliasRes = await app.inject({
method: "POST",
url: "/api/v1/aliases",
headers: authHeader,
payload: {
zoneId: zone.id,
name: "msk.rtnt.top",
purpose: "geo",
mode: "primary",
targetNodeId: node.id,
},
});
expect(aliasRes.statusCode).toBe(200);
const bind = await app.inject({
method: "GET",
url: `/api/v1/zones/${zone.id}/export/bind`,
headers: authHeader,
});
expect(bind.statusCode).toBe(200);
expect(bind.json().content).toContain("msk-hub01.rtnt.top");
expect(bind.json().content).toContain("msk.rtnt.top");
const stats = await app.inject({
method: "GET",
url: "/api/v1/dashboard/stats",
headers: authHeader,
});
expect(stats.statusCode).toBe(200);
expect(stats.json().nodes).toBe(1);
expect(stats.json().aliases).toBe(1);
const topo = await app.inject({
method: "GET",
url: "/api/v1/topology",
headers: authHeader,
});
expect(topo.statusCode).toBe(200);
expect(topo.json().nodes).toHaveLength(1);
});
});