diff --git a/.env.example b/.env.example index 709b701..fd0bb5f 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ ADMIN_PASSWORD_HASH= # Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored) REUI_LICENSE_KEY= +# Cloudflare — Zone DNS Edit + Zone Read +CLOUDFLARE_API_TOKEN= + # Server SERVER_PORT=8081 STATIC_DIR= diff --git a/AGENTS.md b/AGENTS.md index 823dc9c..b2e77eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,16 @@ # CDN Manager -Self-hosted CDN management (каркас). Стек как у CFDM: pnpm monorepo, Vite + React + TanStack + shadcn/ReUI, Fastify + Drizzle + SQLite. +Self-hosted панель **desired-state DNS** для флота CHR/VPS: канонические ноды (A/AAAA) + сервисные CNAME (failover / geo / ix), синхронизация с Cloudflare DNS-only. + +## Vs CFDM + +| CDNManager | Cloudflare Domain Manager | +|------------|---------------------------| +| Флот нод + алиасы + drift/orphan | Домены, сервисы, LB, health-checks, сертификаты | +| Роутинг-зона (напр. `rtnt.top`) | Мультидоменный ops | +| Retarget CNAME как основной UX | Service bindings / HC | + +App id в auth-portal: **`cdn`**. ## Стек @@ -9,28 +19,53 @@ Self-hosted CDN management (каркас). Стек как у CFDM: pnpm monorep - `apps/api` — Fastify + Drizzle + SQLite - Packages: `@cdnmanager/ui`, `@cdnmanager/shared`, `@cdnmanager/db` +## Доменная модель + +- **Location** — справочник кодов (`msk`, `fra`, `ams`, `hel`, `par`) +- **Node** — канонический FQDN + A/AAAA (`msk-hub01.rtnt.top`) +- **Alias** — CNAME → node (`msk.rtnt.top`, `ix-eu.rtnt.top`) +- **Zone** — Cloudflare zone + naming template / TTL +- Managed-записи всегда **`proxied: false`** (туннели WG/GRE) + +Нейминг: шаблон `{loc}-{role}{nn}.{zone}` (см. документ по CHR DNS). + ## Команды ```bash pnpm install pnpm --filter @cdnmanager/shared build pnpm --filter @cdnmanager/db build -pnpm --filter @cdnmanager/api dev -pnpm --filter web dev +pnpm --filter @cdnmanager/api dev # :8081 +pnpm --filter web dev # :5176 pnpm --filter web build ``` -Локально: API `http://localhost:8081`, web `http://localhost:5176`. +Env: `CLOUDFLARE_API_TOKEN` (Zone DNS Edit + Zone Read). Локально `AUTH_REQUIRED=false`, логин `admin` / `admin`. -## ReUI PRO +## UI (ReUI PRO + DNA CFDM) -Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md). -MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`. -License: `REUI_LICENSE_KEY` в `apps/web/.env.local`. +Surface: **frame**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md). -## Auth +| Экран | Kit / pattern | +|-------|----------------| +| Dashboard | `OpsDashboard` + `KpiStatGrid` + `QuickActionGrid` + charts + `AttentionQueue` (как CFDM) | +| Ноды / Алиасы | `ResourcePage` | +| Топология | Frame grid + IconTile | +| Зоны / Sync | Frame Item rows + sync/apply/BIND export | +| Settings | `SettingsShell` (appearance + cloudflare) | -App id в auth-portal: **`cdn`**. Локально `AUTH_REQUIRED=false`, логин `admin` / `admin`. +Preview: [stats-12](https://reui.io/preview/base/stats-12) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [settings-16](https://reui.io/preview/base/settings-16) · [app-shell-12](https://reui.io/preview/base/app-shell-12). + +MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. License: `REUI_LICENSE_KEY` в `apps/web/.env.local`. + +## API (кратко) + +``` +GET/POST /api/v1/zones · POST .../sync · POST .../apply · GET .../export/bind +GET/POST /api/v1/nodes · GET/POST /api/v1/aliases · POST .../retarget +GET /api/v1/locations · GET /api/v1/dashboard/stats · GET /api/v1/topology +GET /api/v1/cloudflare/zones +``` ## CI / Docker diff --git a/README.md b/README.md index c0426e5..da11183 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CDN Manager -Каркас приложения для управления CDN. Monorepo на базе стека Cloudflare Domain Manager (без CF/domain-логики). +Панель desired-state DNS для флота CHR/VPS: канонические ноды (A/AAAA) + сервисные CNAME, sync с Cloudflare (DNS-only). Дополняет CFDM, не заменяет его. ## Быстрый старт @@ -14,14 +14,20 @@ pnpm --filter web dev # :5176 Локальный логин: `admin` / `admin` (`AUTH_REQUIRED=false`). +```env +CLOUDFLARE_API_TOKEN= # Zone DNS Edit + Zone Read +``` + ## Структура ``` -apps/web # Vite SPA -apps/api # Fastify API +apps/web # Vite SPA — nodes / aliases / topology / zones / dashboard +apps/api # Fastify — fleet + Cloudflare DNS client packages/ui # @cdnmanager/ui — shadcn -packages/shared # @cdnmanager/shared — Zod +packages/shared # @cdnmanager/shared — Zod contracts packages/db # @cdnmanager/db — Drizzle + SQLite ``` -Подробнее: [AGENTS.md](AGENTS.md), [docs/releasing.md](docs/releasing.md), [docs/ui-design-contract.md](docs/ui-design-contract.md). +Экраны: Обзор · Ноды · Алиасы · Топология · Зоны/Sync · Настройки. + +Подробнее: [AGENTS.md](AGENTS.md), [docs/Home.md](docs/Home.md), [docs/ui-design-contract.md](docs/ui-design-contract.md). diff --git a/apps/api/dist/server.js b/apps/api/dist/server.js index 9fce15d..f842947 100644 --- a/apps/api/dist/server.js +++ b/apps/api/dist/server.js @@ -31,7 +31,8 @@ function loadConfig() { authRequired: boolEnv(process.env.AUTH_REQUIRED, false), authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top", authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""), - authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null) + authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null), + cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim() }; } @@ -220,8 +221,246 @@ async function corsPlugin(app2) { } var cors_default = fp2(corsPlugin, { name: "cors" }); -// src/plugins/db.ts +// src/plugins/cf-client.ts import fp3 from "fastify-plugin"; + +// src/lib/cloudflare/http.ts +var CF_API_BASE = "https://api.cloudflare.com/client/v4"; +function parseRetryAfter(headers) { + const value = headers.get("retry-after"); + if (!value) return null; + const seconds = Number(value); + return Number.isFinite(seconds) ? seconds * 1e3 : null; +} +async function withRetry(operation, maxAttempts = 3) { + let delay = 500; + let lastError; + 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; +} +function mapCloudflareFailure(operation, status, message) { + const lower = message.toLowerCase(); + if (status === 401 || status === 403 || lower.includes("authentication")) { + return AppError.cloudflareAuthFailed( + "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D. \u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 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( + `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C DNS \u0432 Cloudflare: ${message}` + ); + } + return AppError.cloudflare(`${operation}: ${message}`); +} +async function handleCfResponse(response, operation) { + if (response.status === 429) { + const wait = parseRetryAfter(response.headers) ?? 5e3; + throw AppError.rateLimited( + `Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 ${Math.ceil(wait / 1e3)} \u0441.` + ); + } + const body = await response.json(); + if (!body.success) { + const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; + throw mapCloudflareFailure(operation, response.status, msg); + } + if (body.result === void 0) { + throw mapCloudflareFailure(operation, response.status, "empty result"); + } + return body.result; +} + +// src/lib/cloudflare/dns-service.ts +function createDnsAdapter(token) { + return { + async listDnsRecords(zoneId) { + return withRetry(async () => { + const all = []; + 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(3e4) + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure( + "list_dns_records", + response.status, + String(response.status) + ); + } + const batch = await handleCfResponse( + response, + "list_dns_records" + ); + if (batch.length === 0) break; + all.push(...batch); + page += 1; + } + return all; + }); + }, + async createDnsRecord(zoneId, payload) { + 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(3e4) + }); + return handleCfResponse(response, "create_dns_record"); + }, + async updateDnsRecord(zoneId, recordId, payload) { + 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(3e4) + } + ); + return handleCfResponse(response, "update_dns_record"); + }, + async patchDnsRecord(zoneId, recordId, payload) { + 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(3e4) + } + ); + return handleCfResponse(response, "patch_dns_record"); + }, + async deleteDnsRecord(zoneId, recordId) { + const response = await fetch( + `${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + } + ); + await handleCfResponse(response, "delete_dns_record"); + } + }; +} + +// src/lib/cloudflare/zone-service.ts +function createZoneAdapter(token) { + return { + async listZones() { + return withRetry(async () => { + const all = []; + 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(3e4) + }); + if (response.status >= 500 || response.status === 429) { + throw mapCloudflareFailure( + "list_zones", + response.status, + String(response.status) + ); + } + const batch = await handleCfResponse(response, "list_zones"); + if (batch.length === 0) break; + all.push(...batch); + if (batch.length < 50) break; + page += 1; + } + return all; + }); + }, + async getZone(zoneId) { + const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3e4) + }); + return handleCfResponse(response, "get_zone"); + } + }; +} + +// src/lib/cf-client.ts +var CloudflareClient = class { + zones; + dns; + token; + constructor(token) { + this.token = token.trim(); + this.zones = createZoneAdapter(this.token); + this.dns = createDnsAdapter(this.token); + } + get isConfigured() { + return this.token.length > 0; + } + listZones() { + return this.zones.listZones(); + } + getZone(zoneId) { + return this.zones.getZone(zoneId); + } + listDnsRecords(zoneId) { + return this.dns.listDnsRecords(zoneId); + } + createDnsRecord(zoneId, payload) { + return this.dns.createDnsRecord(zoneId, payload); + } + updateDnsRecord(zoneId, recordId, payload) { + return this.dns.updateDnsRecord(zoneId, recordId, payload); + } + patchDnsRecord(zoneId, recordId, payload) { + return this.dns.patchDnsRecord(zoneId, recordId, payload); + } + deleteDnsRecord(zoneId, recordId) { + return this.dns.deleteDnsRecord(zoneId, recordId); + } +}; + +// src/plugins/cf-client.ts +var cfClientPlugin = async (app2, opts) => { + const cf = new CloudflareClient(opts.config.cloudflareApiToken); + app2.decorate("cf", cf); +}; +var cf_client_default = fp3(cfClientPlugin, { name: "cf-client" }); + +// src/plugins/db.ts +import fp4 from "fastify-plugin"; import { createDb, createMemoryDb, @@ -237,10 +476,10 @@ async function dbPlugin(app2, opts) { sqlite.close(); }); } -var db_default = fp3(dbPlugin, { name: "db" }); +var db_default = fp4(dbPlugin, { name: "db" }); // src/plugins/error-handler.ts -import fp4 from "fastify-plugin"; +import fp5 from "fastify-plugin"; async function errorHandlerPlugin(app2) { app2.setErrorHandler((err, _request, reply) => { if (reply.sent) return; @@ -248,7 +487,7 @@ async function errorHandlerPlugin(app2) { reply.status(appErr.statusCode).send(errorBody(appErr)); }); } -var error_handler_default = fp4(errorHandlerPlugin, { name: "error-handler" }); +var error_handler_default = fp5(errorHandlerPlugin, { name: "error-handler" }); // src/routes/health.ts import { z } from "zod"; @@ -331,7 +570,11 @@ import { appSettingsPatchSchema } from "@cdnmanager/shared"; import { getAppSettings, updateAppSettings } from "@cdnmanager/db"; async function settingsRoutes(app2) { app2.get("/settings", async (request) => { - return getAppSettings(request.server.db); + const settings = getAppSettings(request.server.db); + return { + ...settings, + cloudflareConfigured: request.server.cf.isConfigured + }; }); app2.patch("/settings", async (request) => { const parsed = appSettingsPatchSchema.safeParse(request.body); @@ -340,10 +583,792 @@ async function settingsRoutes(app2) { parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438" ); } - return updateAppSettings(request.server.db, parsed.data); + const settings = updateAppSettings(request.server.db, parsed.data); + return { + ...settings, + cloudflareConfigured: request.server.cf.isConfigured + }; }); } +// src/routes/fleet.ts +import { + aliasCreateSchema, + aliasPatchSchema, + aliasRetargetSchema, + nodeCreateSchema, + nodePatchSchema, + orphanIgnoreSchema, + syncApplySchema, + zoneCreateSchema, + zonePatchSchema +} from "@cdnmanager/shared"; +import { + createAlias, + createNode, + createZone, + dashboardCounts, + deleteAlias, + deleteNode, + deleteZone, + getAlias as getAlias2, + getNode as getNode2, + getZone as getZone2, + ignoreOrphan, + listAliases as listAliases2, + listIgnoredOrphans as listIgnoredOrphans2, + listLocations, + listNodes as listNodes2, + listSyncJobs, + listZones, + unignoreOrphan, + updateAlias as updateAlias2, + updateNode as updateNode2, + updateZone as updateZone2 +} from "@cdnmanager/db"; + +// src/services/naming.ts +function buildHostname(opts) { + 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) { + host = host.replaceAll("{provider}", opts.providerTag.toLowerCase()); + } else { + host = host.replaceAll("-{provider}", "").replaceAll("{provider}", ""); + } + return host.replace(/\.$/, ""); +} +function normalizeFqdn(name) { + return name.trim().toLowerCase().replace(/\.$/, ""); +} +function isValidIpv4(ip) { + 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; + }); +} +function isValidIpv6(ip) { + return /^[0-9a-f:]+$/i.test(ip) && ip.includes(":"); +} + +// src/services/sync.ts +import { randomUUID } from "crypto"; +import { + getAlias, + getNode, + getZone, + listAliases, + listIgnoredOrphans, + listNodes, + updateAlias, + updateNode, + updateZone, + createSyncJob, + updateSyncJob, + getSyncJob, + addSyncEvent +} from "@cdnmanager/db"; +function opId() { + return `op-${randomUUID().slice(0, 8)}`; +} +function findObserved(records, type, name) { + const n = normalizeFqdn(name); + return records.find( + (r) => r.type === type && normalizeFqdn(r.name) === n + ); +} +async function buildZoneDiff(db, cf, zoneId) { + const zone = getZone(db, zoneId); + if (!zone.cfZoneId) { + throw AppError.validation("\u0423 \u0437\u043E\u043D\u044B \u043D\u0435 \u0437\u0430\u0434\u0430\u043D cfZoneId Cloudflare"); + } + if (!cf.isConfigured) { + throw AppError.cloudflareAuthFailed( + "CLOUDFLARE_API_TOKEN \u043D\u0435 \u0437\u0430\u0434\u0430\u043D. \u0414\u043E\u0431\u0430\u0432\u044C\u0442\u0435 \u0442\u043E\u043A\u0435\u043D \u0432 \u043E\u043A\u0440\u0443\u0436\u0435\u043D\u0438\u0435 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 = []; + const managedKeys = /* @__PURE__ */ new Set(); + 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-\u0437\u0430\u043F\u0438\u0441\u044C \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 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 \u0432\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0434\u043B\u044F \u0442\u0443\u043D\u043D\u0435\u043B\u0435\u0439 \u043D\u0443\u0436\u0435\u043D DNS-only" : "\u0421\u043E\u0434\u0435\u0440\u0436\u0438\u043C\u043E\u0435/TTL \u043E\u0442\u043B\u0438\u0447\u0430\u0435\u0442\u0441\u044F \u043E\u0442 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: "\u0417\u0430\u043F\u0438\u0441\u044C \u0432 Cloudflare \u0432\u043D\u0435 inventory" + }); + } + return ops; +} +async function syncZonePull(db, cf, zoneId) { + 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 + }); + } + 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 ?? getAlias(db, op.entityId).cfRecordId + }); + } else if (op.entityType === "node_a") { + updateNode(db, op.entityId, { + syncStatus: status === "ok" ? status : status, + cfARecordId: op.observed?.id ?? getNode(db, op.entityId).cfARecordId + }); + } else if (op.entityType === "node_aaaa") { + updateNode(db, op.entityId, { + cfAaaaRecordId: op.observed?.id ?? getNode(db, op.entityId).cfAaaaRecordId, + syncStatus: status + }); + } + } + const byEntity = /* @__PURE__ */ new Map(); + 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 ?? void 0, + 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: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19) + }); + updateZone(db, zoneId, { + lastSyncAt: (/* @__PURE__ */ 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: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19) + }); + throw err; + } +} +async function applyZoneDiff(db, cf, zoneId, opIds) { + const zone = getZone(db, zoneId); + if (!zone.cfZoneId) { + throw AppError.validation("\u0423 \u0437\u043E\u043D\u044B \u043D\u0435 \u0437\u0430\u0434\u0430\u043D 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: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19) + }); + updateZone(db, zoneId, { + lastSyncAt: (/* @__PURE__ */ 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: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19) + }); + throw err; + } +} +async function retargetAlias(db, cf, aliasId, targetNodeId) { + 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); +} +function exportBindZone(db, zoneId) { + const zone = getZone(db, zoneId); + const nodeList = listNodes(db, { zoneId }); + const aliasList = listAliases(db, { zoneId }); + const lines = [ + `;; CDNManager export \u2014 ${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"); +} + +// src/routes/fleet.ts +var fleetRoutes = async (app2) => { + app2.get("/locations", async () => listLocations(app2.db)); + app2.get("/zones", async () => listZones(app2.db)); + app2.post("/zones", async (req) => { + const body = zoneCreateSchema.parse(req.body); + return createZone(app2.db, body); + }); + app2.get( + "/zones/:zoneId", + async (req) => getZone2(app2.db, req.params.zoneId) + ); + app2.patch("/zones/:zoneId", async (req) => { + const body = zonePatchSchema.parse(req.body); + return updateZone2(app2.db, req.params.zoneId, body); + }); + app2.delete("/zones/:zoneId", async (req) => { + deleteZone(app2.db, req.params.zoneId); + return { ok: true }; + }); + app2.get("/cloudflare/zones", async () => { + if (!app2.cf.isConfigured) { + throw AppError.cloudflareAuthFailed("CLOUDFLARE_API_TOKEN \u043D\u0435 \u0437\u0430\u0434\u0430\u043D"); + } + return app2.cf.listZones(); + }); + app2.post( + "/zones/:zoneId/sync", + async (req) => syncZonePull(app2.db, app2.cf, req.params.zoneId) + ); + app2.post( + "/zones/:zoneId/apply", + async (req) => { + const body = syncApplySchema.parse(req.body ?? {}); + return applyZoneDiff(app2.db, app2.cf, req.params.zoneId, body.opIds); + } + ); + app2.get( + "/zones/:zoneId/sync-jobs", + async (req) => listSyncJobs(app2.db, req.params.zoneId) + ); + app2.get( + "/zones/:zoneId/export/bind", + async (req) => ({ + zoneName: getZone2(app2.db, req.params.zoneId).name, + content: exportBindZone(app2.db, req.params.zoneId) + }) + ); + app2.get( + "/zones/:zoneId/orphans/ignored", + async (req) => listIgnoredOrphans2(app2.db, req.params.zoneId) + ); + app2.post( + "/zones/:zoneId/orphans/ignore", + async (req) => { + const body = orphanIgnoreSchema.parse(req.body); + ignoreOrphan( + app2.db, + req.params.zoneId, + normalizeFqdn(body.recordName), + body.recordType.toUpperCase() + ); + return { ok: true }; + } + ); + app2.post( + "/zones/:zoneId/orphans/unignore", + async (req) => { + const body = orphanIgnoreSchema.parse(req.body); + unignoreOrphan( + app2.db, + req.params.zoneId, + normalizeFqdn(body.recordName), + body.recordType.toUpperCase() + ); + return { ok: true }; + } + ); + app2.get("/nodes", async (req) => { + const q = req.query; + return listNodes2(app2.db, { + zoneId: q.zoneId, + locationId: q.locationId, + role: q.role, + syncStatus: q.syncStatus, + q: q.q + }); + }); + app2.post("/nodes", async (req) => { + const body = nodeCreateSchema.parse(req.body); + if (!isValidIpv4(body.ipv4)) throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4"); + if (body.ipv6 && !isValidIpv6(body.ipv6)) { + throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv6"); + } + const zone = getZone2(app2.db, body.zoneId); + const loc = listLocations(app2.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(app2.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 + }); + }); + app2.get( + "/nodes/:nodeId", + async (req) => getNode2(app2.db, req.params.nodeId) + ); + app2.patch("/nodes/:nodeId", async (req) => { + const body = nodePatchSchema.parse(req.body); + if (body.ipv4 && !isValidIpv4(body.ipv4)) { + throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4"); + } + if (body.ipv6 && !isValidIpv6(body.ipv6)) { + throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv6"); + } + return updateNode2(app2.db, req.params.nodeId, { + ...body, + hostname: body.hostname ? normalizeFqdn(body.hostname) : void 0 + }); + }); + app2.delete("/nodes/:nodeId", async (req) => { + deleteNode(app2.db, req.params.nodeId); + return { ok: true }; + }); + app2.get("/aliases", async (req) => { + const q = req.query; + return listAliases2(app2.db, { + zoneId: q.zoneId, + purpose: q.purpose, + syncStatus: q.syncStatus, + q: q.q + }); + }); + app2.post("/aliases", async (req) => { + const body = aliasCreateSchema.parse(req.body); + return createAlias(app2.db, { + zoneId: body.zoneId, + name: normalizeFqdn(body.name), + purpose: body.purpose, + mode: body.mode, + targetNodeId: body.targetNodeId + }); + }); + app2.get( + "/aliases/:aliasId", + async (req) => getAlias2(app2.db, req.params.aliasId) + ); + app2.patch( + "/aliases/:aliasId", + async (req) => { + const body = aliasPatchSchema.parse(req.body); + return updateAlias2(app2.db, req.params.aliasId, { + ...body, + name: body.name ? normalizeFqdn(body.name) : void 0 + }); + } + ); + app2.post( + "/aliases/:aliasId/retarget", + async (req) => { + const body = aliasRetargetSchema.parse(req.body); + return retargetAlias( + app2.db, + app2.cf, + req.params.aliasId, + body.targetNodeId + ); + } + ); + app2.delete( + "/aliases/:aliasId", + async (req) => { + deleteAlias(app2.db, req.params.aliasId); + return { ok: true }; + } + ); + app2.get("/dashboard/stats", async () => { + const base = dashboardCounts(app2.db); + const nodes = listNodes2(app2.db); + const aliases = listAliases2(app2.db); + const proxyViolations = [...nodes, ...aliases].filter( + (x) => x.syncStatus === "drift" && (x.lastError ?? "").includes("Proxy") + ).length; + const orphans = listZones(app2.db).length ? listSyncJobs(app2.db, listZones(app2.db)[0].id).flatMap((j) => j.diff ?? []).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 + }; + }); + app2.get("/topology", async (req) => { + const q = req.query; + const zoneId = q.zoneId; + const locs = listLocations(app2.db); + const nodeList = listNodes2(app2.db, zoneId ? { zoneId } : {}); + const aliasList = listAliases2(app2.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 ?? "" + })) + }; + }); + app2.get("/naming/preview", async (req) => { + const q = req.query; + if (!q.zoneId || !q.locationId || !q.role) { + throw AppError.validation("zoneId, locationId, role \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B"); + } + const zone = getZone2(app2.db, q.zoneId); + const loc = listLocations(app2.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 + }) + }; + }); +}; + // src/app.ts async function buildApp(opts = {}) { const config2 = opts.config ?? loadConfig(); @@ -353,7 +1378,9 @@ async function buildApp(opts = {}) { app2.setValidatorCompiler(validatorCompiler); app2.setSerializerCompiler(serializerCompiler); await app2.register(import("@fastify/sensible")); - await app2.register(import("@fastify/helmet"), { contentSecurityPolicy: false }); + await app2.register(import("@fastify/helmet"), { + contentSecurityPolicy: false + }); await app2.register(import("@fastify/rate-limit"), { max: 300, timeWindow: "1 minute" @@ -361,6 +1388,7 @@ async function buildApp(opts = {}) { await app2.register(cors_default); await app2.register(error_handler_default); await app2.register(db_default, { config: config2, memory: opts.memory }); + await app2.register(cf_client_default, { config: config2 }); await app2.register(auth_default, { config: config2 }); await app2.register(healthRoutes); await app2.register(authRoutes, { prefix: "/api/v1" }); @@ -368,6 +1396,7 @@ async function buildApp(opts = {}) { async (protectedApi) => { protectedApi.addHook("onRequest", requireAuth); await protectedApi.register(settingsRoutes); + await protectedApi.register(fleetRoutes); }, { prefix: "/api/v1" } ); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d693ebf..00596e5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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" }, ); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index c4cdf72..ad148cb 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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(), }; } diff --git a/apps/api/src/lib/cf-client.ts b/apps/api/src/lib/cf-client.ts new file mode 100644 index 0000000..780fc56 --- /dev/null +++ b/apps/api/src/lib/cf-client.ts @@ -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 { + return this.zones.listZones(); + } + + getZone(zoneId: string): Promise { + return this.zones.getZone(zoneId); + } + + listDnsRecords(zoneId: string): Promise { + return this.dns.listDnsRecords(zoneId); + } + + createDnsRecord( + zoneId: string, + payload: CreateDnsRecordPayload, + ): Promise { + return this.dns.createDnsRecord(zoneId, payload); + } + + updateDnsRecord( + zoneId: string, + recordId: string, + payload: CreateDnsRecordPayload, + ): Promise { + return this.dns.updateDnsRecord(zoneId, recordId, payload); + } + + patchDnsRecord( + zoneId: string, + recordId: string, + payload: PatchDnsRecordPayload, + ): Promise { + return this.dns.patchDnsRecord(zoneId, recordId, payload); + } + + deleteDnsRecord(zoneId: string, recordId: string): Promise { + return this.dns.deleteDnsRecord(zoneId, recordId); + } +} diff --git a/apps/api/src/lib/cloudflare/dns-service.ts b/apps/api/src/lib/cloudflare/dns-service.ts new file mode 100644 index 0000000..1d4b2a9 --- /dev/null +++ b/apps/api/src/lib/cloudflare/dns-service.ts @@ -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 { + 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( + response, + "list_dns_records", + ); + if (batch.length === 0) break; + all.push(...batch); + page += 1; + } + return all; + }); + }, + + async createDnsRecord( + zoneId: string, + payload: CreateDnsRecordPayload, + ): Promise { + 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 { + 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 { + 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 { + 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"); + }, + }; +} diff --git a/apps/api/src/lib/cloudflare/http.ts b/apps/api/src/lib/cloudflare/http.ts new file mode 100644 index 0000000..2b52714 --- /dev/null +++ b/apps/api/src/lib/cloudflare/http.ts @@ -0,0 +1,83 @@ +import { AppError } from "../../errors.js"; + +export const CF_API_BASE = "https://api.cloudflare.com/client/v4"; + +export interface CfResponse { + 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( + operation: () => Promise, + maxAttempts = 3, +): Promise { + 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( + response: Response, + operation: string, +): Promise { + 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; + 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; +} diff --git a/apps/api/src/lib/cloudflare/zone-service.ts b/apps/api/src/lib/cloudflare/zone-service.ts new file mode 100644 index 0000000..14d2b94 --- /dev/null +++ b/apps/api/src/lib/cloudflare/zone-service.ts @@ -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 { + 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(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 { + const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000), + }); + return handleCfResponse(response, "get_zone"); + }, + }; +} diff --git a/apps/api/src/plugins/cf-client.ts b/apps/api/src/plugins/cf-client.ts new file mode 100644 index 0000000..a499712 --- /dev/null +++ b/apps/api/src/plugins/cf-client.ts @@ -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" }); diff --git a/apps/api/src/routes/fleet.ts b/apps/api/src/routes/fleet.ts new file mode 100644 index 0000000..79d3ec4 --- /dev/null +++ b/apps/api/src/routes/fleet.ts @@ -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; + 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; + 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; + 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; + 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, + }), + }; + }); +}; diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index c4cd2b8..49e837c 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -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, + }; }); } diff --git a/apps/api/src/services/naming.ts b/apps/api/src/services/naming.ts new file mode 100644 index 0000000..fce432b --- /dev/null +++ b/apps/api/src/services/naming.ts @@ -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(":"); +} diff --git a/apps/api/src/services/sync.ts b/apps/api/src/services/sync.ts new file mode 100644 index 0000000..63e25bc --- /dev/null +++ b/apps/api/src/services/sync.ts @@ -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 { + 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(); + + 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(); + 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"); +} diff --git a/apps/api/test/fleet.test.ts b/apps/api/test/fleet.test.ts new file mode 100644 index 0000000..73b53de --- /dev/null +++ b/apps/api/test/fleet.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 13bf95b..54c9c25 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,5 +1,12 @@ import { Link, useRouterState } from '@tanstack/react-router' -import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react' +import { + CloudIcon, + LayoutDashboardIcon, + Link2Icon, + MapIcon, + ServerIcon, + SettingsIcon, +} from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { NavUser } from '@/components/nav-user' import { @@ -17,6 +24,10 @@ import { const mainNav = [ { to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true }, + { to: '/nodes', label: 'Ноды', icon: ServerIcon, exact: false }, + { to: '/aliases', label: 'Алиасы', icon: Link2Icon, exact: false }, + { to: '/topology', label: 'Топология', icon: MapIcon, exact: false }, + { to: '/zones', label: 'Зоны / Sync', icon: CloudIcon, exact: false }, { to: '/settings/appearance', label: 'Настройки', diff --git a/apps/web/src/components/reui-kit/attention-queue.tsx b/apps/web/src/components/reui-kit/attention-queue.tsx new file mode 100644 index 0000000..4c59f64 --- /dev/null +++ b/apps/web/src/components/reui-kit/attention-queue.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' +import { EmptyState } from '@/components/empty-state' +import { Badge } from '@/components/reui/badge' +import { + Frame, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { IconTile } from '@/components/reui/icon-tile' +import { cn } from '@cdnmanager/ui/lib/utils' + +/** + * Sibling Frame columns for dashboard attention queue. + * Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile + */ +export interface AttentionQueueColumn { + id: string + title: string + icon: LucideIcon + iconClassName?: string + count: number + countVariant?: + | 'destructive' + | 'warning' + | 'secondary' + | 'destructive-light' + | 'warning-light' + emptyTitle: string + emptyDescription: string + emptyAction?: ReactNode + children: ReactNode +} + +interface AttentionQueueProps { + columns: AttentionQueueColumn[] + className?: string +} + +const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' + +export function AttentionQueue({ columns, className }: AttentionQueueProps) { + return ( +
+ {columns.map((column) => { + const Icon = column.icon + const isEmpty = column.count === 0 + return ( + + +
+ + {column.title} + {column.count > 0 ? ( + + {column.count} + + ) : null} +
+
+ + {isEmpty ? ( +
+ +
+ ) : ( + column.children + )} +
+ + ) + })} +
+ ) +} diff --git a/apps/web/src/components/reui-kit/dashboard-analytics.tsx b/apps/web/src/components/reui-kit/dashboard-analytics.tsx new file mode 100644 index 0000000..a84107e --- /dev/null +++ b/apps/web/src/components/reui-kit/dashboard-analytics.tsx @@ -0,0 +1,183 @@ +import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts' + +import { StatusBadge } from '@/components/status-badge' +import { EmptyState } from '@/components/empty-state' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@cdnmanager/ui/components/chart' +import { cn } from '@cdnmanager/ui/lib/utils' + +const syncChartConfig = { + count: { label: 'Записи' }, + ok: { label: 'OK', color: 'var(--success)' }, + drift: { label: 'Drift', color: 'var(--warning)' }, + missing: { label: 'Нет в CF', color: 'var(--warning)' }, + pending: { label: 'Ожидает', color: 'var(--info)' }, + error: { label: 'Ошибка', color: 'var(--destructive)' }, +} satisfies ChartConfig + +const locationChartConfig = { + count: { label: 'Ноды', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +function statusColor(status: string) { + return ( + (syncChartConfig as Record)[status]?.color ?? + 'var(--chart-1)' + ) +} + +interface SyncStatusChartProps { + data: { status: string; count: number }[] +} + +/** Pie of sync statuses — DNA from CFDM CertStatusChart */ +export function SyncStatusChart({ data }: SyncStatusChartProps) { + const total = data.reduce((sum, entry) => sum + entry.count, 0) + + return ( + + + Статусы синхронизации + Ноды и алиасы по sync_status + + + {data.length === 0 || total === 0 ? ( +
+ +
+ ) : ( +
+
+ + + } + /> + + {data.map((entry) => ( + + ))} + + + +
+ {total} + всего +
+
+
    + {data.map((entry) => ( +
  • + + + {entry.count} + +
  • + ))} +
+
+ )} +
+ + ) +} + +interface NodesByLocationChartProps { + data: { name: string; count: number }[] +} + +/** Bar chart of nodes by location — DNA from CFDM GroupDomainsChart */ +export function NodesByLocationChart({ data }: NodesByLocationChartProps) { + return ( + + + Ноды по локациям + Распределение флота по кодам городов + + + {data.length === 0 ? ( +
+ +
+ ) : ( +
+ + + + + value.length > 12 ? `${value.slice(0, 11)}…` : value + } + /> + } /> + + + + +
    + {data.map((entry) => ( +
  • + {entry.name} + + {entry.count} + +
  • + ))} +
+
+ )} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 8066bd5..dd236c6 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -15,3 +15,11 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' export { OpsDashboard } from './ops-dashboard' export { DetailPanel, type DetailMetricCard } from './detail-panel' export { SettingsShell, type SettingsTabConfig } from './settings-shell' +export { + AttentionQueue, + type AttentionQueueColumn, +} from './attention-queue' +export { + SyncStatusChart, + NodesByLocationChart, +} from './dashboard-analytics' diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx index 7ec80cc..a668709 100644 --- a/apps/web/src/components/reui-kit/settings-shell.tsx +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { Link, Outlet, useRouterState } from '@tanstack/react-router' -import { PaletteIcon } from 'lucide-react' +import { CloudIcon, PaletteIcon } from 'lucide-react' import { useIsMobile } from '@cdnmanager/ui/hooks/use-mobile' import { cn } from '@cdnmanager/ui/lib/utils' @@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [ label: 'Внешний вид', icon: