// src/server.ts import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs"; import { resolve as resolve3 } from "path"; // src/app.ts import { resolve as resolve2 } from "path"; import Fastify from "fastify"; import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"; // src/config.ts import { resolve } from "path"; function boolEnv(v, fallback) { if (v === void 0 || v === "") return fallback; return v === "1" || v.toLowerCase() === "true"; } function loadConfig() { const isProd = process.env.NODE_ENV === "production"; const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me"); return { databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db", cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(), jwtSecret: jwtSecret || "dev-secret-change-me", jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24, adminUsername: process.env.ADMIN_USERNAME ?? "admin", adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder", serverPort: Number(process.env.SERVER_PORT ?? "8080") || 8080, staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null, certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *", // Default: every 2 minutes (was every 30s — hammered origins / anti-bot). healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "0 */2 * * * *", healthDegradedFailures: Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1, healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2, healthSuccessRecoveries: Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2, healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1e3, healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2e3, healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(), healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(), logLevel: process.env.LOG_LEVEL ?? "info", 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) }; } // src/plugins/auth.ts import fp from "fastify-plugin"; // src/errors.ts import { NotFoundError, ConflictError } from "@cfdm/db"; import { ValidationError } from "@cfdm/shared"; var AppError = class _AppError extends Error { constructor(code, message, statusCode) { super(message); this.code = code; this.statusCode = statusCode; this.name = "AppError"; } code; statusCode; static notFound(message) { return new _AppError("NOT_FOUND", message, 404); } static validation(message) { return new _AppError("VALIDATION_ERROR", message, 400); } static unauthorized() { return new _AppError("UNAUTHORIZED", "unauthorized", 401); } static forbidden(message = "forbidden") { return new _AppError("FORBIDDEN", message, 403); } static conflict(message) { return new _AppError("CONFLICT", message, 409); } static cloudflare(message) { return new _AppError("CLOUDFLARE_ERROR", message, 502); } static dnsUpdateFailed(message) { return new _AppError( "DNS_UPDATE_FAILED", message, 502 ); } static healthcheckCreateFailed(message) { return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502); } static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") { return new _AppError("ZONE_NOT_FOUND", message, 404); } static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") { return new _AppError("INVALID_IP", message, 400); } static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") { return new _AppError("INVALID_HOSTNAME", message, 400); } static rateLimited(message = "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 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") { return new _AppError("RATE_LIMITED", message, 429); } static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") { return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401); } static syncFailed(message) { return new _AppError("SYNC_FAILED", message, 502); } static internal(message) { return new _AppError("INTERNAL_ERROR", message, 500); } }; function toAppError(err) { if (err instanceof AppError) return err; if (err instanceof NotFoundError) return AppError.notFound(err.message); if (err instanceof ConflictError) return AppError.conflict(err.message); if (err instanceof ValidationError) return AppError.validation(err.message); if (err instanceof Error) return AppError.internal(err.message); return AppError.internal(String(err)); } function errorBody(err) { return { error: { code: err.code, message: err.message } }; } // src/lib/permissions.ts function hasPermission(granted, required) { if (granted.includes(required)) return true; const parts = required.split(":"); if (parts.length !== 3) return false; const [app2, section, action] = parts; if (action === "read") { return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`); } if (action === "write") { return granted.includes(`${app2}:${section}:admin`); } return false; } var RULES = [ { methods: ["GET"], match: (p) => p.startsWith("/api/v1/domains") || p.startsWith("/api/v1/domain-monitors") || p === "/api/v1/domain-monitors", permission: "cfdm:domains:read" }, { methods: ["POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/domains") || p.startsWith("/api/v1/domain-monitors"), permission: "cfdm:domains:write" }, { methods: ["GET"], match: (p) => p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"), permission: "cfdm:dns:read" }, { methods: ["POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"), permission: "cfdm:dns:write" }, { methods: ["GET"], match: (p) => p.startsWith("/api/v1/certificates"), permission: "cfdm:certificates:read" }, { methods: ["POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/certificates"), permission: "cfdm:certificates:write" }, { methods: ["GET"], match: (p) => p.startsWith("/api/v1/groups") || p.startsWith("/api/v1/service-groups"), permission: "cfdm:groups:read" }, { methods: ["POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/groups") || p.startsWith("/api/v1/service-groups"), permission: "cfdm:groups:write" }, { methods: ["GET"], match: (p) => p.startsWith("/api/v1/services") || p.startsWith("/api/v1/service-bindings") || p.startsWith("/api/v1/health-checks") || p === "/api/v1/ops-summary", permission: "cfdm:services:read" }, { methods: ["POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/services") || p.startsWith("/api/v1/service-bindings") || p.startsWith("/api/v1/health-checks"), permission: "cfdm:services:write" }, { methods: ["GET", "POST"], match: (p) => p.startsWith("/api/v1/sync"), permission: "cfdm:domains:write" }, { methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], match: (p) => p.startsWith("/api/v1/settings") || p.startsWith("/api/v1/notifications") || p.startsWith("/api/v1/health-check"), permission: "cfdm:settings:admin" }, { methods: ["GET"], match: (p) => p.startsWith("/api/v1/audit"), permission: "cfdm:settings:admin" } ]; function permissionForRequest(method, path) { const m = method.toUpperCase(); const pathname = path.split("?")[0] ?? path; for (const rule of RULES) { if (!rule.methods.includes(m)) continue; if (rule.match(pathname)) return rule.permission; } if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read"; return null; } // src/plugins/auth.ts async function authPlugin(app2, opts) { const { config: config2 } = opts; if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) { throw new Error( "AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true" ); } await app2.register(import("@fastify/jwt"), { secret: config2.jwtSecret, ...config2.authRequired ? { verify: { allowedIss: [config2.authIssuer] } } : {} }); if (config2.authRequired) { app2.log.info( { issuer: config2.authIssuer, portal: config2.authPortalUrl }, "AUTH_REQUIRED=true \u2014 portal JWT middleware enabled" ); } else { app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth"); } } async function requireAuth(request2, reply) { const config2 = request2.server.config; const authHeader = request2.headers.authorization ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; if (!token) throw AppError.unauthorized(); try { await request2.jwtVerify(); } catch { throw AppError.unauthorized(); } if (!config2.authRequired) { return; } const payload = request2.user; const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []; const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : []; if (!apps.includes("cfdm")) { throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E Cloudflare Domain Manager"); } request2.authUser = { id: String(payload.sub), email: String(payload.email ?? ""), name: String(payload.name ?? ""), apps, permissions, isAdmin: Boolean(payload.is_admin) }; const required = permissionForRequest(request2.method, request2.url); if (required && !hasPermission(permissions, required)) { throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`); } } var auth_default = fp(authPlugin, { name: "auth" }); // src/plugins/cf-client.ts import fp2 from "fastify-plugin"; // src/lib/cf-retry.ts 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 parseRetryAfter(headers) { const value = headers.get("retry-after"); if (!value) return null; const seconds = Number(value); return Number.isFinite(seconds) ? seconds * 1e3 : null; } // src/lib/cloudflare/zone-cache-events.ts var subscribers = /* @__PURE__ */ new Set(); function subscribeZoneCacheInvalidation(cb) { subscribers.add(cb); return () => subscribers.delete(cb); } function notifyZoneCacheInvalidated() { for (const cb of subscribers) { try { cb(); } catch { } } } // src/lib/cloudflare/http.ts var CF_API_BASE = "https://api.cloudflare.com/client/v4"; function mapCloudflareFailure(operation, status, message) { const lower = message.toLowerCase(); if (status === 401 || status === 403 || lower.includes("authentication")) { notifyZoneCacheInvalidated(); if (operation.includes("workers") || operation.includes("kv_") || operation.includes("accounts")) { return AppError.cloudflareAuthFailed( "\u0422\u043E\u043A\u0435\u043D\u0443 \u043D\u0443\u0436\u043D\u044B \u043F\u0440\u0430\u0432\u0430 Account: Workers Scripts Write \u0438 Workers KV Storage Write. Zone DNS \u043D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E." ); } 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")) { notifyZoneCacheInvalidated(); return AppError.rateLimited(); } if (lower.includes("zone") && (lower.includes("not found") || status === 404)) { return AppError.zoneNotFound(); } if (operation.includes("healthcheck") && (lower.includes("plan") || lower.includes("not entitled") || lower.includes("not allowed") || lower.includes("permission"))) { return AppError.healthcheckCreateFailed( "Cloudflare Health Checks \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B \u0434\u043B\u044F \u044D\u0442\u043E\u0439 \u0437\u043E\u043D\u044B. \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438." ); } 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; } async function handleCfSuccess(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 text = await response.text(); if (!text) { if (!response.ok) { throw mapCloudflareFailure(operation, response.status, String(response.status)); } return; } let body; try { body = JSON.parse(text); } catch { if (!response.ok) { throw mapCloudflareFailure(operation, response.status, text.slice(0, 180)); } return; } if (!body.success) { const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; throw mapCloudflareFailure(operation, response.status, msg); } } // src/lib/cloudflare/dns-service.ts function createDnsAdapter(token) { return { async listDnsRecords(zoneId, cache) { const cached = cache?.get(zoneId); if (cached) return cached; const all = await withRetry(async () => { const records = []; 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; records.push(...batch); page += 1; } return records; }); cache?.set(zoneId, all); 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/healthcheck-service.ts function createHealthCheckAdapter(token) { return { async listHealthChecks(zoneId) { const response = await fetch( `${CF_API_BASE}/zones/${zoneId}/healthchecks`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) } ); return handleCfResponse(response, "list_healthchecks"); }, async getHealthCheck(zoneId, id) { const response = await fetch( `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) } ); return handleCfResponse(response, "get_healthcheck"); }, async createHealthCheck(zoneId, payload) { const response = await fetch( `${CF_API_BASE}/zones/${zoneId}/healthchecks`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(3e4) } ); return handleCfResponse(response, "create_healthcheck"); }, async updateHealthCheck(zoneId, id, payload) { const response = await fetch( `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(3e4) } ); return handleCfResponse(response, "update_healthcheck"); }, async deleteHealthCheck(zoneId, id) { const response = await fetch( `${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) } ); await handleCfResponse(response, "delete_healthcheck"); } }; } // src/lib/cloudflare/kv-service.ts function createKvAdapter(token) { return { async listNamespaces(accountId) { const all = []; let page = 1; while (true) { const url = new URL( `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces` ); url.searchParams.set("per_page", "100"); url.searchParams.set("page", String(page)); const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) }); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("kv_list", response.status, String(response.status)); } const batch = await handleCfResponse(response, "kv_list"); all.push(...batch); if (batch.length < 100) break; page += 1; } return all; }, async createNamespace(accountId, title) { const response = await fetch( `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ title }), signal: AbortSignal.timeout(3e4) } ); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("kv_create", response.status, String(response.status)); } return handleCfResponse(response, "kv_create"); }, async getValue(accountId, namespaceId, key) { const response = await fetch( `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) } ); if (response.status === 404) return null; if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("kv_get", response.status, String(response.status)); } if (!response.ok) { const text = await response.text().catch(() => ""); throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180)); } return response.text(); }, async putValue(accountId, namespaceId, key, value) { const response = await fetch( `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "text/plain" }, body: value, signal: AbortSignal.timeout(3e4) } ); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("kv_put", response.status, String(response.status)); } await handleCfSuccess(response, "kv_put"); } }; } // src/lib/cloudflare/workers-service.ts function createWorkersAdapter(token) { return { async listAccounts() { const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) }); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("list_accounts", response.status, String(response.status)); } return handleCfResponse(response, "list_accounts"); }, async putScript(opts) { const filename = opts.filename ?? "index.mjs"; const metadata = { main_module: filename, compatibility_date: "2025-04-01", bindings: [ { type: "kv_namespace", name: "HEALTH_KV", namespace_id: opts.kvNamespaceId } ] }; const form = new FormData(); form.append( "metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }) ); form.append( filename, new Blob([opts.source], { type: "application/javascript+module" }), filename ); const response = await fetch( `${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`, { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: form, signal: AbortSignal.timeout(6e4) } ); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("workers_put_script", response.status, String(response.status)); } await handleCfSuccess(response, "workers_put_script"); }, async putSchedules(accountId, scriptName, crons) { const response = await fetch( `${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(crons.map((cron) => ({ cron }))), signal: AbortSignal.timeout(3e4) } ); if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status)); } await handleCfSuccess(response, "workers_put_schedules"); }, async enableWorkersDev(accountId, scriptName) { const response = await fetch( `${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ enabled: true }), signal: AbortSignal.timeout(3e4) } ); if (response.status === 409) return; if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status)); } if (!response.ok && response.status !== 200 && response.status !== 201) { await handleCfSuccess(response, "workers_subdomain"); } }, async getWorkersSubdomain(accountId) { const response = await fetch( `${CF_API_BASE}/accounts/${accountId}/workers/subdomain`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3e4) } ); if (response.status === 404) return null; if (response.status >= 500 || response.status === 429) { throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status)); } const result = await handleCfResponse( response, "workers_get_subdomain" ); return result.subdomain?.trim() || null; } }; } // src/lib/cloudflare/zone-service.ts var ZONE_CACHE_TTL_MS = 5 * 6e4; function createZoneAdapter(token) { let cachedAt = 0; let cachedZones = null; let inflight = null; async function fetchZones() { 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; } function invalidate() { cachedAt = 0; cachedZones = null; } subscribeZoneCacheInvalidation(invalidate); return { async listZones() { const now = Date.now(); if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) { return cachedZones; } if (!inflight) { inflight = withRetry(fetchZones).then((zones) => { cachedAt = Date.now(); cachedZones = zones; return zones; }).finally(() => { inflight = null; }); } return inflight; }, invalidateZonesCache: invalidate, 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; healthchecks; kv; workers; token; constructor(token) { this.token = token.trim(); this.zones = createZoneAdapter(token); this.dns = createDnsAdapter(token); this.healthchecks = createHealthCheckAdapter(token); this.kv = createKvAdapter(token); this.workers = createWorkersAdapter(token); } get isConfigured() { return this.token.length > 0; } listZones() { return this.zones.listZones(); } invalidateZonesCache() { this.zones.invalidateZonesCache(); } getZone(zoneId) { return this.zones.getZone(zoneId); } listDnsRecords(zoneId, cache) { return this.dns.listDnsRecords(zoneId, cache); } 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); } listHealthChecks(zoneId) { return this.healthchecks.listHealthChecks(zoneId); } getHealthCheck(zoneId, id) { return this.healthchecks.getHealthCheck(zoneId, id); } createHealthCheck(zoneId, payload) { return this.healthchecks.createHealthCheck(zoneId, payload); } updateHealthCheck(zoneId, id, payload) { return this.healthchecks.updateHealthCheck(zoneId, id, payload); } deleteHealthCheck(zoneId, id) { return this.healthchecks.deleteHealthCheck(zoneId, id); } listAccounts() { return this.workers.listAccounts(); } listKvNamespaces(accountId) { return this.kv.listNamespaces(accountId); } createKvNamespace(accountId, title) { return this.kv.createNamespace(accountId, title); } kvGet(accountId, namespaceId, key) { return this.kv.getValue(accountId, namespaceId, key); } kvPut(accountId, namespaceId, key, value) { return this.kv.putValue(accountId, namespaceId, key, value); } putWorkerScript(opts) { return this.workers.putScript(opts); } putWorkerSchedules(accountId, scriptName, crons) { return this.workers.putSchedules(accountId, scriptName, crons); } enableWorkersDev(accountId, scriptName) { return this.workers.enableWorkersDev(accountId, scriptName); } getWorkersSubdomain(accountId) { return this.workers.getWorkersSubdomain(accountId); } }; // src/plugins/cf-client.ts async function cfClientPlugin(app2, opts) { app2.decorate("config", opts.config); app2.decorate("cf", new CloudflareClient(opts.config.cloudflareApiToken)); } var cf_client_default = fp2(cfClientPlugin, { name: "cf-client" }); // src/plugins/cors.ts import fp3 from "fastify-plugin"; async function corsPlugin(app2) { await app2.register(import("@fastify/cors"), { origin: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"] }); } var cors_default = fp3(corsPlugin, { name: "cors" }); // src/plugins/db.ts import fp4 from "fastify-plugin"; import { createDb, createMemoryDb, healthCheck, runMigrations } from "@cfdm/db"; async function dbPlugin(app2, opts) { const { db, sqlite } = opts.memory ? createMemoryDb() : createDb(opts.config.databaseUrl); runMigrations(sqlite); app2.decorate("db", db); app2.decorate("sqlite", sqlite); app2.addHook("onClose", async () => { sqlite.close(); }); } var db_default = fp4(dbPlugin, { name: "db" }); // src/plugins/error-handler.ts import fp5 from "fastify-plugin"; async function errorHandlerPlugin(app2) { app2.setErrorHandler((err, _request, reply) => { if (reply.sent) return; const appErr = err.statusCode === 401 ? AppError.unauthorized() : toAppError(err); reply.status(appErr.statusCode).send(errorBody(appErr)); }); } var error_handler_default = fp5(errorHandlerPlugin, { name: "error-handler" }); // src/routes/health.ts import { z } from "zod"; // src/services/auth.ts import { verify } from "@node-rs/argon2"; async function verifyPassword(config2, password) { if (config2.adminPasswordHash === "devplaceholder") { if (password === "admin") return; throw AppError.unauthorized(); } const ok = await verify(config2.adminPasswordHash, password); if (!ok) throw AppError.unauthorized(); } async function login(config2, sign, req) { if (req.username !== config2.adminUsername) { throw AppError.unauthorized(); } await verifyPassword(config2, req.password); const expiresAt = new Date( Date.now() + config2.jwtTtlHours * 60 * 60 * 1e3 ); const token = sign({ sub: req.username, exp: Math.floor(expiresAt.getTime() / 1e3) }); return { token, expires_at: expiresAt.toISOString() }; } // src/routes/health.ts async function healthRoutes(app2) { app2.get("/health", async (request2, reply) => { healthCheck(request2.server.sqlite); return { status: "ok" }; }); app2.get("/ready", async (request2, reply) => { healthCheck(request2.server.sqlite); let cloudflare = false; if (request2.server.config.cloudflareApiToken) { try { await request2.server.cf.listZones(); cloudflare = true; } catch { cloudflare = false; } } return { status: cloudflare || !request2.server.config.cloudflareApiToken ? "ready" : "degraded", database: true, cloudflare }; }); } async function authRoutes(app2) { app2.get("/auth/config", async (request2) => { const { config: config2 } = request2.server; return { required: config2.authRequired, portal_url: config2.authPortalUrl }; }); const loginSchema = z.object({ username: z.string(), password: z.string() }); app2.post("/auth/login", async (request2, reply) => { if (request2.server.config.authRequired) { return reply.code(403).send({ error: { code: "FORBIDDEN", message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal" } }); } const body = loginSchema.parse(request2.body); const result = await login( request2.server.config, (payload) => request2.server.jwt.sign(payload), body ); return result; }); } // src/routes/groups.ts import { z as z2 } from "zod"; import { repos as repos2 } from "@cfdm/db"; // src/services/group-service.ts import { repos } from "@cfdm/db"; function listGroups(db) { return repos.listGroups(db); } function createGroup(db, name, slug) { return repos.createGroup(db, name, slug); } function updateGroup(db, id, name, slug) { return repos.updateGroup(db, id, name, slug); } function deleteGroup(db, id) { repos.deleteGroup(db, id); } function getGroupWithStats(db, id) { return repos.getGroupWithStats(db, id); } // src/lib/audit.ts import { randomUUID } from "crypto"; import { appendAudit } from "@cfdm/db"; // src/services/audit-portal-push.ts import { request } from "undici"; async function pushAuditEvents(portalUrl, secret, events) { const base = portalUrl.replace(/\/$/, ""); const res = await request(`${base}/api/v1/ingest/audit`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${secret}` }, body: JSON.stringify({ events }) }); if (res.statusCode >= 400) { const body = await res.body.text(); throw new Error(`portal audit ingest ${res.statusCode}: ${body}`); } } // src/lib/audit.ts function clientIp(request2) { const forwarded = request2.headers["x-forwarded-for"]; if (typeof forwarded === "string" && forwarded.trim()) { return forwarded.split(",")[0]?.trim() ?? null; } return request2.ip ?? null; } function actorFromRequest(request2) { const u = request2.authUser; if (u) { return { actorUserId: u.id, actorEmail: u.email || null, actorName: u.name || null }; } const payload = request2.user; if (payload?.sub) { return { actorUserId: String(payload.sub), actorEmail: payload.email ? String(payload.email) : null, actorName: payload.name ? String(payload.name) : null }; } return { actorUserId: null, actorEmail: null, actorName: null }; } function recordAudit(app2, request2, input) { const eventId = randomUUID(); const actor = actorFromRequest(request2); const ip = clientIp(request2); const createdAt = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(); const full = { ...input, ...actor, eventId, sourceApp: "cfdm", ip, createdAt }; try { appendAudit(app2.db, full); } catch (err) { app2.log.warn({ err }, "audit_log append failed"); } const secret = app2.config.authAuditIngestSecret; const portalUrl = app2.config.authPortalUrl; if (!secret || !portalUrl) return; void pushAuditEvents(portalUrl, secret, [ { event_id: eventId, source_app: "cfdm", action: input.action, severity: input.severity, actor_user_id: actor.actorUserId, actor_email: actor.actorEmail, actor_name: actor.actorName, target_type: input.targetType, target_id: input.targetId, summary: input.summary, details: input.details, ip, created_at: createdAt } ]).catch((err) => { app2.log.warn({ err, eventId }, "audit portal push failed"); }); } // src/routes/groups.ts async function groupRoutes(app2) { const bodySchema = z2.object({ name: z2.string(), slug: z2.string() }); app2.get("/groups", async (request2) => { return listGroups(request2.server.db); }); app2.post("/groups", async (request2) => { const body = bodySchema.parse(request2.body); const group = createGroup(request2.server.db, body.name, body.slug); recordAudit(request2.server, request2, { action: "group.create", targetType: "app_resource", targetId: String(group.id), summary: `\u0421\u043E\u0437\u0434\u0430\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`, details: { name: group.name, slug: group.slug } }); return group; }); app2.get("/groups/:id", async (request2) => { const { id } = request2.params; return getGroupWithStats(request2.server.db, Number(id)); }); app2.patch("/groups/:id", async (request2) => { const { id } = request2.params; const body = bodySchema.parse(request2.body); const group = updateGroup( request2.server.db, Number(id), body.name, body.slug ); recordAudit(request2.server, request2, { action: "group.update", targetType: "app_resource", targetId: String(group.id), summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`, details: { name: group.name, slug: group.slug } }); return group; }); app2.delete("/groups/:id", async (request2) => { const { id } = request2.params; const group = repos2.getGroup(request2.server.db, Number(id)); deleteGroup(request2.server.db, Number(id)); recordAudit(request2.server, request2, { action: "group.delete", severity: "warning", targetType: "app_resource", targetId: String(id), summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`, details: { name: group.name, slug: group.slug } }); return { deleted: true }; }); } // src/routes/services.ts import { z as z3 } from "zod"; import { changeDomainSchema, createServiceNodeSchema, reorderServicesSchema, toggleServiceIpSchema, updateServiceConfigSchema, updateServiceNodeSchema } from "@cfdm/shared"; import { repos as repos13 } from "@cfdm/db"; // src/services/service-config-service.ts import { repos as repos9 } from "@cfdm/db"; import { SYNC_ERROR as SYNC_ERROR2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, SYNC_SYNCED as SYNC_SYNCED3, dnsRecordNamesMatch as dnsRecordNamesMatch2, isIpLiteral as isIpLiteral2, normalizeDnsRecordName as normalizeDnsRecordName2 } from "@cfdm/shared"; // src/lib/validators.ts import { validateDnsRecord, certStatusFromExpiry, isValidIpv4, ValidationError as ValidationError2 } from "@cfdm/shared"; // src/services/dns-service.ts import { repos as repos3 } from "@cfdm/db"; import { SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_PUSH, SYNC_SYNCED, normalizeDnsRecordName } from "@cfdm/shared"; function toCfPayload(recordType, name, content, ttl, proxied, priority) { return { type: recordType.toUpperCase(), name, content, ttl, proxied, priority: priority ?? void 0 }; } async function pushRecord(db, cf, domainId, cfZoneId, record) { const payload = toCfPayload( record.record_type, record.name, record.content, record.ttl, record.proxied, record.priority ); try { const cfRec = record.cf_record_id ? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload) : await cf.createDnsRecord(cfZoneId, payload); repos3.updateDnsFields( db, record.id, cfRec.type ?? record.record_type, cfRec.name, cfRec.content, cfRec.ttl, cfRec.proxied ?? false, cfRec.priority ?? null, SYNC_SYNCED, cfRec.id ?? null, null ); return repos3.getDnsRecord(db, domainId, record.id); } catch (e) { repos3.setDnsSyncStatus( db, record.id, SYNC_ERROR, record.cf_record_id, e instanceof Error ? e.message : String(e) ); throw e; } } async function create(db, cf, domainId, req) { const domain = repos3.getDomain(db, domainId); const ttl = req.ttl ?? 1; const proxied = req.proxied ?? false; const name = normalizeDnsRecordName(req.name, domain.zone_name); validateDnsRecord(req.record_type, name, req.content, ttl, proxied); const record = repos3.insertDnsRecord( db, domainId, req.record_type, name, req.content, ttl, proxied, req.priority ?? null, SYNC_PENDING_PUSH, "local", null ); return pushRecord(db, cf, domainId, domain.cf_zone_id, record); } async function update(db, cf, domainId, recordId, req) { const domain = repos3.getDomain(db, domainId); const existing = repos3.getDnsRecord(db, domainId, recordId); const recordType = req.record_type ?? existing.record_type; const name = normalizeDnsRecordName( req.name ?? existing.name, domain.zone_name ); const content = req.content ?? existing.content; const ttl = req.ttl ?? existing.ttl; const proxied = req.proxied ?? existing.proxied; const priority = req.priority ?? existing.priority; validateDnsRecord(recordType, name, content, ttl, proxied); repos3.updateDnsFields( db, recordId, recordType, name, content, ttl, proxied, priority, SYNC_PENDING_PUSH, existing.cf_record_id, null ); const updated = repos3.getDnsRecord(db, domainId, recordId); return pushRecord(db, cf, domainId, domain.cf_zone_id, updated); } async function deleteRecord(db, cf, domainId, recordId) { const domain = repos3.getDomain(db, domainId); const record = repos3.getDnsRecord(db, domainId, recordId); repos3.markDnsPendingDelete(db, recordId); if (record.cf_record_id) { try { await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id); } catch (e) { repos3.setDnsSyncStatus( db, recordId, SYNC_ERROR, record.cf_record_id, e instanceof Error ? e.message : String(e) ); throw e; } } repos3.deleteDnsRecord(db, recordId); } function list(db, domainId, filter) { repos3.getDomain(db, domainId); return repos3.listDnsRecords(db, domainId, filter); } function get(db, domainId, recordId) { return repos3.getDnsRecord(db, domainId, recordId); } async function bulk(db, cf, domainId, ops) { const results = []; for (const op of ops) { try { if (op.action === "create") { if (!op.record) throw AppError.validation("record required"); const r = await create(db, cf, domainId, op.record); results.push({ id: r.id, success: true }); } else if (op.action === "update") { if (op.id == null) throw AppError.validation("id required"); if (!op.record) throw AppError.validation("record required"); await update(db, cf, domainId, op.id, { record_type: op.record.record_type, name: op.record.name, content: op.record.content, ttl: op.record.ttl, proxied: op.record.proxied, priority: op.record.priority }); results.push({ id: op.id, success: true }); } else if (op.action === "delete") { if (op.id == null) throw AppError.validation("id required"); await deleteRecord(db, cf, domainId, op.id); results.push({ id: op.id, success: true }); } else { results.push({ id: op.id, success: false, error: `unknown action: ${op.action}` }); } } catch (e) { results.push({ id: op.id, success: false, error: e instanceof Error ? e.message : String(e) }); } } return results; } async function resolveConflict(db, cf, domainId, recordId, req) { const domain = repos3.getDomain(db, domainId); const record = repos3.getDnsRecord(db, domainId, recordId); if (record.sync_status !== SYNC_CONFLICT) { throw AppError.validation("record is not in conflict state"); } if (req.source === "cloudflare") { if (record.cf_record_id) { const remote = await cf.listDnsRecords(domain.cf_zone_id); const r = remote.find((x) => x.id === record.cf_record_id); if (r) { repos3.updateDnsFields( db, recordId, r.type, r.name, r.content, r.ttl, r.proxied ?? false, r.priority ?? null, SYNC_SYNCED, r.id ?? null, null ); } } return repos3.getDnsRecord(db, domainId, recordId); } if (req.source === "local") { const updated = repos3.getDnsRecord(db, domainId, recordId); return pushRecord(db, cf, domainId, domain.cf_zone_id, updated); } throw AppError.validation("source must be cloudflare or local"); } // src/services/domain-service.ts import { repos as repos6 } from "@cfdm/db"; // src/services/binding-service.ts import { repos as repos4 } from "@cfdm/db"; function normalizeHostname(hostname) { const h = hostname?.trim(); return h ? h : "@"; } async function syncTargetIp(db, cf, domainId, bindingId, hostname, dnsRecordId, targetIp) { if (dnsRecordId) { await update(db, cf, domainId, dnsRecordId, { record_type: "A", name: hostname, content: targetIp, proxied: false }); return dnsRecordId; } const record = await create(db, cf, domainId, { record_type: "A", name: hostname, content: targetIp, ttl: 1, proxied: false }); repos4.setBindingDnsRecordId(db, bindingId, record.id); return record.id; } function listAll(db) { return repos4.listAllBindings(db); } function listByDomain(db, domainId) { repos4.getDomain(db, domainId); return repos4.listBindingsByDomain(db, domainId); } async function create2(db, cf, req) { repos4.getDomain(db, req.domain_id); repos4.getService(db, req.service_id); const hostname = normalizeHostname(req.hostname); const binding = repos4.insertBinding( db, req.domain_id, req.service_id, hostname, null ); const ip = req.target_ip?.trim(); if (ip) { await syncTargetIp(db, cf, req.domain_id, binding.id, hostname, null, ip); } return repos4.getBindingView(db, binding.id); } async function update2(db, cf, id, req) { const existing = repos4.getBinding(db, id); if (req.cert_monitoring !== void 0) { repos4.updateBindingLbConfig(db, id, { cert_monitoring: req.cert_monitoring }); } const hasIdentityPatch = req.service_id !== void 0 || req.hostname !== void 0 || req.target_ip !== void 0; if (!hasIdentityPatch) { return repos4.getBindingView(db, id); } const serviceId = req.service_id ?? existing.service_id; if (req.service_id) repos4.getService(db, req.service_id); const hostname = req.hostname ? normalizeHostname(req.hostname) : existing.hostname; repos4.updateBindingFields( db, id, serviceId, hostname, existing.dns_record_id ); const ip = req.target_ip?.trim(); if (ip) { await syncTargetIp( db, cf, existing.domain_id, id, hostname, existing.dns_record_id, ip ); } return repos4.getBindingView(db, id); } function remove(db, id) { repos4.getBinding(db, id); repos4.deleteBinding(db, id); } async function setDomainServices(db, domainId, serviceIds) { repos4.getDomain(db, domainId); for (const sid of serviceIds) { repos4.getService(db, sid); } const existing = repos4.listBindingsByDomain(db, domainId); for (const binding of existing) { if (!serviceIds.includes(binding.service_id)) { repos4.deleteBinding(db, binding.id); } } for (const sid of serviceIds) { const already = existing.some((b) => b.service_id === sid); if (!already) { repos4.insertBinding(db, domainId, sid, "@", null); } } return repos4.listBindingsByDomain(db, domainId).map((b) => b.service_id); } // src/services/sync-service.ts import { repos as repos5 } from "@cfdm/db"; import { SYNC_CONFLICT as SYNC_CONFLICT2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH2, SYNC_SYNCED as SYNC_SYNCED2, dnsNameToSubdomainLabel, dnsRecordNamesMatch, subdomainLabelToFqdn } from "@cfdm/shared"; import { randomUUID as randomUUID2 } from "crypto"; function findLocalByRemote(local, cfRec, zoneName) { return local.find( (record) => record.record_type.toUpperCase() === cfRec.type.toUpperCase() && dnsRecordNamesMatch(record.name, cfRec.name, zoneName) ) ?? null; } function dnsRecordsEquivalent(existing, cfRec, zoneName) { const proxied = cfRec.proxied ?? false; return existing.content === cfRec.content && existing.ttl === cfRec.ttl && existing.proxied === proxied && dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) && existing.record_type.toUpperCase() === cfRec.type.toUpperCase(); } function applyRemoteRecord(db, domainId, cfRec, existing, zoneName) { const cfId = cfRec.id; if (!cfId) return false; const proxied = cfRec.proxied ?? false; const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName); if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH2) { repos5.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT2, cfId, null); return true; } if (!equivalent) return false; if (existing.name !== cfRec.name || existing.sync_status !== SYNC_SYNCED2 || existing.cf_record_id !== cfId || existing.content !== cfRec.content || existing.ttl !== cfRec.ttl || existing.proxied !== proxied) { repos5.updateDnsFields( db, existing.id, cfRec.type, cfRec.name, cfRec.content, cfRec.ttl, proxied, cfRec.priority ?? null, SYNC_SYNCED2, cfId, null ); return true; } return false; } async function pullSync(db, cf, domain) { const remote = await cf.listDnsRecords(domain.cf_zone_id); const local = repos5.listDnsByDomain(db, domain.id); let changed = 0; const remoteIds = new Set( remote.map((r) => r.id).filter((id) => Boolean(id)) ); for (const cfRec of remote) { const cfId = cfRec.id; if (!cfId) continue; let existing = repos5.findDnsByCfId(db, domain.id, cfId); if (!existing) { existing = findLocalByRemote(local, cfRec, domain.zone_name); } if (existing) { if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) { changed += 1; } } else { repos5.insertDnsRecord( db, domain.id, cfRec.type, cfRec.name, cfRec.content, cfRec.ttl, cfRec.proxied ?? false, cfRec.priority ?? null, SYNC_SYNCED2, "cloudflare", cfId ); changed += 1; } } const refreshedLocal = repos5.listDnsByDomain(db, domain.id); for (const rec of refreshedLocal) { if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) { if (rec.sync_status !== "pending_delete") { repos5.setDnsSyncStatus( db, rec.id, SYNC_CONFLICT2, rec.cf_record_id, "missing in cloudflare" ); changed += 1; } continue; } if (rec.sync_status === SYNC_PENDING_PUSH2) continue; const remoteSameType = remote.find( (r) => r.id && dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) && r.type.toUpperCase() === rec.record_type.toUpperCase() ); if (remoteSameType?.id) { if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) { changed += 1; } continue; } const remoteSameHost = remote.find( (r) => dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) ); if (remoteSameHost && remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()) { repos5.setDnsSyncStatus( db, rec.id, SYNC_CONFLICT2, rec.cf_record_id, "type mismatch with cloudflare" ); changed += 1; } } const labels = /* @__PURE__ */ new Set(); for (const rec of remote) { const label = dnsNameToSubdomainLabel(rec.name, domain.zone_name); if (label) labels.add(label); } for (const label of labels) { const fqdn = subdomainLabelToFqdn(label, domain.zone_name); repos5.upsertSubdomain(db, domain.id, label, fqdn); changed += 1; } repos5.setDomainLastSynced(db, domain.id); return changed; } async function syncDomain(db, cf, domainId) { const jobId = randomUUID2(); repos5.createSyncJob(db, jobId, domainId); const domain = repos5.getDomain(db, domainId); try { const changes = await pullSync(db, cf, domain); repos5.finishSyncJob(db, jobId, "completed", `${changes} changes`); return { jobId, changes }; } catch (e) { repos5.finishSyncJob( db, jobId, "failed", e instanceof Error ? e.message : String(e) ); throw e; } } async function syncAll(db, cf) { const jobId = randomUUID2(); repos5.createSyncJob(db, jobId, null); const all = repos5.listAllDomains(db); let total = 0; for (const domain of all) { try { total += await pullSync(db, cf, domain); } catch { } } repos5.finishSyncJob(db, jobId, "completed", `${total} total changes`); return jobId; } function getJob(db, jobId) { return repos5.getSyncJob(db, jobId); } // src/services/domain-service.ts function listDomains(db, groupId) { return repos6.listDomainsEnriched(db, groupId); } function getDomain(db, id) { return repos6.getDomain(db, id); } async function createDomain(db, cf, groupId, zoneName) { const trimmed = zoneName.trim(); const zones = await cf.listZones(); if (zones.length === 0) { throw AppError.notFound( "\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read" ); } const zone = zones.find((z9) => z9.name.toLowerCase() === trimmed.toLowerCase()); if (!zone) { const names = zones.map((z9) => z9.name).join(", "); throw AppError.notFound( `\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}` ); } return repos6.createDomain(db, groupId, zone.name, zone.id); } function updateDomain(db, id, patch) { const domain = repos6.updateDomain(db, id, { group_id: patch.group_id, status: patch.status, cert_monitoring: patch.cert_monitoring, environment: patch.environment }); if (patch.tags !== void 0) { repos6.setDomainTags(db, id, patch.tags); } return domain; } function bulkUpdateDomains(db, ids, patch) { let updated = 0; for (const id of ids) { try { repos6.updateDomain(db, id, { group_id: patch.group_id, environment: patch.environment }); if (patch.tags_add?.length) { repos6.addDomainTags(db, id, patch.tags_add); } updated += 1; } catch { } } return updated; } function deleteDomain(db, id) { repos6.deleteDomain(db, id); } async function setDomainServices2(db, domainId, serviceIds) { return setDomainServices(db, domainId, serviceIds); } async function importZoneRecords(db, cf, domainId) { const domain = repos6.getDomain(db, domainId); return pullSync(db, cf, domain); } // src/services/vps-tracker-sync.ts import { resolve4 } from "dns/promises"; import { isIpLiteral } from "@cfdm/shared"; import { repos as repos7, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db"; // src/services/routing/pool.ts function uniqueIpCount(ips) { return new Set(ips.filter(Boolean)).size; } function isSharedPool(ips) { return uniqueIpCount(ips) >= 2; } function canApplyLb(serviceIps, bindingIps) { return isSharedPool(serviceIps) && isSharedPool(bindingIps); } function shouldRecordFailoverDnsDiff(input) { if (!isSharedPool(input.configuredIps)) return false; if (input.lbMode !== "weighted") return true; return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip)); } // src/services/vps-tracker-sync.ts function isLbMode(value) { return value === "round_robin" || value === "failover" || value === "weighted"; } function resolveLbModeForSync(bindingLbMode, groupLbMode) { if (isLbMode(bindingLbMode)) return bindingLbMode; if (isLbMode(groupLbMode)) return groupLbMode; return void 0; } function effectiveLbModeForSync(bindingLbMode, groupLbMode, serviceIps) { if (!isSharedPool(serviceIps)) return void 0; return resolveLbModeForSync(bindingLbMode, groupLbMode); } function groupLbModeForService(db, serviceId, cache) { if (cache.has(serviceId)) return cache.get(serviceId); let mode; try { const service = repos7.getService(db, serviceId); if (service.service_group_id != null) { const group = repos7.getServiceGroup(db, service.service_group_id); mode = resolveLbModeForSync(void 0, group.lb_mode); } } catch { mode = void 0; } cache.set(serviceId, mode); return mode; } function fqdnToDisplay(hostname, zoneName) { if (hostname === "@" || !hostname.trim()) return zoneName; return `${hostname}.${zoneName}`; } function normalizeCnameHost(target, zoneName) { const trimmed = target.trim().toLowerCase().replace(/\.$/, ""); if (!trimmed) return ""; if (trimmed.includes(".")) return trimmed; return `${trimmed}.${zoneName.toLowerCase()}`; } function buildBindingIndex(bindings) { const byFqdn = /* @__PURE__ */ new Map(); for (const b of bindings) { const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase(); byFqdn.set(fqdn, b); } return { byFqdn }; } function resolveIpsLocally(index, startFqdn, depth = 0, seen = /* @__PURE__ */ new Set()) { const key = startFqdn.toLowerCase().replace(/\.$/, ""); if (!key || depth > 8 || seen.has(key)) return []; seen.add(key); const binding = index.byFqdn.get(key); if (!binding) return []; const ips = (binding.target_ips ?? []).filter(isIpLiteral); if (ips.length > 0) { return ips; } const cname = binding.cname_target?.trim(); if (!cname) return []; const next = normalizeCnameHost(cname, binding.zone_name); return resolveIpsLocally(index, next, depth + 1, seen); } async function resolveIpsViaDns(hostname) { const host = hostname.trim().toLowerCase().replace(/\.$/, ""); if (!host) return []; try { return await resolve4(host); } catch { return []; } } async function resolveBindingIpsForSync(binding, serviceIps, index, db) { const directIps = (binding.target_ips ?? []).filter(isIpLiteral); if (directIps.length > 0) { return [...directIps]; } const cname = binding.cname_target?.trim(); if (cname) { const targetFqdn = normalizeCnameHost(cname, binding.zone_name); const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral); if (local.length > 0) return local; if (db) { const fromTable = repos7.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral); if (fromTable.length > 0) return fromTable; } } const fromService = serviceIps.filter(isIpLiteral); if (fromService.length > 0) return [...fromService]; if (cname) { const targetFqdn = normalizeCnameHost(cname, binding.zone_name); const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral); if (viaDns.length > 0) return viaDns; } return []; } function cnameTargetForSync(binding) { const raw = binding.cname_target?.trim(); if (!raw) return void 0; const normalized = normalizeCnameHost(raw, binding.zone_name); return normalized || void 0; } async function buildServiceSyncBindingsAsync(db, serviceId, deletedBindingIds = []) { const service = repos7.getService(db, serviceId); const serviceIps = repos7.listServiceIps(db, serviceId); const allBindings = repos7.listAllBindings(db); const index = buildBindingIndex(allBindings); const bindings = allBindings.filter((row) => row.service_id === serviceId); const groupLbCache = /* @__PURE__ */ new Map(); const groupLb = groupLbModeForService(db, serviceId, groupLbCache); const items = []; for (const binding of bindings) { const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db); const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps); items.push({ bindingId: binding.id, serviceId: service.id, serviceName: service.name, serviceSlug: service.slug, fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), zoneName: binding.zone_name, hostname: binding.hostname, ips, cnameTarget: cnameTargetForSync(binding), ...lbMode ? { lbMode } : {} }); } for (const bindingId of deletedBindingIds) { items.push({ bindingId, serviceId: service.id, serviceName: service.name, serviceSlug: service.slug, fqdn: "", zoneName: "", hostname: "", ips: [], deleted: true }); } return items; } async function buildAllSyncBindings(db) { const bindings = repos7.listAllBindings(db); const index = buildBindingIndex(bindings); const serviceIpCache = /* @__PURE__ */ new Map(); const groupLbCache = /* @__PURE__ */ new Map(); const items = []; for (const binding of bindings) { let serviceIps = serviceIpCache.get(binding.service_id); if (!serviceIps) { serviceIps = repos7.listServiceIps(db, binding.service_id); serviceIpCache.set(binding.service_id, serviceIps); } const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db); const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache); const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps); items.push({ bindingId: binding.id, serviceId: binding.service_id, serviceName: binding.service_name, serviceSlug: binding.service_slug, fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), zoneName: binding.zone_name, hostname: binding.hostname, ips, cnameTarget: cnameTargetForSync(binding), ...lbMode ? { lbMode } : {} }); } return items; } async function syncServiceToVpsTracker(db, serviceId, deletedBindingIds = []) { const config2 = getAppSettingsSecrets(db); if (!config2.vpsTrackerSyncEnabled) return; const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, ""); const token = config2.vpsTrackerIntegrationToken; if (!baseUrl || !token) return; const bindings = await buildServiceSyncBindingsAsync( db, serviceId, deletedBindingIds ); if (bindings.length === 0) return; try { const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ bindings }) }); if (res.ok) { touchVpsTrackerSync(db); } else { console.warn( `VPS Tracker sync failed (${res.status}): ${await res.text()}` ); } } catch (err) { console.warn( "VPS Tracker sync error:", err instanceof Error ? err.message : err ); } } async function pingVpsTracker(db) { const config2 = getAppSettingsSecrets(db); const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, ""); const token = config2.vpsTrackerIntegrationToken; if (!baseUrl) return { ok: false, error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 URL VPS Tracker" }; if (!token) return { ok: false, error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 integration token" }; try { const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) { return { ok: false, error: `HTTP ${res.status}: ${await res.text()}` }; } return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438" }; } } // src/services/health/health-worker-deploy.ts import { getAppSettings, repos as repos8, updateAppSettings } from "@cfdm/db"; import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider as targetHasProvider2 } from "@cfdm/shared"; // src/services/health/health-probe-script.ts import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; function loadHealthProbeWorkerSource() { const dir = dirname(fileURLToPath(import.meta.url)); const candidates = [ join(dir, "health-probe-worker.mjs"), join(process.cwd(), "dist/health-probe-worker.mjs"), join(process.cwd(), "health-probe-worker.mjs"), join(dir, "../../../../../workers/health-probe/src/index.mjs"), join(process.cwd(), "../../workers/health-probe/src/index.mjs"), join(process.cwd(), "workers/health-probe/src/index.mjs") ]; for (const path of candidates) { if (existsSync(path)) { return readFileSync(path, "utf8"); } } throw new Error("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u0438\u0441\u0445\u043E\u0434\u043D\u0438\u043A Worker health-probe"); } // src/services/health/mailbox.ts import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared"; function createCloudflareKvMailbox(cf, accountId, namespaceId) { return { async getTargets() { return readJson(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY); }, async putTargets(doc) { await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc)); }, async getResults() { return readJson(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY); } }; } async function readJson(cf, accountId, namespaceId, key) { const raw = await cf.kvGet(accountId, namespaceId, key); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } function originProbeKey(target) { const port = target.port ?? (target.type === "http" ? 80 : 80); const ip = String(target.ip || "").trim().toLowerCase(); if (target.type === "http") { const path = target.path?.trim() || "/"; const expected = target.expected_status ?? ""; return `http|${ip}|${port}|${path}|${expected}`; } if (target.type === "tcp") return `tcp|${ip}|${port}`; if (target.type === "ping") { return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`; } if (target.type === "dns") { return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`; } return `${target.type}|${ip}|${port}`; } function cloudflareMailboxTargets(targets) { const unique = /* @__PURE__ */ new Map(); for (const target of targets) { if (!targetHasProvider(target, "cloudflare")) continue; if (target.type !== "tcp" && target.type !== "http") continue; const key = originProbeKey(target); if (unique.has(key)) continue; unique.set(key, { key, ip: target.ip, hostname: target.hostname || target.ip, type: target.type, port: target.port ?? (target.type === "http" ? 80 : 80), path: target.path ?? "/", expectedStatus: target.expected_status, timeoutMs: target.timeout_ms ?? 3e3, verifyTls: Boolean(target.verify_tls) }); } return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key)); } function fingerprintTargets(items) { return items.map( (item) => `${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}` ).join(";"); } function buildTargetsDoc(targets) { const items = cloudflareMailboxTargets(targets); return { fingerprint: fingerprintTargets(items), updatedAt: (/* @__PURE__ */ new Date()).toISOString(), items }; } function indexResults(doc) { const map = /* @__PURE__ */ new Map(); if (!doc?.items) return map; for (const item of doc.items) { map.set(item.key, item); } return map; } function isResultsStale(doc, staleAfterMs) { if (!doc?.probedAt) return true; const ts = Date.parse(doc.probedAt); if (!Number.isFinite(ts)) return true; return Date.now() - ts > staleAfterMs; } function toCloudflareCron(expr) { const parts = expr.trim().split(/\s+/).filter(Boolean); if (parts.length === 6) return parts.slice(1).join(" "); if (parts.length === 5) return parts.join(" "); throw new Error("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435"); } function cronStaleAfterMs(expr) { const cf = toCloudflareCron(expr); const minute = cf.split(/\s+/)[0] ?? "*"; if (minute.startsWith("*/")) { const n = Number(minute.slice(2)); if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 6e4; } if (minute === "*") return 10 * 6e4; return 10 * 6e4; } // src/services/health/health-worker-deploy.ts var DEFAULT_HEALTH_FALLBACKS = { healthCheckCron: "0 */2 * * * *", healthDegradedFailures: 1, healthDownFailures: 2, healthLatencyWarnMs: 1e3, healthSuccessRecoveries: 2, healthWorkerUrl: "", healthWorkerTokenSet: false }; async function resolveAccountId(cf, db, cached) { const trimmed = cached?.trim(); if (trimmed) return trimmed; const domains = repos8.listDomains(db); for (const domain of domains) { if (!domain.cf_zone_id) continue; try { const zone = await cf.getZone(domain.cf_zone_id); const id2 = zone.account?.id?.trim(); if (id2) return id2; } catch { } } const accounts = await cf.listAccounts(); const id = accounts[0]?.id?.trim(); if (!id) { throw AppError.cloudflare( "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C Cloudflare account_id. \u0414\u043E\u0431\u0430\u0432\u044C\u0442\u0435 \u0437\u043E\u043D\u0443 \u0438\u043B\u0438 \u0440\u0430\u0441\u0448\u0438\u0440\u044C\u0442\u0435 \u043F\u0440\u0430\u0432\u0430 \u0442\u043E\u043A\u0435\u043D\u0430 (Account Settings Read)." ); } return id; } async function ensureKvNamespace(cf, accountId, existingId) { if (existingId?.trim()) return existingId.trim(); const listed = await cf.listKvNamespaces(accountId); const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE); if (found?.id) return found.id; const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE); if (!created.id) { throw AppError.cloudflare("Cloudflare \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B id KV namespace"); } return created.id; } async function ensureHealthWorker(db, cf, fallbacks) { const settings = getAppSettings(db, fallbacks); try { const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId); const kvNamespaceId = await ensureKvNamespace( cf, accountId, settings.healthWorkerKvNamespaceId ); const source = loadHealthProbeWorkerSource(); await cf.putWorkerScript({ accountId, scriptName: HEALTH_PROBE_SCRIPT_NAME, source, kvNamespaceId }); await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [ toCloudflareCron(settings.healthCheckCron) ]); try { await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME); } catch { } const subdomain = await cf.getWorkersSubdomain(accountId); const url = subdomain ? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev` : settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`; updateAppSettings( db, { healthWorkerAccountId: accountId, healthWorkerKvNamespaceId: kvNamespaceId, healthWorkerUrl: url, healthWorkerError: null, healthWorkerDeployedAt: (/* @__PURE__ */ new Date()).toISOString() }, fallbacks ); await syncCloudflareTargetsToKv(db, cf, fallbacks); return { url, kvNamespaceId, accountId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); updateAppSettings(db, { healthWorkerError: message }, fallbacks); throw err; } } async function maybeEnsureHealthWorker(db, cf, fallbacks) { const hasCloudflare = repos8.listHealthCheckTargets(db).some((target) => targetHasProvider2(target, "cloudflare")); if (!hasCloudflare) return; const settings = getAppSettings(db, fallbacks); if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) { await syncCloudflareTargetsToKv(db, cf, fallbacks); return; } await ensureHealthWorker(db, cf, fallbacks); } function mailboxFromSettings(db, cf, fallbacks) { const settings = getAppSettings(db, fallbacks); const accountId = settings.healthWorkerAccountId.trim(); const ns = settings.healthWorkerKvNamespaceId.trim(); if (!accountId || !ns) return null; return createCloudflareKvMailbox(cf, accountId, ns); } async function syncCloudflareTargetsToKv(db, cf, fallbacks, mailbox) { const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks); if (!box) return; const next = buildTargetsDoc(repos8.listHealthCheckTargets(db)); const current = await box.getTargets(); if (current?.fingerprint === next.fingerprint) return; await box.putTargets(next); } function fireEnsureHealthWorker(db, cf, fallbacks, log) { if (process.env.VITEST) return; if (!cf.isConfigured) return; const hasCloudflare = repos8.listHealthCheckTargets(db).some((target) => targetHasProvider2(target, "cloudflare")); if (!hasCloudflare) { void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => { log?.warn({ err }, "health worker KV sync failed"); }); return; } void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => { log?.warn({ err }, "health worker ensure failed"); }); } // src/services/routing/health.ts function isDown(state) { return state === "down" || state === "unhealthy"; } function isPoolMember(state) { return !isDown(state); } // src/services/routing/failover.ts function failoverDesired(rows) { if (rows.length === 0) return []; const live = rows.filter((r) => isPoolMember(r.health)); const pool = live.length > 0 ? live : rows; const sorted = [...pool].sort( (a, b) => a.priority - b.priority || a.weight - b.weight ); const minPriority = sorted[0].priority; const primaries = sorted.filter((r) => r.priority === minPriority); if (live.length > 0) { return primaries.map((r) => r.ip); } return [sorted[0].ip]; } // src/services/routing/round-robin.ts function roundRobinDesired(rows) { const live = rows.filter((r) => isPoolMember(r.health)); const pool = live.length > 0 ? live : rows; return pool.map((r) => r.ip); } // src/services/routing/weighted.ts var WEIGHTED_SLOT_MS = 6e4; var WEIGHTED_DNS_TTL = 60; function weightedDesired(rows, nowMs = Date.now()) { if (rows.length === 0) return []; const live = rows.filter((r) => isPoolMember(r.health)); const pool = live.length > 0 ? live : rows; if (pool.length === 1) return [pool[0].ip]; const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip)); const cycle = []; for (const row of sorted) { const weight = Math.max(1, Math.round(row.weight)); for (let i = 0; i < weight; i++) cycle.push(row.ip); } const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length; return [cycle[slot]]; } // src/services/routing/binding-lock.ts var locks = /* @__PURE__ */ new Map(); async function withBindingLock(bindingId, fn) { const previous = locks.get(bindingId) ?? Promise.resolve(); let release; const current = new Promise((resolve5) => { release = resolve5; }); locks.set( bindingId, previous.then(() => current).catch(() => current) ); await previous.catch(() => void 0); try { return await fn(); } finally { release(); if (locks.get(bindingId) === current) { locks.delete(bindingId); } } } // src/services/routing/index.ts function selectActiveIpsByMode(config2, rows, nowMs = Date.now()) { if (rows.length === 0) return []; if (config2.lb_mode === "failover") { return failoverDesired(rows); } if (config2.lb_mode === "weighted") { return weightedDesired(rows, nowMs); } return roundRobinDesired(rows); } function resolveDesiredAIps(config2, rows, fallbackIps, nowMs = Date.now(), serviceIps = fallbackIps) { const fallback = [...fallbackIps]; if (!canApplyLb(serviceIps, fallback)) return fallback; if (!isSharedPool(rows.map((row) => row.ip))) return fallback; if (config2.lb_mode === "weighted" || config2.health_check_enabled) { const activeIps = selectActiveIpsByMode(config2, rows, nowMs); if (activeIps.length > 0) return activeIps; } return fallback; } // src/services/service-config-service.ts var AUTO_DNS_TTL = 1; function ttlForBinding(mode, ips) { return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; } function enabledServiceIps(db, serviceId) { return repos9.listServiceIpRows(db, serviceId).filter((row) => row.enabled).map((row) => row.ip); } function failoverARecordDiff(existingA, desiredIps) { const before = new Set(existingA); const after = new Set(desiredIps); return { added: desiredIps.filter((ip) => !before.has(ip)), removed: existingA.filter((ip) => !after.has(ip)) }; } function recordFailoverDnsDiff(db, bindingId, hostname, zoneName, existingRecords, desiredIps) { const existingA = existingRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content); const { added, removed } = failoverARecordDiff(existingA, desiredIps); if (added.length === 0 && removed.length === 0) return; const binding = repos9.getBinding(db, bindingId); const configuredIps = repos9.listBindingIps(db, bindingId); const { config: config2, rows } = getBindingLbState(db, bindingId); const downIps = new Set( rows.filter((row) => row.health === "down").map((row) => row.ip) ); if (!shouldRecordFailoverDnsDiff({ configuredIps, lbMode: config2.lb_mode, added, removed, downIps })) { return; } repos9.insertFailoverLog(db, { serviceId: binding.service_id, bindingId, fqdn: fqdnToDisplay2(hostname, zoneName), entries: [ ...added.map((ip) => ({ ip, action: "added" })), ...removed.map((ip) => ({ ip, action: "removed" })) ] }); } function fqdnToDisplay2(hostname, zoneName) { return hostname === "@" ? zoneName : `${hostname}.${zoneName}`; } function parseFqdn(fqdn, knownZones) { const normalized = fqdn.trim().toLowerCase(); if (!normalized) throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 FQDN"); const zones = [...knownZones].sort((a, b) => b.length - a.length); for (const zone of zones) { const zoneLower = zone.toLowerCase(); if (normalized === zoneLower) { return { zoneName: zone, hostname: "@" }; } const suffix = `.${zoneLower}`; if (normalized.endsWith(suffix)) { const prefix = normalized.slice(0, -suffix.length); if (prefix) return { zoneName: zone, hostname: prefix }; } } throw AppError.validation( `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u0437\u043E\u043D\u0443 \u0434\u043B\u044F \xAB${fqdn}\xBB \u2014 \u0437\u043E\u043D\u0430 \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u043E\u0432\u0430\u0442\u044C \u0432 Cloudflare` ); } function normalizeIps(ips) { const out = []; for (const ip of ips) { const trimmed = ip.trim(); if (!trimmed || !isValidIpv4(trimmed)) continue; if (!out.includes(trimmed)) out.push(trimmed); } out.sort(); return out; } function aggregateSyncStatus(statuses) { if (statuses.length === 0) return null; if (statuses.some((s) => s === SYNC_ERROR2)) return SYNC_ERROR2; if (statuses.some((s) => s === SYNC_PENDING_PUSH3)) return SYNC_PENDING_PUSH3; if (statuses.every((s) => s === SYNC_SYNCED3)) return SYNC_SYNCED3; return statuses[0] ?? null; } function getBindingLbState(db, bindingId) { const binding = repos9.getBinding(db, bindingId); const ipMetas = repos9.listBindingIpsWithMeta(db, bindingId); const rows = ipMetas.map((entry) => { const status = repos9.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); return { ip: entry.ip, weight: entry.weight, priority: entry.priority, health: status ? status.status : "unknown" }; }); return { config: { lb_mode: binding.lb_mode, health_check_enabled: binding.health_check_enabled }, rows }; } function getGroupLbState(db, groupId) { const group = repos9.getServiceGroup(db, groupId); const services = repos9.listServicesByGroup(db, groupId); const seen = /* @__PURE__ */ new Map(); for (const service of services) { if (!service.enabled) continue; const bindings = repos9.listBindingsByService(db, service.id); for (const binding of bindings) { const ipMetas = repos9.listBindingIpsWithMeta(db, binding.id); for (const entry of ipMetas) { const status = repos9.getIpHealthStatusRow(db, "group", groupId, entry.ip); const existing = seen.get(entry.ip); const weight = entry.weight * service.lb_weight; const priority = Math.min(entry.priority, service.lb_priority); if (!existing) { seen.set(entry.ip, { ip: entry.ip, weight, priority, health: status ? status.status : "unknown" }); } else { existing.weight += weight; existing.priority = Math.min(existing.priority, priority); if (isPoolMember(existing.health) && status && !isPoolMember(status.status)) { existing.health = status.status; } } } } } return { config: { lb_mode: group.lb_mode, health_check_enabled: group.health_check_enabled }, rows: [...seen.values()] }; } function desiredAIps(db, scope, refId, fallbackIps) { const state = scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId); const serviceIps = scope === "binding" ? enabledServiceIps(db, repos9.getBinding(db, refId).service_id) : fallbackIps; return resolveDesiredAIps( state.config, state.rows, fallbackIps, Date.now(), serviceIps ); } async function collectKnownZones(db, cf) { const dbDomains = repos9.listDomains(db); const zones = dbDomains.map((d) => d.zone_name); const cfZones = await cf.listZones(); for (const zone of cfZones) { if (!zones.some((n) => n.toLowerCase() === zone.name.toLowerCase())) { zones.push(zone.name); } } return zones; } function bindingLbStateFromBatch(binding, ctx) { const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? []; const healthByIp = ctx.healthByBinding.get(binding.id); const rows = ipMetas.map((entry) => ({ ip: entry.ip, weight: entry.weight, priority: entry.priority, health: healthByIp?.get(entry.ip)?.status ?? "unknown" })); return { config: { lb_mode: binding.lb_mode, health_check_enabled: binding.health_check_enabled }, rows }; } async function buildView(db, serviceId) { const [view] = await buildViews(db, [serviceId]); return view; } async function buildViews(db, serviceIds) { const services = serviceIds.map((id) => repos9.getService(db, id)); const ipRowsByService = repos9.listServiceIpRowsByServiceIds(db, serviceIds); const bindingsByService = repos9.listBindingsByServiceIds(db, serviceIds); const allBindings = [...bindingsByService.values()].flat(); const bindingIds = allBindings.map((b) => b.id); const recordsByBinding = repos9.listRecordsByBindingIds(db, bindingIds); const ipMetaByBinding = repos9.listBindingIpsWithMetaByBindingIds(db, bindingIds); const healthByBinding = repos9.listBindingIpHealthByBindingIds(db, bindingIds); const lbCtx = { ipMetaByBinding, healthByBinding }; const now = Date.now(); return services.map((service) => { const ipRows = ipRowsByService.get(service.id) ?? []; const ips = ipRows.map((row) => row.ip); const ip_enabled = Object.fromEntries( ipRows.map((row) => [row.ip, row.enabled]) ); const bindings = bindingsByService.get(service.id) ?? []; const domainViews = bindings.map((binding) => { const records = recordsByBinding.get(binding.id) ?? []; const statuses = records.map((r) => r.sync_status); const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? []; const targetIps = targetIpsWithMeta.map((entry) => entry.ip); const linkedCname = records.find( (record) => record.record_type.toUpperCase() === "CNAME" ); const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null; const target_ip_weights = {}; const target_ip_priorities = {}; for (const entry of targetIpsWithMeta) { target_ip_weights[entry.ip] = entry.weight; target_ip_priorities[entry.ip] = entry.priority; } for (const ip of targetIps) { if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1; if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1; } const { config: config2, rows } = bindingLbStateFromBatch(binding, lbCtx); const bindingActiveIps = targetCname ? [] : resolveDesiredAIps(config2, rows, targetIps, now, ips); return { binding_id: binding.id, domain_id: binding.domain_id, zone_name: binding.zone_name, hostname: binding.hostname, fqdn: fqdnToDisplay2(binding.hostname, binding.zone_name), record_type: targetCname ? "CNAME" : "A", target_ips: targetCname ? [] : targetIps, target_ip_weights, target_ip_priorities, target_cname: targetCname, lb_mode: binding.lb_mode, health_check_enabled: binding.health_check_enabled, health_check_type: binding.health_check_type, health_check_port: binding.health_check_port, health_check_path: binding.health_check_path, health_check_expected_status: binding.health_check_expected_status, health_check_interval_sec: binding.health_check_interval_sec, health_check_timeout_ms: binding.health_check_timeout_ms, health_check_verify_tls: binding.health_check_verify_tls, health_check_provider: binding.health_check_provider ?? "local", health_check_providers: binding.health_check_providers ?? [ binding.health_check_provider ?? "local" ], health_check_aggregate: binding.health_check_aggregate ?? "majority", cert_monitoring: binding.cert_monitoring ?? "auto", sync_status: aggregateSyncStatus(statuses), active_ips: bindingActiveIps }; }); const activeIps = /* @__PURE__ */ new Set(); for (const domain of domainViews) { for (const ip of domain.active_ips) { activeIps.add(ip); } } return { id: service.id, name: service.name, slug: service.slug, service_group_id: service.service_group_id ?? null, subdomain: service.subdomain ?? "", enabled: Boolean(service.enabled), computed_fqdn: null, lb_weight: service.lb_weight, lb_priority: service.lb_priority, created_at: service.created_at, updated_at: service.updated_at, ips, ip_enabled, domains: domainViews, health_status: "unknown", health_latency_ms: null, ip_health: [], lb_mode: bindings[0]?.lb_mode ?? "round_robin", active_ips: [...activeIps] }; }); } var HEALTH_RANK = { down: 3, degraded: 2, unknown: 1, up: 0 }; function cnameLookupKeys(value, zoneName) { const trimmed = value.trim(); if (!trimmed) return []; const noDot = trimmed.replace(/\.+$/, ""); const lower = noDot.toLowerCase(); const keys = /* @__PURE__ */ new Set([trimmed, noDot, lower]); if (zoneName && !lower.includes(".")) { keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`); } return [...keys]; } function fallbackCnameHealth(rows, view) { const cnameKeys = /* @__PURE__ */ new Set(); for (const domain of view.domains ?? []) { const cname = domain.target_cname?.trim(); if (!cname) continue; for (const key of cnameLookupKeys(cname, domain.zone_name)) { cnameKeys.add(key); } } const hostnameRows = rows.filter((row) => !isIpLiteral2(row.ip)); if (hostnameRows.length === 0) return void 0; const matched = cnameKeys.size === 0 ? hostnameRows : hostnameRows.filter( (row) => cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)) ); const candidates = matched.length > 0 ? matched : hostnameRows; return candidates.reduce( (worst, row) => (HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0) ? row : worst ); } function overlayLiveHealth(stored, live) { if (live && live !== "unknown") return live; return stored ?? live ?? "unknown"; } function bestAliveDisplayStatus(statuses) { if (statuses.some((status) => status === "up")) return "up"; if (statuses.some((status) => status === "degraded")) return "degraded"; if (statuses.some((status) => status === "down")) return "down"; return "unknown"; } function attachServiceHealth(db, views) { const ids = views.map((v) => v.id); const healthByService = repos9.aggregateIpHealthByServiceIds(db, ids); const ipHealthByService = repos9.listIpHealthByServiceIds(db, ids); const liveByService = repos9.listLatestLiveHealthByServiceIds(db, ids); return views.map((view) => { const health = healthByService.get(view.id); const rows = ipHealthByService.get(view.id) ?? []; const liveRows = liveByService.get(view.id) ?? []; const byIp = new Map(rows.map((row) => [row.ip, row])); const liveByIp = new Map(liveRows.map((row) => [row.ip, row])); const cnameFallback = fallbackCnameHealth(rows, view); const aRecordIps = new Set( (view.domains ?? []).flatMap( (domain) => domain.target_cname?.trim() ? [] : domain.target_ips ?? [] ) ); const ip_health = (view.ips ?? []).map((ip) => { const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? void 0 : cnameFallback); const live = liveByIp.get(ip); const status = overlayLiveHealth(row?.status, live?.status); const extras = live && live.status !== "unknown" ? live : row; return { ip, status, latency_ms: extras?.latency_ms ?? null, last_checked_at: extras?.last_checked_at ?? null, last_error: live && live.status !== "unknown" ? live.last_error : row?.last_error ?? null, provider: extras?.provider ?? "local", colo: extras?.colo ?? null }; }); const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status)); const latencyRow = ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ?? ip_health.find((row) => row.latency_ms != null); return { ...view, health_status: overlayLiveHealth(health?.health_status, displayStatus), health_latency_ms: displayStatus !== "unknown" ? latencyRow?.latency_ms ?? null : health?.health_latency_ms ?? null, ip_health }; }); } async function listViews(db) { const ids = repos9.listServices(db).map((s) => s.id); const views = await buildViews(db, ids); return attachServiceHealth(db, views); } async function getView(db, id) { repos9.getService(db, id); const [view] = attachServiceHealth(db, [await buildView(db, id)]); return view; } async function listGroupViews(db) { const groups = repos9.listServiceGroups(db); const servicesByGroup = repos9.listServicesByGroupIds(db, groups.map((g) => g.id)); const ungroupedServices = repos9.listUngroupedServices(db); const allIds = [ ...[...servicesByGroup.values()].flat().map((s) => s.id), ...ungroupedServices.map((s) => s.id) ]; const allServiceViews = await buildViews(db, allIds); const viewsById = new Map(allServiceViews.map((v) => [v.id, v])); const groupViewsRaw = groups.map((group) => ({ ...group, services: (servicesByGroup.get(group.id) ?? []).map((s) => viewsById.get(s.id)).filter((v) => v !== void 0) })); const withHealth = attachServiceHealth(db, allServiceViews); const healthById = new Map(withHealth.map((v) => [v.id, v])); const groupHealthById = repos9.aggregateGroupScopeHealthByIds( db, groups.map((g) => g.id) ); const groupViews = groupViewsRaw.map((group) => { const services = group.services.map( (s) => healthById.get(s.id) ?? { ...s, health_status: "unknown", health_latency_ms: null, ip_health: [], ip_enabled: {} } ); const groupScopeHealth = groupHealthById.get(group.id); const enabledServiceHealth = services.filter((s) => s.enabled).map((s) => ({ health_status: s.health_status, health_latency_ms: s.health_latency_ms })); const merged = repos9.mergeHealthAggregates([ groupScopeHealth, ...enabledServiceHealth ]); return { ...group, services, health_status: merged.health_status, health_latency_ms: merged.health_latency_ms }; }); const ungrouped = ungroupedServices.map((s) => { const view = viewsById.get(s.id); return healthById.get(s.id) ?? (view ? { ...view, health_status: "unknown", health_latency_ms: null, ip_health: [], ip_enabled: {} } : view); }); return { groups: groupViews, ungrouped }; } function shouldPushDns(db, service) { if (!service.enabled) return false; if (!service.service_group_id) return true; const group = repos9.getServiceGroup(db, service.service_group_id); return group.enabled; } async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget, listingCache) { const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; let effectiveCname = cnameTarget?.trim() || null; if (!effectiveCname) { const existingCname = await findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "CNAME", void 0, listingCache ); if (existingCname) { effectiveCname = existingCname.content; repos9.setBindingCnameTarget(db, bindingId, effectiveCname); repos9.replaceBindingIps(db, bindingId, []); } } if (effectiveCname) { await syncBindingCnameDns( db, cf, bindingId, domainId, hostname, effectiveCname, listingCache ); return; } const binding = repos9.getBinding(db, bindingId); const configuredIps = repos9.listBindingIps(db, bindingId); await syncBindingADns( db, cf, bindingId, domainId, hostname, desiredIps, ttlForBinding(binding.lb_mode, configuredIps), listingCache ); } async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget, listingCache) { const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; const normalized = normalizeCnameTarget(cnameTarget, zoneName); const existingRecords = repos9.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "A") { repos9.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } const refreshed = repos9.listRecordsForBinding(db, bindingId); const existingCname = refreshed.find( (record) => record.record_type.toUpperCase() === "CNAME" ); let recordId; if (existingCname) { if (!cnameContentMatches(existingCname.content, normalized, zoneName) || !dnsRecordNamesMatch2(existingCname.name, hostname, zoneName)) { await update(db, cf, domainId, existingCname.id, { record_type: "CNAME", name: dnsNameForBinding(hostname, zoneName), content: normalized, proxied: false }); } recordId = existingCname.id; } else { const adopted = await findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "CNAME", normalized, listingCache ); if (adopted) { repos9.linkBindingRecord(db, bindingId, adopted.id); if (!cnameContentMatches(adopted.content, normalized, zoneName)) { await update(db, cf, domainId, adopted.id, { record_type: "CNAME", name: dnsNameForBinding(hostname, zoneName), content: normalized, proxied: false }); } recordId = adopted.id; } else { const record = await create(db, cf, domainId, { record_type: "CNAME", name: dnsNameForBinding(hostname, zoneName), content: normalized, ttl: 1, proxied: false }); repos9.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } repos9.setBindingDnsRecordId(db, bindingId, recordId); repos9.setBindingCnameTarget(db, bindingId, normalized); } async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps, ttl, listingCache) { const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; const existingRecords = repos9.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "CNAME") { repos9.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } else if (!desiredIps.includes(record.content)) { repos9.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } repos9.setBindingCnameTarget(db, bindingId, null); if (desiredIps.length === 0) { repos9.setBindingDnsRecordId(db, bindingId, null); recordFailoverDnsDiff( db, bindingId, hostname, zoneName, existingRecords, desiredIps ); return; } const refreshed = repos9.listRecordsForBinding(db, bindingId); let primaryId = null; for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); const recordName = dnsNameForBinding(hostname, zoneName); let recordId; if (existing) { if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName) || existing.ttl !== ttl) { await update(db, cf, domainId, existing.id, { record_type: "A", name: recordName, content: ip, ttl, proxied: false }); } recordId = existing.id; } else { const adopted = await findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "A", ip, listingCache ); if (adopted) { repos9.linkBindingRecord(db, bindingId, adopted.id); recordId = adopted.id; if (!dnsRecordNamesMatch2(adopted.name, hostname, zoneName) || adopted.ttl !== ttl) { await update(db, cf, domainId, adopted.id, { record_type: "A", name: recordName, content: ip, ttl, proxied: false }); } } else { const record = await create(db, cf, domainId, { record_type: "A", name: recordName, content: ip, ttl, proxied: false }); repos9.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } if (primaryId == null) primaryId = recordId; } repos9.setBindingDnsRecordId(db, bindingId, primaryId); recordFailoverDnsDiff( db, bindingId, hostname, zoneName, existingRecords, desiredIps ); } async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) { await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null); } async function cleanupServiceDnsOnly(db, cf, serviceId) { const bindings = repos9.listBindingsByService(db, serviceId); for (const binding of bindings) { await cleanupBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname ); } } function validateTargetIpsInPool(targetIps, ips) { for (const ip of targetIps) { if (!isValidIpv4(ip)) { throw AppError.validation(`\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4: ${ip}`); } if (!ips.includes(ip)) { throw AppError.validation(`IP ${ip} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 \u043F\u0443\u043B \u0430\u0434\u0440\u0435\u0441\u043E\u0432 \u0441\u0435\u0440\u0432\u0438\u0441\u0430`); } } } function bindingTargetIps(input) { if (input.target_cname?.trim()) return []; const raw = input.target_ips ? input.target_ips : input.target_ip?.trim() ? [input.target_ip.trim()] : []; const normalized = normalizeIps(raw); if (raw.length > 0 && normalized.length === 0) { throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 IP \u0432 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0435 \u0434\u043E\u043C\u0435\u043D\u0430"); } return normalized; } function bindingTargetCname(input) { const target = input.target_cname?.trim(); return target ? target : null; } function normalizeCnameTarget(target, zoneName) { const trimmed = target.trim().toLowerCase(); if (!trimmed) { throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C"); } if (trimmed.includes(".")) return trimmed; return `${trimmed}.${zoneName.toLowerCase()}`; } function dnsNameForBinding(hostname, zoneName) { return normalizeDnsRecordName2(hostname, zoneName); } function cnameContentMatches(left, right, zoneName) { return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName); } function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) { const records = repos9.listDnsByDomain(db, domainId); return records.find( (record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsRecordNamesMatch2(record.name, hostname, zoneName) ) ?? null; } async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content, listingCache) { const local = findLocalDnsRecord( db, domainId, zoneName, hostname, recordType, content ); if (local) return local; const domain = repos9.getDomain(db, domainId); const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache); for (const cfRec of remote) { if (cfRec.type.toUpperCase() !== recordType) continue; if (content != null) { if (recordType === "CNAME") { if (!cnameContentMatches(cfRec.content, content, zoneName)) continue; } else if (cfRec.content !== content) { continue; } } if (!dnsRecordNamesMatch2(cfRec.name, hostname, zoneName)) continue; if (!cfRec.id) continue; const existing = repos9.findDnsByCfId(db, domainId, cfRec.id); if (existing) return existing; return repos9.insertDnsRecord( db, domainId, cfRec.type, cfRec.name, cfRec.content, cfRec.ttl, cfRec.proxied ?? false, cfRec.priority ?? null, SYNC_SYNCED3, "cloudflare", cfRec.id ); } return null; } async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content, listingCache) { return findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "A", content, listingCache ); } async function syncServiceBindingsToDns(db, cf, serviceId) { const bindings = repos9.listBindingsByService(db, serviceId); if (bindings.length === 0) { throw AppError.validation("\u043D\u0430\u0441\u0442\u0440\u043E\u0439\u0442\u0435 FQDN \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); } const needsIpPool = bindings.some((binding) => { if (binding.cname_target?.trim()) return false; const targetIps = repos9.listBindingIps(db, binding.id); return targetIps.length > 0; }); const ips = repos9.listServiceIps(db, serviceId); if (needsIpPool && ips.length === 0) { throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); } const listingCache = /* @__PURE__ */ new Map(); for (const binding of bindings) { const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) { await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, [], cnameTarget, listingCache ); continue; } let targetIps = repos9.listBindingIps(db, binding.id); if (targetIps.length === 0) { throw AppError.validation( `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay2(binding.hostname, binding.zone_name)}` ); } validateTargetIpsInPool(targetIps, ips); const desiredIps = desiredAIps(db, "binding", binding.id, targetIps); await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, desiredIps, null, listingCache ); } } async function collectGroupDnsIps(db, groupId) { const services = repos9.listServicesByGroup(db, groupId); const ips = []; for (const service of services) { if (!service.enabled) continue; const bindings = repos9.listBindingsByService(db, service.id); for (const binding of bindings) { for (const ip of repos9.listBindingIps(db, binding.id)) { if (!ips.includes(ip)) ips.push(ip); } } } ips.sort(); return ips; } async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps, ttl = AUTO_DNS_TTL, listingCache) { const domain = repos9.getDomain(db, domainId); const zoneName = domain.zone_name; const existingRecords = repos9.listGroupDnsRecords(db, groupId); for (const record of existingRecords) { if (!desiredIps.includes(record.content)) { repos9.unlinkGroupDnsRecord(db, groupId, record.id); await deleteRecord(db, cf, domainId, record.id); } } if (desiredIps.length === 0) return; const refreshed = repos9.listGroupDnsRecords(db, groupId); const recordName = dnsNameForBinding(hostname, zoneName); for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName) || existing.ttl !== ttl) { await update(db, cf, domainId, existing.id, { record_type: "A", name: recordName, content: ip, ttl, proxied: false }); } continue; } const adopted = await findOrImportDnsARecord( db, cf, domainId, zoneName, hostname, ip, listingCache ); if (adopted) { repos9.linkGroupDnsRecord(db, groupId, adopted.id); if (!dnsRecordNamesMatch2(adopted.name, hostname, zoneName) || adopted.ttl !== ttl) { await update(db, cf, domainId, adopted.id, { record_type: "A", name: recordName, content: ip, ttl, proxied: false }); } continue; } const record = await create(db, cf, domainId, { record_type: "A", name: recordName, content: ip, ttl, proxied: false }); repos9.linkGroupDnsRecord(db, groupId, record.id); } } async function resolveDomainId(db, cf, zoneName) { const trimmed = zoneName.trim(); if (!trimmed) throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u0437\u043E\u043D\u044B"); const existing = repos9.findDomainByZoneName(db, trimmed); if (existing) return existing.id; const created = await createDomain(db, cf, null, trimmed); return created.id; } async function cleanupGroupDomainDns(db, cf, groupId) { const group = repos9.getServiceGroup(db, groupId); const domainValue = group.domain?.trim(); if (!domainValue) return; const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(domainValue, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []); } async function syncGroupDomainDns(db, cf, groupId, listingCache) { const group = repos9.getServiceGroup(db, groupId); if (!group.enabled) { await cleanupGroupDomainDns(db, cf, groupId); return; } const domainValue = group.domain?.trim(); if (!domainValue) return; const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(domainValue, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); const fallbackIps = await collectGroupDnsIps(db, groupId); const desiredIps = desiredAIps(db, "group", groupId, fallbackIps); await syncGroupDomainDnsRecords( db, cf, groupId, domainId, hostname, desiredIps, ttlForBinding(group.lb_mode, fallbackIps), listingCache ); } async function syncGroupDomainForService(db, cf, serviceId) { const service = repos9.getService(db, serviceId); if (!service.service_group_id) return; await syncGroupDomainDns(db, cf, service.service_group_id); } async function syncEnabledServicesInGroup(db, cf, groupId) { const group = repos9.getServiceGroup(db, groupId); if (!group.enabled || !group.domain?.trim()) return; const services = repos9.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { await syncServiceBindingsToDns(db, cf, service.id); } } await syncGroupDomainDns(db, cf, groupId); } async function normalizeGroupDomain(db, cf, domain) { const raw = domain?.trim(); if (!raw) return null; const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(raw, knownZones); return fqdnToDisplay2(hostname, zoneName); } async function cleanupStaleGroupFqdnBindings(db, cf, groupId, fqdn) { const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(fqdn, knownZones); if (hostname === "@") return; const domain = repos9.findDomainByZoneName(db, zoneName); if (!domain) return; const services = repos9.listServicesByGroup(db, groupId); for (const service of services) { const binding = repos9.findBinding( db, service.id, domain.id, hostname ); if (!binding) continue; await cleanupBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname ); repos9.deleteBinding(db, binding.id); } } async function updateConfig(db, cf, id, req) { if (req.name && req.slug) { repos9.updateService(db, id, req.name, req.slug); } else if (req.name) { const existing = repos9.getService(db, id); repos9.updateService(db, id, req.name, existing.slug); } else if (req.slug) { const existing = repos9.getService(db, id); repos9.updateService(db, id, existing.name, req.slug); } if (req.service_group_id !== void 0) { repos9.setServiceGroup(db, id, req.service_group_id); } if (req.lb_weight !== void 0 || req.lb_priority !== void 0) { const existing = repos9.getService(db, id); repos9.setServiceLb( db, id, req.lb_weight ?? existing.lb_weight, req.lb_priority ?? existing.lb_priority ); } const ipsUpdated = req.ips !== void 0; const knownZones = await collectKnownZones(db, cf); const ips = req.ips ? normalizeIps(req.ips) : repos9.listServiceIps(db, id); if (ipsUpdated) repos9.replaceServiceIps(db, id, ips); const keptBindingIds = []; let service = repos9.getService(db, id); const pushDns = shouldPushDns(db, service); let removedBindingIds = []; if (req.domains) { for (const input of req.domains) { const fqdn = input.fqdn.trim(); if (!fqdn) continue; const targetCname = bindingTargetCname(input); const targetIps = bindingTargetIps(input); if (!targetCname) { validateTargetIpsInPool(targetIps, ips); } else if (targetIps.length > 0) { throw AppError.validation( `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}` ); } const { zoneName, hostname } = parseFqdn(fqdn, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); const binding = repos9.findBinding(db, id, domainId, hostname) ?? repos9.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); const targetIpWeights = input.target_ip_weights ?? {}; const targetIpPriorities = input.target_ip_priorities ?? {}; const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({ ip, weight: targetIpWeights[ip] ?? 1, priority: targetIpPriorities[ip] ?? 1 })); repos9.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); repos9.setBindingCnameTarget(db, binding.id, targetCname); if (input.lb_mode !== void 0 || input.health_check_enabled !== void 0 || input.health_check_type !== void 0 || input.health_check_port !== void 0 || input.health_check_path !== void 0 || input.health_check_expected_status !== void 0 || input.health_check_interval_sec !== void 0 || input.health_check_timeout_ms !== void 0 || input.health_check_verify_tls !== void 0 || input.health_check_provider !== void 0 || input.health_check_providers !== void 0 || input.health_check_aggregate !== void 0) { repos9.updateBindingLbConfig(db, binding.id, { lb_mode: input.lb_mode, health_check_enabled: input.health_check_enabled, health_check_type: input.health_check_type, health_check_port: input.health_check_port, health_check_path: input.health_check_path, health_check_expected_status: input.health_check_expected_status, health_check_interval_sec: input.health_check_interval_sec, health_check_timeout_ms: input.health_check_timeout_ms, health_check_verify_tls: input.health_check_verify_tls, health_check_provider: input.health_check_provider, health_check_providers: input.health_check_providers, health_check_aggregate: input.health_check_aggregate }); } if (pushDns) { const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps); await syncBindingDns( db, cf, binding.id, domainId, hostname, effectiveIps, targetCname ); } } const removed = repos9.bindingsToRemove(db, id, keptBindingIds); removedBindingIds = removed.map((binding) => binding.id); for (const binding of removed) { await cleanupBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname ); } repos9.deleteBindingsExcept(db, id, keptBindingIds); } else if (ipsUpdated) { const bindings = repos9.listBindingsByService(db, id); for (const binding of bindings) { const targetIps = repos9.listBindingIps(db, binding.id); for (const ip of targetIps) { if (!ips.includes(ip)) { throw AppError.validation( `IP ${ip} \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A ${fqdnToDisplay2(binding.hostname, binding.zone_name)}, \u043D\u043E \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 \u043D\u043E\u0432\u043E\u043C \u043F\u0443\u043B\u0435 \u0430\u0434\u0440\u0435\u0441\u043E\u0432` ); } } } } service = repos9.getService(db, id); const remainingBindings = repos9.listBindingsByService(db, id); if (req.domains && req.domains.length > 0 && !service.enabled) { repos9.setServiceEnabled(db, id, true); service = repos9.getService(db, id); } if (shouldPushDns(db, service)) { if (remainingBindings.length > 0) { await syncServiceBindingsToDns(db, cf, id); } await syncGroupDomainForService(db, cf, id); } void syncServiceToVpsTracker(db, id, removedBindingIds); fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); const [view] = attachServiceHealth(db, [await buildView(db, id)]); return view; } async function createGroup2(db, cf, body) { const groupType = body.type?.trim() || "custom"; const domain = await normalizeGroupDomain(db, cf, body.domain); const group = repos9.createServiceGroup( db, body.name, groupType, body.icon ?? null, domain, { lb_mode: body.lb_mode, health_check_enabled: body.health_check_enabled, health_check_type: body.health_check_type, health_check_port: body.health_check_port, health_check_path: body.health_check_path, health_check_expected_status: body.health_check_expected_status, health_check_interval_sec: body.health_check_interval_sec, health_check_timeout_ms: body.health_check_timeout_ms, health_check_verify_tls: body.health_check_verify_tls, health_check_provider: body.health_check_provider, health_check_providers: body.health_check_providers, health_check_aggregate: body.health_check_aggregate } ); fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); return group; } async function updateGroup2(db, cf, id, body) { const groupType = body.type?.trim() || "custom"; const previous = repos9.getServiceGroup(db, id); const name = body.name ?? previous.name; const oldDomain = previous.domain?.trim(); if (oldDomain) { await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain); await cleanupGroupDomainDns(db, cf, id); } const domain = await normalizeGroupDomain(db, cf, body.domain); let group = repos9.updateServiceGroup( db, id, name, groupType, body.icon ?? null, domain, { lb_mode: body.lb_mode, health_check_enabled: body.health_check_enabled, health_check_type: body.health_check_type, health_check_port: body.health_check_port, health_check_path: body.health_check_path, health_check_expected_status: body.health_check_expected_status, health_check_interval_sec: body.health_check_interval_sec, health_check_timeout_ms: body.health_check_timeout_ms, health_check_verify_tls: body.health_check_verify_tls, health_check_provider: body.health_check_provider, health_check_providers: body.health_check_providers, health_check_aggregate: body.health_check_aggregate } ); if (!domain && group.enabled) { repos9.setServiceGroupEnabled(db, id, false); group = repos9.getServiceGroup(db, id); } await syncEnabledServicesInGroup(db, cf, id); fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS); return group; } function deleteGroup2(db, id) { repos9.deleteServiceGroup(db, id); } async function toggleService(db, cf, serviceId, enabled) { const service = repos9.getService(db, serviceId); if (enabled && service.service_group_id) { const group = repos9.getServiceGroup(db, service.service_group_id); if (group.domain?.trim() && !group.enabled) { throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432"); } } repos9.setServiceEnabled(db, serviceId, enabled); if (!enabled) { await cleanupServiceDnsOnly(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); const [disabledView] = attachServiceHealth(db, [ await buildView(db, serviceId) ]); return disabledView; } await syncServiceBindingsToDns(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); const [enabledView] = attachServiceHealth(db, [ await buildView(db, serviceId) ]); return enabledView; } async function toggleServiceIp(db, cf, serviceId, ip, enabled) { repos9.getService(db, serviceId); const pool = repos9.listServiceIps(db, serviceId); if (!pool.includes(ip)) { throw AppError.validation(`IP ${ip} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 \u043F\u0443\u043B \u0430\u0434\u0440\u0435\u0441\u043E\u0432 \u0441\u0435\u0440\u0432\u0438\u0441\u0430`); } repos9.setServiceIpEnabled(db, serviceId, ip, enabled); const node = repos9.listNodes(db, serviceId).find((entry) => entry.address === ip); if (node) { repos9.updateNode(db, node.id, { enabled }); } const bindings = repos9.listBindingsByService(db, serviceId); for (const binding of bindings) { if (binding.cname_target?.trim()) continue; const current = repos9.listBindingIpsWithMeta(db, binding.id); const hasIp = current.some((entry) => entry.ip === ip); if (enabled && !hasIp) { repos9.replaceBindingIpsWithMeta(db, binding.id, [ ...current, { ip, weight: 1, priority: 1 } ]); continue; } if (!enabled && hasIp) { repos9.replaceBindingIpsWithMeta( db, binding.id, current.filter((entry) => entry.ip !== ip) ); } } const service = repos9.getService(db, serviceId); if (shouldPushDns(db, service)) { await syncServiceBindingsToDns(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); } void syncServiceToVpsTracker(db, serviceId); const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]); return view; } async function toggleGroup(db, cf, groupId, enabled) { const group = repos9.getServiceGroup(db, groupId); if (enabled && !group.domain?.trim()) { throw AppError.validation("\u043D\u0435\u043B\u044C\u0437\u044F \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u0443 \u0431\u0435\u0437 \u0434\u043E\u043C\u0435\u043D\u0430"); } repos9.setServiceGroupEnabled(db, groupId, enabled); if (!enabled) { const services = repos9.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { repos9.setServiceEnabled(db, service.id, false); await cleanupServiceDnsOnly(db, cf, service.id); } } await cleanupGroupDomainDns(db, cf, groupId); } else { await syncEnabledServicesInGroup(db, cf, groupId); } return listGroupViews(db); } function reorderServices(db, groupId, serviceIds) { if (groupId !== null) { repos9.getServiceGroup(db, groupId); } repos9.reorderServices(db, groupId, serviceIds); } async function applyBindingDesiredDns(db, cf, bindingId, desiredIps) { const binding = repos9.getBinding(db, bindingId); const cnameTarget = binding.cname_target?.trim() || null; await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, desiredIps, cnameTarget ); } async function reconcileDnsForTarget(db, cf, scope, refId) { if (scope === "binding") { await withBindingLock(refId, async () => { const binding = repos9.getBinding(db, refId); if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return; const service = repos9.getService(db, binding.service_id); if (!shouldPushDns(db, service)) return; const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) return; const ips = repos9.listServiceIps(db, service.id); const poolIps = enabledServiceIps(db, service.id); const targetIps = repos9.listBindingIps(db, binding.id); validateTargetIpsInPool(targetIps, ips); const desiredIps = canApplyLb(poolIps, targetIps) ? desiredAIps(db, "binding", refId, targetIps) : targetIps; await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, desiredIps, null ); }); return; } const group = repos9.getServiceGroup(db, refId); if (!group.enabled || !group.domain?.trim()) { return; } if (!group.health_check_enabled && group.lb_mode !== "weighted") { return; } await syncGroupDomainDns(db, cf, refId); } async function reconcileWeightedDns(db, cf) { if (!repos9.hasWeightedBindings(db)) return 0; let n = 0; const listingCache = /* @__PURE__ */ new Map(); for (const binding of repos9.listAllBindings(db)) { if (binding.lb_mode !== "weighted") continue; if (binding.cname_target?.trim()) continue; if (!isSharedPool(binding.target_ips ?? [])) continue; try { await withBindingLock(binding.id, async () => { const latest = repos9.getBinding(db, binding.id); if (latest.lb_mode !== "weighted") return; if (latest.cname_target?.trim()) return; const service = repos9.getService(db, latest.service_id); if (!shouldPushDns(db, service)) return; const targetIps = repos9.listBindingIps(db, latest.id); const poolIps = enabledServiceIps(db, service.id); if (!canApplyLb(poolIps, targetIps)) return; const ips = repos9.listServiceIps(db, service.id); validateTargetIpsInPool(targetIps, ips); const desiredIps = desiredAIps(db, "binding", latest.id, targetIps); await syncBindingDns( db, cf, latest.id, latest.domain_id, latest.hostname, desiredIps, null, listingCache ); n += 1; }); } catch { continue; } } for (const group of repos9.listServiceGroups(db)) { if (group.lb_mode !== "weighted") continue; if (!group.enabled || !group.domain?.trim()) continue; try { await syncGroupDomainDns(db, cf, group.id, listingCache); n += 1; } catch { continue; } } return n; } // src/services/node-service.ts import { repos as repos10 } from "@cfdm/db"; function assertAddress(address) { if (!isValidIpv4(address)) { throw AppError.invalidIp(`\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441: ${address}`); } } function listNodes(db, serviceId) { repos10.getService(db, serviceId); return repos10.listNodes(db, serviceId); } function createNode(db, serviceId, input) { repos10.getService(db, serviceId); assertAddress(input.address); try { return repos10.createNode(db, serviceId, { address: input.address, protocol: input.protocol, port: input.port, enabled: input.enabled, priority: input.priority, weight: input.weight, health_check_id: input.health_check_id }); } catch (err) { if (err instanceof Error && err.name === "ConflictError") { throw AppError.conflict(err.message); } throw err; } } function updateNode(db, serviceId, nodeId, patch) { const node = repos10.getNode(db, nodeId); if (node.service_id !== serviceId) { throw AppError.notFound(`node ${nodeId}`); } if (patch.address) assertAddress(patch.address); return repos10.updateNode(db, nodeId, patch); } function deleteNode(db, serviceId, nodeId) { const node = repos10.getNode(db, nodeId); if (node.service_id !== serviceId) { throw AppError.notFound(`node ${nodeId}`); } repos10.deleteNode(db, nodeId); } async function getOverview(db, serviceId) { const service = await getView(db, serviceId); const nodes = repos10.listNodes(db, serviceId); const bindings = repos10.listBindingsByService(db, serviceId); const first = bindings[0]; const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin"; const healthCheck2 = nodes.map((n) => n.health_check_id).find((id) => id != null) != null ? repos10.getHealthCheck( db, nodes.find((n) => n.health_check_id != null).health_check_id ) : null; const active = /* @__PURE__ */ new Set(); const serviceIps = service.ips.filter( (ip) => service.ip_enabled[ip] !== false ); for (const binding of bindings) { const metas = repos10.listBindingIpsWithMeta(db, binding.id); const targetIps = metas.map((entry) => entry.ip); const rows = metas.map((entry) => { const status = repos10.getIpHealthStatusRow(db, "binding", binding.id, entry.ip); return { ip: entry.ip, weight: entry.weight, priority: entry.priority, health: status ? status.status : "unknown" }; }); for (const ip of resolveDesiredAIps( { lb_mode: binding.lb_mode, health_check_enabled: binding.health_check_enabled }, rows, targetIps, Date.now(), serviceIps )) { active.add(ip); } } return { service, nodes, health_check: healthCheck2, routing_strategy: routing, active_addresses: [...active] }; } function opsSummary(db) { const allNodes = repos10.listAllNodes(db); const services = repos10.listServices(db); const domains = repos10.listDomains(db); const healthy = allNodes.filter( (n) => n.health_status === "healthy" || n.health_status === "up" ).length; const unhealthy = allNodes.filter( (n) => n.health_status === "unhealthy" || n.health_status === "down" ).length; const failoverActive = repos10.listAllBindings(db).filter((binding) => { if (binding.lb_mode !== "failover" || !binding.health_check_enabled) { return false; } return isSharedPool(binding.target_ips) && binding.target_ips.some((ip) => { const row = repos10.getIpHealthStatusRow(db, "binding", binding.id, ip); return row?.status === "down"; }); }).length; return { domains: domains.length, services: services.length, nodes: allNodes.length, healthy, unhealthy, active_failovers: failoverActive }; } // src/services/change-domain-service.ts import { repos as repos11 } from "@cfdm/db"; async function changeServiceDomain(db, cf, serviceId, input) { repos11.getService(db, serviceId); if (input.from_domain_id === input.to_domain_id) { throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0434\u0440\u0443\u0433\u043E\u0439 \u0446\u0435\u043B\u0435\u0432\u043E\u0439 \u0434\u043E\u043C\u0435\u043D"); } const fromDomain = repos11.getDomain(db, input.from_domain_id); const toDomain = repos11.getDomain(db, input.to_domain_id); const bindings = repos11.listBindingsByService(db, serviceId).filter((b) => b.domain_id === input.from_domain_id); const selected = input.hostnames?.length ? bindings.filter((b) => input.hostnames.includes(b.hostname)) : bindings; if (selected.length === 0) { throw AppError.validation("\u043D\u0435\u0442 \u043F\u0440\u0438\u0432\u044F\u0437\u043E\u043A \u0434\u043B\u044F \u043F\u0435\u0440\u0435\u043D\u043E\u0441\u0430"); } const items = selected.map((binding) => ({ binding_id: binding.id, hostname: binding.hostname, from_fqdn: fqdnToDisplay2(binding.hostname, fromDomain.zone_name), to_fqdn: fqdnToDisplay2(binding.hostname, toDomain.zone_name) })); const preview = { from_domain_id: fromDomain.id, to_domain_id: toDomain.id, from_zone: fromDomain.zone_name, to_zone: toDomain.zone_name, items, dry_run: Boolean(input.dry_run), applied: false, message: `\u041F\u0435\u0440\u0435\u043D\u043E\u0441 ${items.length} \u043F\u0440\u0438\u0432\u044F\u0437\u043E\u043A ${fromDomain.zone_name} \u2192 ${toDomain.zone_name}` }; if (input.dry_run) return preview; const createdRecordIds = []; try { for (const binding of selected) { const existing = repos11.findBinding( db, serviceId, toDomain.id, binding.hostname ); if (existing) { throw AppError.conflict( `\u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0430 ${fqdnToDisplay2(binding.hostname, toDomain.zone_name)} \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442` ); } await withBindingLock(binding.id, async () => { repos11.bumpBindingVersion(db, binding.id); const ips = repos11.listBindingIps(db, binding.id); repos11.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname); await applyBindingDesiredDns(db, cf, binding.id, ips); const newRecords = repos11.listRecordsForBinding(db, binding.id); createdRecordIds.push(...newRecords.map((r) => r.id)); const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id); for (const record of oldRecords) { repos11.unlinkBindingRecord(db, binding.id, record.id); try { await deleteRecord(db, cf, fromDomain.id, record.id); } catch { } } }); } } catch (err) { throw err instanceof AppError ? err : AppError.syncFailed( err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0442\u0438 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438" ); } void createdRecordIds; return { ...preview, dry_run: false, applied: true, message: `\u041F\u0440\u0438\u0432\u044F\u0437\u043A\u0438 \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0435\u043D\u044B \u0432 ${toDomain.zone_name}. \u0421\u0442\u0430\u0440\u044B\u0435 \u0437\u0430\u043F\u0438\u0441\u0438 \u0437\u043E\u043D\u044B \u0443\u0434\u0430\u043B\u0435\u043D\u044B.` }; } // src/services/certificate-service.ts import { connect } from "net"; import { connect as tlsConnect } from "tls"; import pLimit from "p-limit"; import { repos as repos12 } from "@cfdm/db"; import { CERT_ERROR, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_UNKNOWN, certStatusFromExpiry as certStatusFromExpiry2, fqdnToDisplay as fqdnToDisplay3, shouldMonitorService } from "@cfdm/shared"; function listCertificates(db, status) { return repos12.listCertificates(db, status); } function getCertificate(db, id) { return repos12.getCertificate(db, id); } function listServiceCertificates(db, serviceId) { repos12.getService(db, serviceId); const certsByHost = new Map( repos12.listCertificates(db).map((cert) => [cert.hostname, cert]) ); return repos12.listBindingsByService(db, serviceId).map((binding) => { const hostname = fqdnToDisplay3(binding.hostname, binding.zone_name); const cert = certsByHost.get(hostname); return { binding_id: binding.id, domain_id: binding.domain_id, service_id: binding.service_id, hostname, cert_monitoring: binding.cert_monitoring ?? "auto", id: cert?.id ?? null, status: cert?.status ?? "unknown", expires_at: cert?.expires_at ?? null, last_checked_at: cert?.last_checked_at ?? null, last_error: cert?.last_error ?? null }; }); } async function checkHostname(hostname) { return new Promise((resolve5) => { const socket = connect({ host: hostname, port: 443, timeout: 1e4 }); socket.on( "error", (e) => resolve5({ expiresAt: null, error: e.message }) ); socket.on("timeout", () => { socket.destroy(); resolve5({ expiresAt: null, error: "connection timeout" }); }); socket.on("connect", () => { const tlsSocket = tlsConnect( { socket, servername: hostname, rejectUnauthorized: true }, () => { const cert = tlsSocket.getPeerCertificate(); tlsSocket.end(); if (!cert?.valid_to) { resolve5({ expiresAt: null, error: "no peer certificates" }); return; } resolve5({ expiresAt: new Date(cert.valid_to), error: null }); } ); tlsSocket.on( "error", (e) => resolve5({ expiresAt: null, error: e.message }) ); }); }); } async function checkAndStore(db, domainId, subdomainId, hostname, serviceId = null) { const { expiresAt, error } = await checkHostname(hostname); if (error) { return repos12.upsertCertificateCheck( db, domainId, subdomainId, hostname, null, CERT_ERROR, error, serviceId ); } if (expiresAt) { const days = Math.floor( (expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24) ); return repos12.upsertCertificateCheck( db, domainId, subdomainId, hostname, expiresAt.toISOString(), certStatusFromExpiry2(days), null, serviceId ); } return repos12.upsertCertificateCheck( db, domainId, subdomainId, hostname, null, CERT_UNKNOWN, "unknown expiry", serviceId ); } function bindingSubdomain(db, domainId, hostname) { if (hostname === "@") return null; return repos12.findSubdomainByDomainAndName(db, domainId, hostname); } function hasSslHealthGate(own, group) { if (own.health_check_enabled) { return own.health_check_verify_tls; } if (group?.enabled && group.health_check_enabled) { return group.health_check_verify_tls; } return false; } function resolveCertificateTargets(db) { const targets = []; const seen = /* @__PURE__ */ new Set(); for (const binding of repos12.listAllBindings(db)) { const service = repos12.getService(db, binding.service_id); const group = service.service_group_id ? repos12.getServiceGroup(db, service.service_group_id) : null; if (!shouldMonitorService(service, group)) continue; const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname); if (subdomain && !subdomain.enabled) continue; const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO; if (mode === CERT_MONITOR_SKIPPED) continue; if (mode === CERT_MONITOR_AUTO) { if (!hasSslHealthGate( { health_check_enabled: binding.health_check_enabled, health_check_verify_tls: binding.health_check_verify_tls }, group )) { continue; } } else if (mode !== CERT_MONITOR_REQUIRED) { continue; } const fqdn = fqdnToDisplay3(binding.hostname, binding.zone_name); if (seen.has(fqdn)) continue; seen.add(fqdn); targets.push({ domainId: binding.domain_id, subdomainId: subdomain?.id ?? null, serviceId: binding.service_id, hostname: fqdn }); } return targets; } async function runAllChecks(db) { const targets = resolveCertificateTargets(db); const limit = pLimit(5); await Promise.all( targets.map( (target) => limit( () => checkAndStore( db, target.domainId, target.subdomainId, target.hostname, target.serviceId ) ) ) ); repos12.deleteCertificatesNotIn( db, targets.map((t) => t.hostname) ); return targets.length; } async function runServiceChecks(db, serviceId) { repos12.getService(db, serviceId); const targets = resolveCertificateTargets(db).filter( (target) => target.serviceId === serviceId ); for (const target of targets) { await checkAndStore( db, target.domainId, target.subdomainId, target.hostname, target.serviceId ); } return targets.length; } function statusSummary(db) { return repos12.countCertificatesByStatus(db); } // src/routes/services.ts async function serviceRoutes(app2) { const createSchema = z3.object({ name: z3.string(), slug: z3.string(), service_group_id: z3.number().nullable().optional() }); app2.get("/services", async (request2) => { return listViews(request2.server.db); }); app2.patch("/services/reorder", async (request2) => { const body = reorderServicesSchema.parse(request2.body); reorderServices( request2.server.db, body.group_id, body.service_ids ); return { ok: true }; }); app2.post("/services", async (request2) => { const body = createSchema.parse(request2.body); const service = repos13.createService( request2.server.db, body.name, body.slug ); if (body.service_group_id != null) { repos13.setServiceGroup( request2.server.db, service.id, body.service_group_id ); } const view = await getView(request2.server.db, service.id); recordAudit(request2.server, request2, { action: "service.create", targetType: "app_resource", targetId: String(service.id), summary: `\u0421\u043E\u0437\u0434\u0430\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${service.name}\xBB`, details: { name: service.name, slug: service.slug } }); return view; }); app2.get("/services/:id", async (request2) => { const { id } = request2.params; return getView(request2.server.db, Number(id)); }); app2.get("/services/:id/health-log", async (request2) => { const { id } = request2.params; repos13.getService(request2.server.db, Number(id)); return { items: repos13.listHealthProbeLogForService( request2.server.db, Number(id), 200 ) }; }); app2.get("/services/:id/failover-log", async (request2) => { const { id } = request2.params; repos13.getService(request2.server.db, Number(id)); return { items: repos13.listFailoverLogForService( request2.server.db, Number(id), 200 ) }; }); app2.get("/services/:id/certificates", async (request2) => { const { id } = request2.params; return listServiceCertificates( request2.server.db, Number(id) ); }); app2.post("/services/:id/certificates/check", async (request2) => { const { id } = request2.params; const checked = await runServiceChecks( request2.server.db, Number(id) ); return { checked }; }); app2.get("/services/:id/overview", async (request2) => { const { id } = request2.params; return getOverview(request2.server.db, Number(id)); }); app2.get("/services/:id/nodes", async (request2) => { const { id } = request2.params; return listNodes(request2.server.db, Number(id)); }); app2.post("/services/:id/nodes", async (request2) => { const { id } = request2.params; const body = createServiceNodeSchema.parse(request2.body); const node = createNode(request2.server.db, Number(id), body); recordAudit(request2.server, request2, { action: "node.create", targetType: "app_resource", targetId: String(node.id), summary: `\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`, details: { service_id: Number(id), address: node.address } }); return node; }); app2.patch("/services/:id/nodes/:nodeId", async (request2) => { const { id, nodeId } = request2.params; const body = updateServiceNodeSchema.parse(request2.body); const node = updateNode( request2.server.db, Number(id), Number(nodeId), body ); recordAudit(request2.server, request2, { action: "node.update", targetType: "app_resource", targetId: String(node.id), summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`, details: body }); return node; }); app2.delete("/services/:id/nodes/:nodeId", async (request2) => { const { id, nodeId } = request2.params; const node = repos13.getNode(request2.server.db, Number(nodeId)); deleteNode(request2.server.db, Number(id), Number(nodeId)); recordAudit(request2.server, request2, { action: "node.delete", severity: "warning", targetType: "app_resource", targetId: nodeId, summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}` }); return { deleted: true }; }); app2.post("/services/:id/change-domain", async (request2) => { const { id } = request2.params; const body = changeDomainSchema.parse(request2.body); const result = await changeServiceDomain( request2.server.db, request2.server.cf, Number(id), body ); if (result.applied) { recordAudit(request2.server, request2, { action: "service.change_domain", targetType: "app_resource", targetId: id, summary: result.message, details: result }); } return result; }); app2.get("/ops-summary", async (request2) => { return opsSummary(request2.server.db); }); app2.patch("/services/:id", async (request2) => { const { id } = request2.params; const body = updateServiceConfigSchema.parse(request2.body); const view = await updateConfig( request2.server.db, request2.server.cf, Number(id), body ); recordAudit(request2.server, request2, { action: "service.update", targetType: "app_resource", targetId: String(id), summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${view.name}\xBB`, details: body }); return view; }); app2.delete("/services/:id", async (request2) => { const { id } = request2.params; const view = await getView(request2.server.db, Number(id)); repos13.deleteService(request2.server.db, Number(id)); recordAudit(request2.server, request2, { action: "service.delete", severity: "warning", targetType: "app_resource", targetId: String(id), summary: `\u0423\u0434\u0430\u043B\u0451\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${view.name}\xBB`, details: { name: view.name, slug: view.slug } }); return { deleted: true }; }); app2.patch("/services/:id/toggle", async (request2) => { const { id } = request2.params; const body = z3.object({ enabled: z3.boolean() }).parse(request2.body); return toggleService( request2.server.db, request2.server.cf, Number(id), body.enabled ); }); app2.patch("/services/:id/ips/toggle", async (request2) => { const { id } = request2.params; const body = toggleServiceIpSchema.parse(request2.body); const view = await toggleServiceIp( request2.server.db, request2.server.cf, Number(id), body.ip, body.enabled ); recordAudit(request2.server, request2, { action: "service.ip.toggle", targetType: "app_resource", targetId: String(id), summary: body.enabled ? `\u0412\u043A\u043B\u044E\u0447\u0451\u043D IP ${body.ip} \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \xAB${view.name}\xBB` : `\u0412\u044B\u043A\u043B\u044E\u0447\u0435\u043D IP ${body.ip} \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \xAB${view.name}\xBB`, details: { ip: body.ip, enabled: body.enabled } }); return view; }); } // src/routes/service-groups.ts import { createServiceGroupSchema, toggleEnabledSchema, updateServiceGroupSchema } from "@cfdm/shared"; async function serviceGroupRoutes(app2) { app2.get("/service-groups", async (request2) => { return listGroupViews(request2.server.db); }); app2.post("/service-groups", async (request2) => { const body = createServiceGroupSchema.parse(request2.body); return createGroup2( request2.server.db, request2.server.cf, body ); }); app2.patch("/service-groups/:id", async (request2) => { const { id } = request2.params; const body = updateServiceGroupSchema.parse(request2.body); return updateGroup2( request2.server.db, request2.server.cf, Number(id), body ); }); app2.delete("/service-groups/:id", async (request2) => { const { id } = request2.params; deleteGroup2(request2.server.db, Number(id)); return { deleted: true }; }); app2.patch("/service-groups/:id/toggle", async (request2) => { const { id } = request2.params; const body = toggleEnabledSchema.parse(request2.body); return toggleGroup( request2.server.db, request2.server.cf, Number(id), body.enabled ); }); } // src/routes/service-bindings.ts import { z as z4 } from "zod"; import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared"; // src/services/change-ip-service.ts import { repos as repos14 } from "@cfdm/db"; async function patchRecordContent(db, cf, domainId, recordId, content) { const domain = repos14.getDomain(db, domainId); const record = repos14.getDnsRecord(db, domainId, recordId); if (!record.cf_record_id) { throw AppError.dnsUpdateFailed("\u0443 DNS-\u0437\u0430\u043F\u0438\u0441\u0438 \u043D\u0435\u0442 \u0438\u0434\u0435\u043D\u0442\u0438\u0444\u0438\u043A\u0430\u0442\u043E\u0440\u0430 Cloudflare"); } try { const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, { content }); repos14.updateDnsFields( db, record.id, patched.type ?? record.record_type, patched.name ?? record.name, patched.content ?? content, patched.ttl ?? record.ttl, patched.proxied ?? record.proxied, patched.priority ?? record.priority, "synced", patched.id ?? record.cf_record_id, null ); } catch (err) { if (err instanceof AppError) throw err; throw AppError.dnsUpdateFailed( err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C \u0432 Cloudflare" ); } } async function changeBindingIp(db, cf, bindingId, input) { const binding = repos14.getBinding(db, bindingId); const domain = repos14.getDomain(db, binding.domain_id); const current = repos14.listBindingIpsWithMeta(db, bindingId); if (current.length === 0) { throw AppError.validation("\u0443 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438 \u043D\u0435\u0442 IP \u0434\u043B\u044F \u0437\u0430\u043C\u0435\u043D\u044B"); } let fromIp = input.from_ip?.trim(); let toIp = input.to_ip?.trim(); if (input.node_id) { const node = repos14.getNode(db, input.node_id); if (node.service_id !== binding.service_id) { throw AppError.validation("\u043D\u043E\u0434\u0430 \u043D\u0435 \u043F\u0440\u0438\u043D\u0430\u0434\u043B\u0435\u0436\u0438\u0442 \u0441\u0435\u0440\u0432\u0438\u0441\u0443 \u044D\u0442\u043E\u0439 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438"); } toIp = node.address; } if (!fromIp) { fromIp = current[0].ip; } if (!toIp) { throw AppError.invalidIp("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u043E\u0432\u044B\u0439 IP \u0438\u043B\u0438 \u043D\u043E\u0434\u0443"); } if (!isValidIpv4(toIp)) { throw AppError.invalidIp(`\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441: ${toIp}`); } if (!current.some((row) => row.ip === fromIp)) { throw AppError.validation(`IP ${fromIp} \u043D\u0435\u0442 \u0432 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0435`); } const preview = { binding_id: bindingId, hostname: binding.hostname, zone_name: domain.zone_name, from_ip: fromIp, to_ip: toIp, dry_run: Boolean(input.dry_run), applied: false, message: `${fromIp} \u2192 ${toIp}` }; if (input.dry_run || fromIp === toIp) { return preview; } return withBindingLock(bindingId, async () => { repos14.bumpBindingVersion(db, bindingId); const next = current.map( (row) => row.ip === fromIp ? { ...row, ip: toIp } : row ); repos14.replaceBindingIpsWithMeta(db, bindingId, next); const records = repos14.listRecordsForBinding(db, bindingId); const match = records.find( (record) => record.content === fromIp && (record.record_type.toUpperCase() === "A" || record.record_type.toUpperCase() === "AAAA") ); if (match) { await patchRecordContent(db, cf, binding.domain_id, match.id, toIp); } else { await applyBindingDesiredDns( db, cf, bindingId, next.map((row) => row.ip) ); } return { ...preview, dry_run: false, applied: true, message: `\u0417\u0430\u043F\u0438\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0432 Cloudflare (${fromIp} \u2192 ${toIp}). \u0420\u0430\u0441\u043F\u0440\u043E\u0441\u0442\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043E\u0442 TTL.` }; }); } // src/routes/service-bindings.ts async function serviceBindingRoutes(app2) { const createSchema = z4.object({ domain_id: z4.number(), service_id: z4.number(), hostname: z4.string().optional(), target_ip: z4.string().optional() }); const updateSchema = z4.object({ service_id: z4.number().optional(), hostname: z4.string().optional(), target_ip: z4.string().optional(), cert_monitoring: certMonitoringSchema.optional() }); app2.get("/service-bindings", async (request2) => { return listAll(request2.server.db); }); app2.post("/service-bindings", async (request2) => { const body = createSchema.parse(request2.body); return create2( request2.server.db, request2.server.cf, body ); }); app2.get("/service-bindings/:id", async (request2) => { const { id } = request2.params; const { repos: repos24 } = await import("@cfdm/db"); return repos24.getBindingView(request2.server.db, Number(id)); }); app2.patch("/service-bindings/:id", async (request2) => { const { id } = request2.params; const body = updateSchema.parse(request2.body); return update2( request2.server.db, request2.server.cf, Number(id), body ); }); app2.delete("/service-bindings/:id", async (request2) => { const { id } = request2.params; remove(request2.server.db, Number(id)); return { deleted: true }; }); app2.post("/service-bindings/:id/change-ip", async (request2) => { const { id } = request2.params; const body = changeIpSchema.parse(request2.body); const result = await changeBindingIp( request2.server.db, request2.server.cf, Number(id), body ); if (result.applied) { recordAudit(request2.server, request2, { action: "binding.change_ip", targetType: "app_resource", targetId: id, summary: `\u0421\u043C\u0435\u043D\u0451\u043D IP: ${result.message}`, details: result }); } return result; }); app2.get("/domains/:id/service-bindings", async (request2) => { const { id } = request2.params; return listByDomain(request2.server.db, Number(id)); }); } // src/routes/domains.ts import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared"; import { z as z5 } from "zod"; async function domainRoutes(app2) { const createSchema = z5.object({ zone_name: z5.string(), group_id: z5.number().nullable().optional() }); app2.get("/domains", async (request2) => { const query = request2.query; const groupId = query.group_id ? Number(query.group_id) : void 0; return listDomains(request2.server.db, groupId); }); app2.post("/domains", async (request2) => { const body = createSchema.parse(request2.body); const domain = await createDomain( request2.server.db, request2.server.cf, body.group_id ?? null, body.zone_name ); recordAudit(request2.server, request2, { action: "domain.create", targetType: "app_resource", targetId: String(domain.id), summary: `\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`, details: { zone_name: domain.zone_name, group_id: domain.group_id } }); return domain; }); app2.post("/domains/bulk", async (request2) => { const body = bulkUpdateDomainsSchema.parse(request2.body); const updated = bulkUpdateDomains( request2.server.db, body.ids, { group_id: body.group_id, environment: body.environment, tags_add: body.tags_add } ); return { updated }; }); app2.get("/domains/:id", async (request2) => { const { id } = request2.params; return getDomain(request2.server.db, Number(id)); }); app2.patch("/domains/:id", async (request2) => { const { id } = request2.params; const body = updateDomainSchema.parse(request2.body); const domain = updateDomain(request2.server.db, Number(id), body); recordAudit(request2.server, request2, { action: "domain.update", targetType: "app_resource", targetId: String(domain.id), summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`, details: body }); return domain; }); app2.delete("/domains/:id", async (request2) => { const { id } = request2.params; const domain = getDomain(request2.server.db, Number(id)); deleteDomain(request2.server.db, Number(id)); recordAudit(request2.server, request2, { action: "domain.delete", severity: "warning", targetType: "app_resource", targetId: String(id), summary: `\u0423\u0434\u0430\u043B\u0451\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`, details: { zone_name: domain.zone_name } }); return { deleted: true }; }); app2.post("/domains/:id/import", async (request2) => { const { id } = request2.params; const imported = await importZoneRecords( request2.server.db, request2.server.cf, Number(id) ); return { imported }; }); app2.put("/domains/:id/services", async (request2) => { const { id } = request2.params; const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request2.body); const serviceIds = await setDomainServices2( request2.server.db, Number(id), body.service_ids ); return { service_ids: serviceIds }; }); } // src/routes/dns.ts import { z as z6 } from "zod"; async function dnsRoutes(app2) { const createSchema = z6.object({ record_type: z6.string(), name: z6.string(), content: z6.string(), ttl: z6.number().optional(), proxied: z6.boolean().optional(), priority: z6.number().optional() }); app2.get("/domains/:id/dns", async (request2) => { const { id } = request2.params; const q = request2.query; return list(request2.server.db, Number(id), { record_type: q.record_type, name: q.name, content: q.content, proxied: q.proxied != null ? q.proxied === "true" : void 0, sync_status: q.sync_status, q: q.q, sort: q.sort ?? "name", page: q.page ? Number(q.page) : 1, limit: q.limit ? Number(q.limit) : 50 }); }); app2.post("/domains/:id/dns", async (request2) => { const { id } = request2.params; const body = createSchema.parse(request2.body); const record = await create( request2.server.db, request2.server.cf, Number(id), body ); recordAudit(request2.server, request2, { action: "dns.create", targetType: "app_resource", targetId: String(record.id), summary: `\u0421\u043E\u0437\u0434\u0430\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name} (${record.record_type})`, details: { domain_id: Number(id), record_type: record.record_type, name: record.name, content: record.content } }); return record; }); app2.post("/domains/:id/dns/bulk", async (request2) => { const { id } = request2.params; const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request2.body); return bulk( request2.server.db, request2.server.cf, Number(id), body.operations ); }); app2.get("/domains/:id/dns/:recordId", async (request2) => { const { id, recordId } = request2.params; return get( request2.server.db, Number(id), Number(recordId) ); }); app2.patch("/domains/:id/dns/:recordId", async (request2) => { const { id, recordId } = request2.params; const record = await update( request2.server.db, request2.server.cf, Number(id), Number(recordId), request2.body ); recordAudit(request2.server, request2, { action: "dns.update", targetType: "app_resource", targetId: String(recordId), summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name}`, details: { domain_id: Number(id), record_id: Number(recordId) } }); return record; }); app2.delete("/domains/:id/dns/:recordId", async (request2) => { const { id, recordId } = request2.params; const record = get( request2.server.db, Number(id), Number(recordId) ); await deleteRecord( request2.server.db, request2.server.cf, Number(id), Number(recordId) ); recordAudit(request2.server, request2, { action: "dns.delete", severity: "warning", targetType: "app_resource", targetId: String(recordId), summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name}`, details: { domain_id: Number(id), name: record.name } }); return { deleted: true }; }); app2.post("/domains/:id/dns/:recordId/resolve", async (request2) => { const { id, recordId } = request2.params; const body = z6.object({ source: z6.string() }).parse(request2.body); return resolveConflict( request2.server.db, request2.server.cf, Number(id), Number(recordId), body ); }); } // src/routes/subdomains.ts import { updateSubdomainSchema } from "@cfdm/shared"; import { repos as repos15 } from "@cfdm/db"; import { z as z7 } from "zod"; async function subdomainRoutes(app2) { app2.get("/domains/:id/subdomains", async (request2) => { const { id } = request2.params; repos15.getDomain(request2.server.db, Number(id)); return repos15.listSubdomainsByDomain(request2.server.db, Number(id)); }); app2.post("/domains/:id/subdomains", async (request2) => { const { id } = request2.params; const body = z7.object({ name: z7.string() }).parse(request2.body); const domain = repos15.getDomain(request2.server.db, Number(id)); const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; return repos15.createSubdomain( request2.server.db, Number(id), body.name, fqdn ); }); app2.get("/subdomains/:id", async (request2) => { const { id } = request2.params; return repos15.getSubdomain(request2.server.db, Number(id)); }); app2.patch("/subdomains/:id", async (request2) => { const { id } = request2.params; const body = updateSubdomainSchema.parse(request2.body); const sub = repos15.getSubdomain(request2.server.db, Number(id)); const domain = repos15.getDomain(request2.server.db, sub.domain_id); const patch = {}; if (body.name !== void 0) { patch.name = body.name; patch.fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; } if (body.enabled !== void 0) { patch.enabled = body.enabled; } if (body.cert_monitoring !== void 0) { patch.cert_monitoring = body.cert_monitoring; } return repos15.updateSubdomain(request2.server.db, Number(id), patch); }); app2.delete("/subdomains/:id", async (request2) => { const { id } = request2.params; repos15.deleteSubdomain(request2.server.db, Number(id)); return { deleted: true }; }); } // src/routes/certificates.ts async function certificateRoutes(app2) { app2.get("/certificates", async (request2) => { const query = request2.query; return listCertificates( request2.server.db, query.status ); }); app2.get("/certificates/summary", async (request2) => { return statusSummary(request2.server.db); }); app2.post("/certificates/check", async (request2) => { const checked = await runAllChecks(request2.server.db); return { checked }; }); app2.get("/certificates/:id", async (request2) => { const { id } = request2.params; return getCertificate( request2.server.db, Number(id) ); }); } // src/routes/sync.ts async function syncRoutes(app2) { app2.post("/sync", async (request2) => { const jobId = await syncAll( request2.server.db, request2.server.cf ); return { job_id: jobId }; }); app2.post("/domains/:id/sync", async (request2) => { const { id } = request2.params; const result = await syncDomain( request2.server.db, request2.server.cf, Number(id) ); return { job_id: result.jobId, changes: result.changes }; }); app2.get("/sync/jobs/:id", async (request2) => { const { id } = request2.params; return getJob(request2.server.db, id); }); } // src/routes/origin-health-checks.ts import { createOriginHealthCheckSchema } from "@cfdm/shared"; // src/services/origin-health-check-service.ts import { repos as repos17 } from "@cfdm/db"; // src/services/health/cloudflare.ts import { repos as repos16 } from "@cfdm/db"; function toPayload(check, address) { const type = (check.protocol || "TCP").toUpperCase(); const payload = { address, name: check.name, type, interval: check.interval_sec, timeout: check.timeout, retries: check.retries, consecutive_fails: check.consecutive_fails, consecutive_successes: check.consecutive_successes, suspended: check.suspended }; if (type === "HTTP" || type === "HTTPS") { payload.http_config = { method: check.method ?? "GET", path: check.path ?? "/", expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"] }; } else { payload.tcp_config = { method: "connection_established" }; } return payload; } var CloudflareHealthCheckProvider = class { constructor(db, cf) { this.db = db; this.cf = cf; } db; cf; kind = "cloudflare"; async probe(target) { const node = repos16.findNodeByIp(this.db, target.ip); if (!node?.health_check_id) { return { ok: false, latencyMs: 0, error: "\u043D\u0435\u0442 Cloudflare Health Check" }; } const check = repos16.getHealthCheck(this.db, node.health_check_id); if (!check.cf_zone_id || !check.cf_healthcheck_id) { return { ok: false, latencyMs: 0, error: "Cloudflare Health Check \u043D\u0435 \u0441\u0438\u043D\u0445\u0440\u043E\u043D\u0438\u0437\u0438\u0440\u043E\u0432\u0430\u043D" }; } try { const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id); const status = (remote.status ?? "").toLowerCase(); const ok = status === "healthy" || status === "ok"; return { ok, latencyMs: 0, error: ok ? null : remote.status ?? "unhealthy" }; } catch (err) { return { ok: false, latencyMs: 0, error: err instanceof Error ? err.message : String(err) }; } } async syncCreate(check, zoneId, address) { try { const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address)); repos16.updateHealthCheck(this.db, check.id, { cf_healthcheck_id: remote.id, cf_zone_id: zoneId, provider: "cloudflare" }); return remote; } catch (err) { if (err instanceof AppError) throw err; throw AppError.healthcheckCreateFailed( err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0437\u0434\u0430\u0442\u044C Cloudflare Health Check" ); } } async syncUpdate(check, address) { if (!check.cf_zone_id || !check.cf_healthcheck_id) { throw AppError.healthcheckCreateFailed("Cloudflare Health Check \u043D\u0435 \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A \u0437\u043E\u043D\u0435"); } return this.cf.updateHealthCheck( check.cf_zone_id, check.cf_healthcheck_id, toPayload(check, address) ); } async syncDelete(check) { if (!check.cf_zone_id || !check.cf_healthcheck_id) return; await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id); } }; // src/services/origin-health-check-service.ts function listOriginHealthChecks(db) { return repos17.listHealthChecks(db); } function getOriginHealthCheck(db, id) { return repos17.getHealthCheck(db, id); } async function createOriginHealthCheck(db, cf, input) { const protocol = (input.protocol ?? "tcp").toLowerCase(); const check = repos17.createHealthCheck(db, { provider: input.provider, name: input.name, cf_zone_id: input.cf_zone_id ?? null, protocol, path: input.path, method: input.method, timeout: input.timeout, interval_sec: input.interval_sec, retries: input.retries, expected_status: input.expected_status, consecutive_fails: input.consecutive_fails, consecutive_successes: input.consecutive_successes, suspended: input.suspended }); if (input.node_id) { const node = repos17.getNode(db, input.node_id); repos17.updateNode(db, node.id, { health_check_id: check.id }); } if (input.provider === "cloudflare") { const zoneId = input.cf_zone_id; if (!zoneId) { throw AppError.zoneNotFound("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0437\u043E\u043D\u0443 Cloudflare \u0434\u043B\u044F Health Check"); } const address = input.node_id ? repos17.getNode(db, input.node_id).address : check.name; const provider = new CloudflareHealthCheckProvider(db, cf); await provider.syncCreate(check, zoneId, address); return repos17.getHealthCheck(db, check.id); } return check; } async function updateOriginHealthCheck(db, cf, id, patch) { const current = repos17.getHealthCheck(db, id); const updated = repos17.updateHealthCheck(db, id, { provider: patch.provider, name: patch.name, cf_zone_id: patch.cf_zone_id, protocol: patch.protocol?.toLowerCase(), path: patch.path, method: patch.method, timeout: patch.timeout, interval_sec: patch.interval_sec, retries: patch.retries, expected_status: patch.expected_status, consecutive_fails: patch.consecutive_fails, consecutive_successes: patch.consecutive_successes, suspended: patch.suspended }); if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) { const address = repos17.findNodeByIp(db, updated.name)?.address ?? updated.name; const provider = new CloudflareHealthCheckProvider(db, cf); await provider.syncUpdate(updated, address); } void current; return repos17.getHealthCheck(db, id); } async function deleteOriginHealthCheck(db, cf, id) { const check = repos17.getHealthCheck(db, id); if (check.provider === "cloudflare") { const provider = new CloudflareHealthCheckProvider(db, cf); await provider.syncDelete(check); } repos17.deleteHealthCheck(db, id); } async function syncOriginHealthCheck(db, cf, id) { const check = repos17.getHealthCheck(db, id); if (check.provider !== "cloudflare") { throw AppError.validation("\u0441\u0438\u043D\u0445\u0440\u043E\u043D\u0438\u0437\u0430\u0446\u0438\u044F \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F Cloudflare Health Checks"); } if (!check.cf_zone_id) { throw AppError.zoneNotFound(); } const provider = new CloudflareHealthCheckProvider(db, cf); const address = repos17.findNodeByIp(db, check.name)?.address ?? check.name; if (check.cf_healthcheck_id) { await provider.syncUpdate(check, address); } else { await provider.syncCreate(check, check.cf_zone_id, address); } return repos17.getHealthCheck(db, id); } // src/routes/origin-health-checks.ts async function originHealthCheckRoutes(app2) { app2.get("/health-checks", async (request2) => { return listOriginHealthChecks(request2.server.db); }); app2.post("/health-checks", async (request2) => { const body = createOriginHealthCheckSchema.parse(request2.body); const check = await createOriginHealthCheck( request2.server.db, request2.server.cf, body ); recordAudit(request2.server, request2, { action: "healthcheck.create", targetType: "app_resource", targetId: String(check.id), summary: `\u0421\u043E\u0437\u0434\u0430\u043D health check \xAB${check.name}\xBB (${check.provider})` }); return check; }); app2.get("/health-checks/:id", async (request2) => { const { id } = request2.params; return getOriginHealthCheck(request2.server.db, Number(id)); }); app2.patch("/health-checks/:id", async (request2) => { const { id } = request2.params; const body = createOriginHealthCheckSchema.partial().parse(request2.body); const check = await updateOriginHealthCheck( request2.server.db, request2.server.cf, Number(id), body ); recordAudit(request2.server, request2, { action: "healthcheck.update", targetType: "app_resource", targetId: id, summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D health check \xAB${check.name}\xBB` }); return check; }); app2.delete("/health-checks/:id", async (request2) => { const { id } = request2.params; await deleteOriginHealthCheck( request2.server.db, request2.server.cf, Number(id) ); recordAudit(request2.server, request2, { action: "healthcheck.delete", severity: "warning", targetType: "app_resource", targetId: id, summary: `\u0423\u0434\u0430\u043B\u0451\u043D health check ${id}` }); return { deleted: true }; }); app2.post("/health-checks/:id/sync", async (request2) => { const { id } = request2.params; return syncOriginHealthCheck( request2.server.db, request2.server.cf, Number(id) ); }); } // src/routes/health-check.ts import { z as z8 } from "zod"; import { healthStatusQuerySchema } from "@cfdm/shared"; import { getAppSettings as getAppSettings3, repos as repos20 } from "@cfdm/db"; // src/services/health-check-service.ts import { connect as connect2, isIP } from "net"; import { resolve4 as resolve42, resolve6 } from "dns/promises"; import { Agent, buildConnector, fetch as undiciFetch } from "undici"; import { repos as repos18 } from "@cfdm/db"; import { aggregateHealthOk, parseHealthAggregate, targetProviders } from "@cfdm/shared"; // src/services/health/state-machine.ts function wasHealthy(status) { return status === "up" || status === "healthy"; } function wasUnhealthy(status) { return status === "down" || status === "unhealthy" || status === "checking" || status === "degraded"; } function nextHealthState(ok, latencyMs, prev, thresholds) { if (!ok) { const failures = (prev?.consecutive_failures ?? 0) + 1; if (failures >= thresholds.downFailures) { return { legacy: "down", node: "unhealthy", failures, successes: 0 }; } return { legacy: "degraded", node: "degraded", failures, successes: 0 }; } if (latencyMs > thresholds.latencyWarnMs) { return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 }; } if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) { return { legacy: "up", node: "healthy", failures: 0, successes: (prev?.consecutive_successes ?? 0) + 1 }; } const successes = (prev.consecutive_successes ?? 0) + 1; if (successes >= thresholds.successRecoveries) { return { legacy: "up", node: "healthy", failures: 0, successes }; } return { legacy: "unknown", node: "checking", failures: 0, successes }; } // src/services/health/local.ts var LocalHealthCheckProvider = class { kind = "local"; probe(target) { return probeTarget(target); } }; // src/services/health/worker.ts function workerNotConfiguredResult() { return { ok: false, latencyMs: 0, error: "Cloudflare Worker \u043D\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0435\u043D (\u043D\u0435\u0442 KV mailbox)", colo: null }; } // src/lib/globalping-client.ts import { clampGlobalpingLimit, parseGlobalpingLocations } from "@cfdm/shared"; var GLOBALPING_API_ROOT = "https://api.globalping.io"; var GLOBALPING_MIN_POLL_MS = 500; var GLOBALPING_UA = "CFDM-health/1.0"; function sleep(ms) { return new Promise((resolve5) => setTimeout(resolve5, ms)); } function locationLabel(probe) { if (!probe) return null; const city = probe.city?.trim(); const country = probe.country?.trim(); if (city && country) return `${city}, ${country}`; return city || country || null; } function buildMeasurementBody(target, options) { const limit = clampGlobalpingLimit(options.limit, 3); const locations = parseGlobalpingLocations(options.locations).map((magic) => ({ magic })); const port = target.port ?? (target.type === "http" ? 80 : 80); const ip = String(target.ip || "").trim(); const hostname = (target.hostname || ip).trim(); if (target.type === "http") { const path = target.path?.trim() || "/"; const protocol = port === 443 ? "HTTPS" : "HTTP"; return { type: "http", target: ip, inProgressUpdates: false, limit, locations, measurementOptions: { protocol, port, request: { method: "GET", host: hostname, path: path.startsWith("/") ? path : `/${path}` } } }; } return { type: "ping", target: ip, inProgressUpdates: false, limit, locations, measurementOptions: { protocol: "TCP", port } }; } function rowOk(target, row) { const result = row.result; if (!result) return false; const status = String(result.status ?? "").toLowerCase(); if (status && status !== "finished") return false; if (target.type === "http") { const code = result.statusCode; if (code == null) return false; if (target.expected_status != null) return code === target.expected_status; return code >= 200 && code < 400; } const loss = result.stats?.loss; if (loss != null && loss >= 100) return false; return status === "finished" || status === ""; } function rowLatency(row) { const total = row.result?.timings?.total; if (typeof total === "number" && Number.isFinite(total)) return Math.round(total); const avg = row.result?.stats?.avg; if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg); return 0; } function summarizeMeasurement(target, doc) { const rows = doc.results ?? []; if (rows.length === 0) { return { ok: false, latencyMs: 0, error: "Globalping: \u043F\u0443\u0441\u0442\u043E\u0439 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442", colo: null }; } const oks = rows.map((row) => rowOk(target, row)); const okCount = oks.filter(Boolean).length; const ok = okCount > rows.length / 2; const latencies = rows.map(rowLatency); const latencyMs = Math.round( latencies.reduce((sum, n) => sum + n, 0) / latencies.length ); const colo = locationLabel(rows.find((_, i) => oks[i])?.probe) ?? locationLabel(rows[0]?.probe); if (ok) { return { ok: true, latencyMs, error: null, colo }; } const expected = target.type === "http" && target.expected_status != null ? `\u043E\u0436\u0438\u0434\u0430\u043B\u0438 HTTP ${target.expected_status}` : target.type === "http" ? "\u043E\u0436\u0438\u0434\u0430\u043B\u0438 HTTP 2xx/3xx" : "TCP ping \u0441 packet loss < 100%"; return { ok: false, latencyMs, error: `Globalping: ${okCount}/${rows.length} \u043F\u0440\u043E\u0431 \u0443\u0441\u043F\u0435\u0448\u043D\u044B (${expected})`, colo }; } async function parseJson(response) { try { return await response.json(); } catch { return {}; } } async function runGlobalpingMeasurement(target, options = {}) { const fetchImpl = options.fetchImpl ?? fetch; const pollMs = options.pollIntervalMs === void 0 ? GLOBALPING_MIN_POLL_MS : Math.max(0, options.pollIntervalMs); const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3e3, 3e3) + 15e3; const headers = { Accept: "application/json", "Content-Type": "application/json", "User-Agent": GLOBALPING_UA }; const token = options.token?.trim(); if (token) headers.Authorization = `Bearer ${token}`; const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, { method: "POST", headers, body: JSON.stringify(buildMeasurementBody(target, options)) }); if (created.status === 429) { return { ok: false, latencyMs: 0, error: "Globalping: 429 rate limit", colo: null }; } if (created.status !== 202 && created.status !== 200) { const body = await parseJson(created); return { ok: false, latencyMs: 0, error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`, colo: null }; } const createdBody = await parseJson(created); const id = createdBody.id?.trim(); if (!id) { return { ok: false, latencyMs: 0, error: "Globalping: \u043D\u0435\u0442 id \u0438\u0437\u043C\u0435\u0440\u0435\u043D\u0438\u044F", colo: null }; } const started = Date.now(); while (Date.now() - started < maxWaitMs) { await sleep(pollMs); const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, { method: "GET", headers: { Accept: "application/json", "User-Agent": GLOBALPING_UA, ...token ? { Authorization: `Bearer ${token}` } : {} } }); if (polled.status === 429) { return { ok: false, latencyMs: 0, error: "Globalping: 429 rate limit", colo: null }; } if (!polled.ok) { return { ok: false, latencyMs: 0, error: `Globalping: HTTP ${polled.status} \u043F\u0440\u0438 \u043E\u043F\u0440\u043E\u0441\u0435`, colo: null }; } const doc = await parseJson(polled); if (String(doc.status ?? "").toLowerCase() === "in-progress") continue; return summarizeMeasurement(target, doc); } return { ok: false, latencyMs: Date.now() - started, error: "Globalping: timeout \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F measurement", colo: null }; } // src/services/health/globalping.ts function globalpingNotConfiguredResult() { return { ok: false, latencyMs: 0, error: "Globalping: \u0442\u043E\u043A\u0435\u043D \u043D\u0435 \u0437\u0430\u0434\u0430\u043D", colo: null }; } async function probeWithGlobalping(target, options) { if (!options.token?.trim()) { return globalpingNotConfiguredResult(); } try { const result = await runGlobalpingMeasurement(target, options); return { ok: result.ok, latencyMs: result.latencyMs, error: result.error, colo: result.colo }; } catch (err) { return { ok: false, latencyMs: 0, error: err instanceof Error ? err.message : "Globalping: \u043E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430", colo: null }; } } // src/services/health-check-service.ts function hostForUrl(ipOrHost) { return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost; } function buildHttpProbeUrl(urlHost, port, pathWithSlash, useTls) { const defaultPort = useTls ? 443 : 80; const portPart = port === defaultPort ? "" : `:${port}`; return `${useTls ? "https" : "http"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`; } function tcpProbe(ip, port, timeoutMs) { return new Promise((resolve5) => { const started = Date.now(); const socket = connect2({ host: ip, port, timeout: timeoutMs }); let settled = false; const finish = (result) => { if (settled) return; settled = true; socket.destroy(); resolve5(result); }; socket.on( "connect", () => finish({ ok: true, latencyMs: Date.now() - started, error: null }) ); socket.on( "timeout", () => finish({ ok: false, latencyMs: Date.now() - started, error: "connection timeout" }) ); socket.on( "error", (err) => finish({ ok: false, latencyMs: Date.now() - started, error: err.message }) ); }); } function createIpPinnedAgent(connectAddr, sniHost, useTls, timeoutMs, verifyTls) { const connector = buildConnector({ rejectUnauthorized: verifyTls, timeout: timeoutMs }); return new Agent({ connect(opts, callback) { connector( { ...opts, // Force socket to configured IP (or CNAME target), not public DNS of FQDN. hostname: connectAddr, host: connectAddr, servername: useTls && isIP(sniHost) === 0 ? sniHost : opts.servername }, callback ); } }); } async function httpProbe(ip, target, timeoutMs) { const started = Date.now(); const path = target.path?.trim() || "/"; const pathWithSlash = path.startsWith("/") ? path : `/${path}`; const port = target.port ?? 80; const useTls = port === 443; const connectAddr = String(ip || "").trim(); const headerHost = (target.hostname || "").trim() || connectAddr; const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls); const verifyTls = target.verify_tls === true; const family = isIP(connectAddr); const pinToIp = family === 4 || family === 6; const dispatcher = pinToIp ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls) : useTls ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls) : void 0; try { const response = await undiciFetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs), redirect: "manual", dispatcher }); const latency = Date.now() - started; await response.body?.cancel().catch(() => { }); if (target.expected_status != null) { if (response.status !== target.expected_status) { return { ok: false, latencyMs: latency, error: `expected ${target.expected_status}, got ${response.status}` }; } return { ok: true, latencyMs: latency, error: null }; } if (response.status >= 200 && response.status < 400) { return { ok: true, latencyMs: latency, error: null }; } return { ok: false, latencyMs: latency, error: `unexpected status ${response.status}` }; } catch (err) { return { ok: false, latencyMs: Date.now() - started, error: err instanceof Error ? err.message : String(err) }; } finally { await dispatcher?.destroy().catch(() => { }); } } async function pingProbe(hostname, timeoutMs) { const ports = [443, 80]; let last = { ok: false, latencyMs: 0, error: "unreachable" }; for (const port of ports) { last = await tcpProbe(hostname, port, timeoutMs); if (last.ok) return last; } return last; } async function dnsProbe(hostname) { const started = Date.now(); try { const [v4, v6] = await Promise.allSettled([ resolve42(hostname), resolve6(hostname) ]); const hasV4 = v4.status === "fulfilled" && v4.value.length > 0; const hasV6 = v6.status === "fulfilled" && v6.value.length > 0; if (!hasV4 && !hasV6) { return { ok: false, latencyMs: Date.now() - started, error: "no A/AAAA records" }; } return { ok: true, latencyMs: Date.now() - started, error: null }; } catch (err) { return { ok: false, latencyMs: Date.now() - started, error: err instanceof Error ? err.message : String(err) }; } } async function probeTarget(target) { const port = target.port ?? (target.type === "http" ? 80 : 80); const timeoutMs = target.timeout_ms || 3e3; if (target.type === "http") { return httpProbe(target.ip, target, timeoutMs); } if (target.type === "ping") { return pingProbe(target.hostname || target.ip, timeoutMs); } if (target.type === "dns") { return dnsProbe(target.hostname || target.ip); } return tcpProbe(target.ip, port, timeoutMs); } function deriveState(ok, latencyMs, prev, thresholds) { const next = nextHealthState(ok, latencyMs, prev, { degradedFailures: thresholds.degradedFailures, downFailures: thresholds.downFailures, latencyWarnMs: thresholds.latencyWarnMs, successRecoveries: thresholds.successRecoveries ?? 2 }); return { state: next.legacy, failures: next.failures, successes: next.successes, node: next.node }; } function sleep2(ms) { return new Promise((resolve5) => setTimeout(resolve5, ms)); } function logSourceResult(db, target, provider, result) { repos18.insertHealthProbeLog(db, { scope: target.scope, refId: target.ref_id, ip: target.ip, provider, status: result.ok ? "up" : "down", ok: result.ok, latencyMs: result.latencyMs, colo: result.colo ?? null, error: result.error }); } async function applyAggregatedStatus(db, target, sources, options) { const policy = parseHealthAggregate(target.aggregate); const oks = sources.map((s) => s.result.ok); const aggregatedOk = aggregateHealthOk(oks, policy); const latencies = sources.map((s) => s.result.latencyMs); const latencyMs = latencies.length ? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length) : 0; const colo = sources.find((s) => s.result.colo)?.result.colo ?? sources[0]?.result.colo ?? null; const error = aggregatedOk ? null : sources.map((s) => s.result.error).filter((msg) => Boolean(msg)).join("; ") || "health aggregate down"; const statusProvider = sources.length > 1 ? "aggregate" : sources[0]?.provider ?? target.provider; const prev = repos18.getIpHealthStatusRow( db, target.scope, target.ref_id, target.ip ); const { state, failures, successes, node } = deriveState( aggregatedOk, latencyMs, prev ? { consecutive_failures: prev.consecutive_failures, consecutive_successes: prev.consecutive_successes, status: prev.status } : null, options.thresholds ); const prevState = prev ? prev.status : null; repos18.upsertIpHealthStatus( db, target.scope, target.ref_id, target.ip, state, latencyMs, failures, error, successes, { colo, provider: statusProvider } ); const matchedNode = repos18.findNodeByIp(db, target.ip); if (matchedNode && matchedNode.enabled && target.scope === "binding") { repos18.updateNode(db, matchedNode.id, { health_status: node, consecutive_failures: failures, consecutive_successes: successes, last_check_at: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19), last_failure_reason: error }); } if (prevState !== state) { await options.onStatusChange?.(target, prevState, state); } } function staleWorkerResult(colo) { return { ok: false, latencyMs: 0, error: "Cloudflare Worker: \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442\u044B \u0443\u0441\u0442\u0430\u0440\u0435\u043B\u0438 \u0438\u043B\u0438 KV \u043F\u0443\u0441\u0442", colo }; } async function runAllChecks2(db, options) { const targets = repos18.listHealthCheckTargets(db); const gapMs = Math.max(0, options.probeGapMs ?? 2e3); const local = new LocalHealthCheckProvider(); const staleAfterMs = options.staleAfterMs ?? 10 * 6e4; const byOrigin = /* @__PURE__ */ new Map(); for (const target of targets) { const key = originProbeKey(target); const list2 = byOrigin.get(key); if (list2) list2.push(target); else byOrigin.set(key, [target]); } const needsCloudflare = targets.some( (t) => targetProviders(t).includes("cloudflare") ); let mailboxResults = /* @__PURE__ */ new Map(); let mailboxColo = null; let mailboxStale = true; const mailbox = options.mailbox ?? null; if (needsCloudflare) { const resultsDoc = mailbox ? await mailbox.getResults() : null; mailboxResults = indexResults(resultsDoc); mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs); mailboxColo = resultsDoc?.colo ?? null; if (mailbox) { try { const next = buildTargetsDoc(targets); const current = await mailbox.getTargets(); if (current?.fingerprint !== next.fingerprint) { await mailbox.putTargets(next); } } catch { } } } const probeCache = /* @__PURE__ */ new Map(); let probeIndex = 0; async function resolveProvider(provider, representative, originKey) { const cacheKey = `${provider}|${originKey}`; const cached = probeCache.get(cacheKey); if (cached) return cached; let result; if (provider === "local") { if (probeIndex > 0 && gapMs > 0) await sleep2(gapMs); probeIndex += 1; result = await local.probe(representative); } else if (provider === "cloudflare") { const item = mailboxResults.get(originKey); if (!mailbox) result = workerNotConfiguredResult(); else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo); else { result = { ok: item.ok, latencyMs: item.latencyMs, error: item.error, colo: mailboxColo }; } } else { if (!options.globalping?.token?.trim()) { result = globalpingNotConfiguredResult(); } else { if (probeIndex > 0 && gapMs > 0) await sleep2(gapMs); probeIndex += 1; result = await probeWithGlobalping(representative, options.globalping); } } probeCache.set(cacheKey, result); return result; } for (const [originKey, group] of byOrigin) { const representative = group.find((t) => t.scope === "binding") ?? group[0]; const needed = /* @__PURE__ */ new Set(); for (const target of group) { for (const provider of targetProviders(target)) needed.add(provider); } for (const provider of needed) { await resolveProvider(provider, representative, originKey); } for (const target of group) { const providers = targetProviders(target); const sources = providers.map((provider) => ({ provider, result: probeCache.get(`${provider}|${originKey}`) })); for (const source of sources) { logSourceResult(db, target, source.provider, source.result); } await applyAggregatedStatus(db, target, sources, options); } } repos18.pruneStaleIpHealthStatus(db, targets); return targets.length; } async function runDomainMonitors(db, thresholds) { const monitors = repos18.listEnabledDomainMonitors(db); let checked = 0; for (const monitor of monitors) { const target = { scope: "binding", ref_id: monitor.id, ip: monitor.hostname, hostname: monitor.hostname, type: monitor.type, port: monitor.type === "http" ? monitor.path?.includes("443") ? 443 : 80 : null, path: monitor.path, expected_status: monitor.expected_status, timeout_ms: monitor.timeout_ms, verify_tls: false, provider: "local", providers: ["local"], aggregate: "majority" }; let result; if (monitor.type === "http") { result = await httpProbe(monitor.hostname, { ...target, port: 443, path: monitor.path ?? "/" }, monitor.timeout_ms); if (!result.ok) { result = await httpProbe(monitor.hostname, { ...target, port: 80, path: monitor.path ?? "/" }, monitor.timeout_ms); } } else if (monitor.type === "ping") { result = await pingProbe(monitor.hostname, monitor.timeout_ms); } else { result = await dnsProbe(monitor.hostname); } const prevStatus = monitor.last_status; const { state } = deriveState( result.ok, result.latencyMs, { consecutive_failures: result.ok ? 0 : 1, consecutive_successes: result.ok ? 1 : 0, status: prevStatus }, thresholds ); repos18.updateDomainMonitorResult( db, monitor.id, state, result.latencyMs, result.error ); if (prevStatus !== state && prevStatus !== "unknown") { const label = state === "up" ? "OK" : state === "degraded" ? "Slow" : state === "down" ? "Down" : "\u2014"; repos18.insertNotificationLog( db, "domain_monitor", "domain_monitor", monitor.id, `${monitor.hostname}: ${label}`, result.error ? `${monitor.type.toUpperCase()} \u2192 ${label}. ${result.error}` : `${monitor.type.toUpperCase()} \u2192 ${label}${result.latencyMs != null ? ` (${result.latencyMs} \u043C\u0441)` : ""}` ); } checked += 1; } return checked; } function listStatus(db, scope, refId) { return repos18.listIpHealthStatus(db, scope, refId); } // src/services/health-check-scheduler.ts import { AsyncTask, CronJob } from "toad-scheduler"; import { getAppSettings as getAppSettings2, getAppSettingsSecrets as getAppSettingsSecrets2, updateAppSettings as updateAppSettings2 } from "@cfdm/db"; import { repos as repos19 } from "@cfdm/db"; var HEALTH_CHECK_JOB_ID = "health-check"; function healthEngineFallbacksFromConfig(config2) { return { healthCheckCron: config2.healthCheckCron, healthDegradedFailures: config2.healthDegradedFailures, healthDownFailures: config2.healthDownFailures, healthLatencyWarnMs: config2.healthLatencyWarnMs, healthSuccessRecoveries: config2.healthSuccessRecoveries, healthWorkerUrl: config2.healthWorkerUrl, healthWorkerTokenSet: Boolean(config2.healthWorkerToken) }; } function assertValidHealthCron(expr) { const cronExpression = expr.trim(); const parts = cronExpression.split(/\s+/).filter(Boolean); if (parts.length < 5 || parts.length > 6) { throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435"); } try { const job = new CronJob( { cronExpression }, new AsyncTask("validate-cron", async () => void 0), { id: "validate-cron" } ); job.stop(); } catch { throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435"); } } function createHealthCheckTask(app2, config2) { const fallbacks = healthEngineFallbacksFromConfig(config2); return new AsyncTask( HEALTH_CHECK_JOB_ID, async () => { const settings = getAppSettings2(app2.db, fallbacks); const thresholds = { degradedFailures: settings.healthDegradedFailures, downFailures: settings.healthDownFailures, latencyWarnMs: settings.healthLatencyWarnMs, successRecoveries: settings.healthSuccessRecoveries }; const mailbox = mailboxFromSettings(app2.db, app2.cf, fallbacks); const secrets = getAppSettingsSecrets2(app2.db); const n = await runAllChecks2(app2.db, { thresholds, probeGapMs: config2.healthProbeGapMs, mailbox, staleAfterMs: cronStaleAfterMs(settings.healthCheckCron), globalping: { token: secrets.globalpingToken, locations: secrets.globalpingLocations, limit: secrets.globalpingLimit }, onStatusChange: async (target, prev, next) => { try { const label = next === "up" ? "OK" : next === "degraded" ? "Slow" : next === "down" ? "Down" : "\u2014"; repos19.insertNotificationLog( app2.db, "ip_health", target.scope, target.ref_id, `${target.hostname || target.ip}: ${label}`, `IP ${target.ip}: ${prev ?? "\u2014"} \u2192 ${label}` ); await reconcileDnsForTarget( app2.db, app2.cf, target.scope, target.ref_id ); } catch (err) { app2.log.warn( { err, scope: target.scope, refId: target.ref_id }, "health-check reconcile failed" ); } } }); if (mailbox) { updateAppSettings2( app2.db, { healthWorkerLastIngestAt: (/* @__PURE__ */ new Date()).toISOString() }, fallbacks ); } const monitors = await runDomainMonitors( app2.db, thresholds ); app2.log.info({ checked: n, monitors }, "health check completed"); }, (err) => { app2.log.warn({ err }, "health check failed"); } ); } function scheduleHealthCheckJob(app2, config2, task) { const scheduler = app2.scheduler; if (!scheduler) return; if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) { scheduler.removeById(HEALTH_CHECK_JOB_ID); } const settings = getAppSettings2( app2.db, healthEngineFallbacksFromConfig(config2) ); scheduler.addCronJob( new CronJob( { cronExpression: settings.healthCheckCron }, task, { preventOverrun: true, id: HEALTH_CHECK_JOB_ID } ) ); } // src/routes/health-check.ts async function healthCheckRoutes(app2) { app2.get("/health-status", async (request2) => { const query = healthStatusQuerySchema.parse(request2.query); return listStatus( request2.server.db, query.scope, query.ref_id ); }); app2.get("/health-status/batch", async (request2) => { const raw = request2.query ?? {}; const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : ""; const refIds = z8.array(z8.coerce.number().int().positive()).max(200).parse( idsParam.split(",").map((s) => s.trim()).filter((s) => s.length > 0) ); const grouped = repos20.listIpHealthStatusByBindingIds( request2.server.db, refIds ); const items = []; for (const id of refIds) { items.push({ ref_id: id, rows: grouped.get(id) ?? [] }); } return { items }; }); app2.post("/health-check/run", async (request2) => { const config2 = request2.server.config; const fallbacks = healthEngineFallbacksFromConfig(config2); const settings = getAppSettings3( request2.server.db, fallbacks ); const thresholds = { degradedFailures: settings.healthDegradedFailures, downFailures: settings.healthDownFailures, latencyWarnMs: settings.healthLatencyWarnMs, successRecoveries: settings.healthSuccessRecoveries }; const checked = await runAllChecks2(request2.server.db, { thresholds, probeGapMs: config2.healthProbeGapMs, mailbox: mailboxFromSettings(request2.server.db, request2.server.cf, fallbacks), staleAfterMs: cronStaleAfterMs(settings.healthCheckCron), onStatusChange: async (target, prev, next) => { try { const label = next === "up" ? "OK" : next === "degraded" ? "Slow" : next === "down" ? "Down" : "\u2014"; repos20.insertNotificationLog( request2.server.db, "ip_health", target.scope, target.ref_id, `${target.hostname || target.ip}: ${label}`, `IP ${target.ip}: ${prev ?? "\u2014"} \u2192 ${label}` ); await reconcileDnsForTarget( request2.server.db, request2.server.cf, target.scope, target.ref_id ); } catch { } } }); const monitors = await runDomainMonitors( request2.server.db, thresholds ); return { checked, monitors }; }); } // src/routes/domain-monitors.ts import { createDomainMonitorSchema } from "@cfdm/shared"; import { repos as repos21 } from "@cfdm/db"; async function domainMonitorRoutes(app2) { app2.get("/domains/:id/monitors", async (request2) => { const { id } = request2.params; return repos21.listDomainMonitors(request2.server.db, Number(id)); }); app2.post("/domains/:id/monitors", async (request2) => { const { id } = request2.params; const body = createDomainMonitorSchema.parse(request2.body); return repos21.createDomainMonitor(request2.server.db, Number(id), body); }); app2.delete("/domains/:domainId/monitors/:monitorId", async (request2) => { const { monitorId } = request2.params; repos21.deleteDomainMonitor(request2.server.db, Number(monitorId)); return { deleted: true }; }); app2.get("/domains/:id/monitor-results", async (request2) => { const { id } = request2.params; const query = request2.query; const limit = query.limit ? Number(query.limit) : 50; return repos21.listDomainMonitorResultsForDomain( request2.server.db, Number(id), limit ); }); app2.post("/domains/:id/monitors/run", async (request2) => { const config2 = request2.server.config; const checked = await runDomainMonitors(request2.server.db, { degradedFailures: config2.healthDegradedFailures, downFailures: config2.healthDownFailures, latencyWarnMs: config2.healthLatencyWarnMs }); return { checked }; }); } async function notificationRoutes(app2) { app2.get("/notifications/log", async (request2) => { const query = request2.query; const limit = query.limit ? Number(query.limit) : 50; return repos21.listNotificationLog(request2.server.db, limit); }); } // src/routes/settings.ts import { appSettingsPatchSchema } from "@cfdm/shared"; import { getAppSettings as getAppSettings4, updateAppSettings as updateAppSettings3 } from "@cfdm/db"; async function settingsRoutes(app2) { app2.get("/settings", async (request2) => { return getAppSettings4( request2.server.db, healthEngineFallbacksFromConfig(request2.server.config) ); }); app2.patch("/settings", async (request2) => { const parsed = appSettingsPatchSchema.safeParse(request2.body); if (!parsed.success) { throw AppError.validation( 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" ); } const body = parsed.data; if (body.healthCheckCron) { assertValidHealthCron(body.healthCheckCron); } const fallbacks = healthEngineFallbacksFromConfig(request2.server.config); const current = getAppSettings4(request2.server.db, fallbacks); const nextDegraded = body.healthDegradedFailures ?? current.healthDegradedFailures; const nextDown = body.healthDownFailures ?? current.healthDownFailures; if (nextDown < nextDegraded) { throw AppError.validation( "\u043E\u0448\u0438\u0431\u043E\u043A \u0434\u043E down \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435, \u0447\u0435\u043C \u0434\u043E degraded" ); } updateAppSettings3(request2.server.db, body, fallbacks); if (body.healthCheckCron !== void 0) { request2.server.reloadHealthCheckJob?.(); const after = getAppSettings4(request2.server.db, fallbacks); if (after.healthWorkerKvNamespaceId) { try { await ensureHealthWorker( request2.server.db, request2.server.cf, fallbacks ); } catch { } } } return getAppSettings4(request2.server.db, fallbacks); }); app2.post("/settings/health/worker/ensure", async (request2) => { const fallbacks = healthEngineFallbacksFromConfig(request2.server.config); await ensureHealthWorker(request2.server.db, request2.server.cf, fallbacks); return getAppSettings4(request2.server.db, fallbacks); }); app2.post("/settings/vps-tracker/test", async (request2) => { return pingVpsTracker(request2.server.db); }); } // src/routes/integrations-vps-tracker.ts import { timingSafeEqual } from "crypto"; import { vpsTrackerEventSchema } from "@cfdm/shared"; import { getAppSettingsSecrets as getAppSettingsSecrets3 } from "@cfdm/db"; // src/services/vps-tracker-events.ts import { repos as repos22 } from "@cfdm/db"; function collectIps(event) { const ips = /* @__PURE__ */ new Set(); for (const v of event.vps) { if (v.ip?.trim()) ips.add(v.ip.trim()); } return ips; } function bindingUsesIps(db, serviceId, bindingId, ips) { const targetIps = repos22.listBindingIps(db, bindingId); const poolIps = repos22.listServiceIps(db, serviceId); const effective = targetIps.length > 0 ? targetIps : poolIps; return effective.some((ip) => ips.has(ip)); } async function reconcileForVpsDown(db, cf, event) { const ips = collectIps(event); if (ips.size === 0) return 0; const seen = /* @__PURE__ */ new Set(); let reconciled = 0; for (const service of repos22.listServices(db)) { if (!service.enabled) continue; const bindings = repos22.listBindingsByService(db, service.id); for (const binding of bindings) { if (!bindingUsesIps(db, service.id, binding.id, ips)) continue; const key = `binding:${binding.id}`; if (seen.has(key)) continue; seen.add(key); await reconcileDnsForTarget(db, cf, "binding", binding.id); reconciled += 1; } } return reconciled; } // src/routes/integrations-vps-tracker.ts function verifyBearer(authHeader, expected) { if (!authHeader?.startsWith("Bearer ")) return false; const token = authHeader.slice(7); if (!token || !expected) return false; const a = Buffer.from(token); const b = Buffer.from(expected); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } async function integrationsVpsTrackerRoutes(app2) { app2.post("/integrations/vps-tracker/events", async (req, reply) => { const secrets = getAppSettingsSecrets3(app2.db); const token = secrets.vpsTrackerIntegrationToken; if (!token) { return reply.status(503).send({ error: "Integration not configured" }); } if (!verifyBearer(req.headers.authorization, token)) { return reply.status(401).send({ error: "Unauthorized" }); } const parsed = vpsTrackerEventSchema.safeParse(req.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.flatten() }); } if (parsed.data.event !== "vps_down") { return { ok: true, reconciled: 0 }; } const reconciled = await reconcileForVpsDown( app2.db, app2.cf, parsed.data ); return { ok: true, reconciled }; }); app2.post("/integrations/vps-tracker/sync", async (req, reply) => { const secrets = getAppSettingsSecrets3(app2.db); const token = secrets.vpsTrackerIntegrationToken; if (!token) { return reply.status(503).send({ ok: false, error: "Integration not configured" }); } if (!verifyBearer(req.headers.authorization, token)) { return reply.status(401).send({ ok: false, error: "Unauthorized" }); } const bindings = await buildAllSyncBindings(app2.db); return { ok: true, count: bindings.length, bindings, fullSync: true }; }); } // src/routes/audit.ts import { listAudit } from "@cfdm/db"; import { auditListQuerySchema } from "@cfdm/shared"; async function auditRoutes(app2) { app2.get("/audit", async (request2) => { const parsed = auditListQuerySchema.safeParse(request2.query); if (!parsed.success) { throw AppError.validation("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0437\u0430\u043F\u0440\u043E\u0441\u0430"); } const q = parsed.data; return listAudit(app2.db, { action: q.action, severity: q.severity, userId: q.user_id, sourceApp: q.source_app, limit: q.limit }); }); } // src/app.ts import { repos as repos23, walCheckpointTruncate } from "@cfdm/db"; // src/services/weighted-dns-scheduler.ts import { AsyncTask as AsyncTask2, SimpleIntervalJob } from "toad-scheduler"; var WEIGHTED_DNS_JOB_ID = "weighted-dns"; function createWeightedDnsTask(app2) { return new AsyncTask2( WEIGHTED_DNS_JOB_ID, async () => { const n = await reconcileWeightedDns(app2.db, app2.cf); if (n > 0) { app2.log.info({ reconciled: n }, "weighted dns rotated"); } }, (err) => { app2.log.warn({ err }, "weighted dns rotate failed"); } ); } function scheduleWeightedDnsJob(app2, task) { const scheduler = app2.scheduler; if (!scheduler) return; if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) { scheduler.removeById(WEIGHTED_DNS_JOB_ID); } scheduler.addSimpleIntervalJob( new SimpleIntervalJob( { seconds: WEIGHTED_SLOT_MS / 1e3, runImmediately: true }, task, { id: WEIGHTED_DNS_JOB_ID, preventOverrun: true } ) ); } // src/app.ts import { AsyncTask as AsyncTask3, CronJob as CronJob2, ToadScheduler } from "toad-scheduler"; async function buildApp(opts = {}) { const config2 = opts.config ?? loadConfig(); const app2 = Fastify({ logger: { level: config2.logLevel } }).withTypeProvider(); 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/rate-limit"), { max: 300, timeWindow: "1 minute" }); 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" }); await app2.register(integrationsVpsTrackerRoutes, { prefix: "/api/v1" }); await app2.register( async (protectedApi) => { protectedApi.addHook("onRequest", requireAuth); await protectedApi.register(groupRoutes); await protectedApi.register(serviceRoutes); await protectedApi.register(serviceGroupRoutes); await protectedApi.register(serviceBindingRoutes); await protectedApi.register(domainRoutes); await protectedApi.register(dnsRoutes); await protectedApi.register(subdomainRoutes); await protectedApi.register(certificateRoutes); await protectedApi.register(syncRoutes); await protectedApi.register(healthCheckRoutes); await protectedApi.register(originHealthCheckRoutes); await protectedApi.register(domainMonitorRoutes); await protectedApi.register(notificationRoutes); await protectedApi.register(settingsRoutes); await protectedApi.register(auditRoutes); }, { prefix: "/api/v1" } ); const staticDir = config2.staticDir ?? resolve2(process.cwd(), "static"); if (config2.staticDir !== null) { await app2.register(import("@fastify/static"), { root: staticDir, wildcard: false }); app2.setNotFoundHandler(async (_request, reply) => { return reply.sendFile("index.html"); }); } if (!opts.memory) { const scheduler = new ToadScheduler(); app2.decorate("scheduler", scheduler); app2.addHook("onClose", async () => { scheduler.stop(); }); const certTask = new AsyncTask3( "certificate-check", async () => { const pruned = repos23.pruneLogs(app2.db); if (pruned > 0) { app2.log.info({ pruned }, "log retention pruned"); } walCheckpointTruncate(app2.sqlite); const n = await runAllChecks(app2.db); app2.log.info({ checked: n }, "certificate check completed"); }, (err) => { app2.log.warn({ err }, "certificate check failed"); } ); scheduler.addCronJob( new CronJob2( { cronExpression: config2.certCheckCron }, certTask, { preventOverrun: true } ) ); const healthTask = createHealthCheckTask(app2, config2); scheduleHealthCheckJob(app2, config2, healthTask); app2.decorate("reloadHealthCheckJob", () => { scheduleHealthCheckJob(app2, config2, healthTask); }); scheduleWeightedDnsJob(app2, createWeightedDnsTask(app2)); if (config2.cloudflareApiToken) { fireEnsureHealthWorker( app2.db, app2.cf, healthEngineFallbacksFromConfig(config2), app2.log ); } } return app2; } // src/server.ts for (const path of [ resolve3(import.meta.dirname, "../../../.env"), ".env", "../.env" ]) { if (!existsSync2(path)) continue; const content = readFileSync2(path, "utf-8"); for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); let value = trimmed.slice(eq + 1).trim(); if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { value = value.slice(1, -1); } if (!(key in process.env)) process.env[key] = value; } break; } var config = loadConfig(); if (!config.cloudflareApiToken) { console.warn( "CLOUDFLARE_API_TOKEN \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u0438\u043C\u043F\u043E\u0440\u0442 \u0434\u043E\u043C\u0435\u043D\u043E\u0432 \u0438\u0437 Cloudflare \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D" ); } var app = await buildApp({ config }); try { await app.listen({ port: config.serverPort, host: "0.0.0.0" }); app.log.info(`listening on ${config.serverPort}`); } catch (err) { app.log.error(err); process.exit(1); } //# sourceMappingURL=server.js.map