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
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:
Vendored
+1037
-8
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(":");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user