// src/server.ts import { readFileSync, existsSync } 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 loadConfig() { return { databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db", cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(), jwtSecret: process.env.JWT_SECRET ?? "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 * * *", logLevel: process.env.LOG_LEVEL ?? "info" }; } // 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() { return new _AppError("FORBIDDEN", "forbidden", 403); } static conflict(message) { return new _AppError("CONFLICT", message, 409); } static cloudflare(message) { return new _AppError("CLOUDFLARE_ERROR", 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/plugins/auth.ts async function authPlugin(app2, opts) { await app2.register(import("@fastify/jwt"), { secret: opts.config.jwtSecret }); } async function requireAuth(request) { const authHeader = request.headers.authorization ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; if (!token) throw AppError.unauthorized(); try { await request.jwtVerify(); } catch { throw AppError.unauthorized(); } } 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/cf-client.ts var BASE_URL = "https://api.cloudflare.com/client/v4"; var CloudflareClient = class { constructor(token) { this.token = token; } token; async handleResponse(response, operation) { if (response.status === 429) { const wait = parseRetryAfter(response.headers) ?? 5e3; throw AppError.cloudflare(`rate limited, retry after ${wait}ms`); } const body = await response.json(); if (!body.success) { const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error"; throw AppError.cloudflare(`${operation}: ${msg}`); } if (body.result === void 0) { throw AppError.cloudflare(`${operation}: empty result`); } return body.result; } async listZones() { return withRetry(async () => { const all = []; let page = 1; while (true) { const url = new URL(`${BASE_URL}/zones`); url.searchParams.set("per_page", "50"); url.searchParams.set("page", String(page)); const response = await fetch(url, { headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(3e4) }); if (response.status >= 500 || response.status === 429) { throw AppError.cloudflare(String(response.status)); } const batch = await this.handleResponse( response, "list_zones" ); if (batch.length === 0) break; all.push(...batch); if (batch.length < 50) break; page += 1; } return all; }); } async getZone(zoneId) { const response = await fetch(`${BASE_URL}/zones/${zoneId}`, { headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(3e4) }); return this.handleResponse(response, "get_zone"); } async listDnsRecords(zoneId) { return withRetry(async () => { const all = []; let page = 1; while (page <= 50) { const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`); url.searchParams.set("per_page", "100"); url.searchParams.set("page", String(page)); const response = await fetch(url, { headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(3e4) }); if (response.status >= 500 || response.status === 429) { throw AppError.cloudflare(String(response.status)); } const batch = await this.handleResponse( response, "list_dns_records" ); if (batch.length === 0) break; all.push(...batch); page += 1; } return all; }); } async createDnsRecord(zoneId, payload) { const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, { method: "POST", headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(3e4) }); return this.handleResponse(response, "create_dns_record"); } async updateDnsRecord(zoneId, recordId, payload) { const response = await fetch( `${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`, { method: "PUT", headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(3e4) } ); return this.handleResponse(response, "update_dns_record"); } async deleteDnsRecord(zoneId, recordId) { const response = await fetch( `${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE", headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(3e4) } ); await this.handleResponse(response, "delete_dns_record"); } }; // 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 (request, reply) => { healthCheck(request.server.sqlite); return { status: "ok" }; }); app2.get("/ready", async (request, reply) => { healthCheck(request.server.sqlite); let cloudflare = false; if (request.server.config.cloudflareApiToken) { try { await request.server.cf.listZones(); cloudflare = true; } catch { cloudflare = false; } } return { status: cloudflare || !request.server.config.cloudflareApiToken ? "ready" : "degraded", database: true, cloudflare }; }); } async function authRoutes(app2) { const loginSchema = z.object({ username: z.string(), password: z.string() }); app2.post("/auth/login", async (request, reply) => { const body = loginSchema.parse(request.body); const result = await login( request.server.config, (payload) => request.server.jwt.sign(payload), body ); return result; }); } // src/routes/groups.ts import { z as z2 } from "zod"; // 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/routes/groups.ts async function groupRoutes(app2) { const bodySchema = z2.object({ name: z2.string(), slug: z2.string() }); app2.get("/groups", async (request) => { return listGroups(request.server.db); }); app2.post("/groups", async (request) => { const body = bodySchema.parse(request.body); return createGroup(request.server.db, body.name, body.slug); }); app2.get("/groups/:id", async (request) => { const { id } = request.params; return getGroupWithStats(request.server.db, Number(id)); }); app2.patch("/groups/:id", async (request) => { const { id } = request.params; const body = bodySchema.parse(request.body); return updateGroup( request.server.db, Number(id), body.name, body.slug ); }); app2.delete("/groups/:id", async (request) => { const { id } = request.params; deleteGroup(request.server.db, Number(id)); return { deleted: true }; }); } // src/routes/services.ts import { z as z3 } from "zod"; import { reorderServicesSchema } from "@cfdm/shared"; import { repos as repos7 } from "@cfdm/db"; // src/services/service-config-service.ts import { repos as repos6 } from "@cfdm/db"; import { SYNC_ERROR as SYNC_ERROR2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, SYNC_SYNCED as SYNC_SYNCED3, dnsNameToSubdomainLabel as dnsNameToSubdomainLabel2 } 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 repos2 } from "@cfdm/db"; import { SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_PUSH, SYNC_SYNCED } 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); repos2.updateDnsFields( db, record.id, record.record_type, record.name, record.content, record.ttl, record.proxied, record.priority, SYNC_SYNCED, cfRec.id ?? null, null ); return repos2.getDnsRecord(db, domainId, record.id); } catch (e) { repos2.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 = repos2.getDomain(db, domainId); const ttl = req.ttl ?? 1; const proxied = req.proxied ?? false; validateDnsRecord(req.record_type, req.name, req.content, ttl, proxied); const record = repos2.insertDnsRecord( db, domainId, req.record_type, req.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 = repos2.getDomain(db, domainId); const existing = repos2.getDnsRecord(db, domainId, recordId); const recordType = req.record_type ?? existing.record_type; const name = req.name ?? existing.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); repos2.updateDnsFields( db, recordId, recordType, name, content, ttl, proxied, priority, SYNC_PENDING_PUSH, existing.cf_record_id, null ); const updated = repos2.getDnsRecord(db, domainId, recordId); return pushRecord(db, cf, domainId, domain.cf_zone_id, updated); } async function deleteRecord(db, cf, domainId, recordId) { const domain = repos2.getDomain(db, domainId); const record = repos2.getDnsRecord(db, domainId, recordId); repos2.markDnsPendingDelete(db, recordId); if (record.cf_record_id) { try { await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id); } catch (e) { repos2.setDnsSyncStatus( db, recordId, SYNC_ERROR, record.cf_record_id, e instanceof Error ? e.message : String(e) ); throw e; } } repos2.deleteDnsRecord(db, recordId); } function list(db, domainId, filter) { repos2.getDomain(db, domainId); return repos2.listDnsRecords(db, domainId, filter); } function get(db, domainId, recordId) { return repos2.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 = repos2.getDomain(db, domainId); const record = repos2.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) { repos2.updateDnsFields( db, recordId, r.type, r.name, r.content, r.ttl, r.proxied ?? false, r.priority ?? null, SYNC_SYNCED, r.id ?? null, null ); } } return repos2.getDnsRecord(db, domainId, recordId); } if (req.source === "local") { const updated = repos2.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 repos5 } from "@cfdm/db"; // src/services/binding-service.ts import { repos as repos3 } 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 }); repos3.setBindingDnsRecordId(db, bindingId, record.id); return record.id; } function listAll(db) { return repos3.listAllBindings(db); } function listByDomain(db, domainId) { repos3.getDomain(db, domainId); return repos3.listBindingsByDomain(db, domainId); } async function create2(db, cf, req) { repos3.getDomain(db, req.domain_id); repos3.getService(db, req.service_id); const hostname = normalizeHostname(req.hostname); const binding = repos3.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 repos3.getBindingView(db, binding.id); } async function update2(db, cf, id, req) { const existing = repos3.getBinding(db, id); const serviceId = req.service_id ?? existing.service_id; if (req.service_id) repos3.getService(db, req.service_id); const hostname = req.hostname ? normalizeHostname(req.hostname) : existing.hostname; repos3.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 repos3.getBindingView(db, id); } function remove(db, id) { repos3.getBinding(db, id); repos3.deleteBinding(db, id); } async function setDomainServices(db, domainId, serviceIds) { repos3.getDomain(db, domainId); for (const sid of serviceIds) { repos3.getService(db, sid); } const existing = repos3.listBindingsByDomain(db, domainId); for (const binding of existing) { if (!serviceIds.includes(binding.service_id)) { repos3.deleteBinding(db, binding.id); } } for (const sid of serviceIds) { const already = existing.some((b) => b.service_id === sid); if (!already) { repos3.insertBinding(db, domainId, sid, "@", null); } } return repos3.listBindingsByDomain(db, domainId).map((b) => b.service_id); } // src/services/sync-service.ts import { repos as repos4 } from "@cfdm/db"; import { SYNC_CONFLICT as SYNC_CONFLICT2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH2, SYNC_SYNCED as SYNC_SYNCED2, dnsNameToSubdomainLabel, subdomainLabelToFqdn } from "@cfdm/shared"; import { randomUUID } from "crypto"; async function pullSync(db, cf, domain) { const remote = await cf.listDnsRecords(domain.cf_zone_id); const local = repos4.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; const proxied = cfRec.proxied ?? false; const existing = repos4.findDnsByCfId(db, domain.id, cfId); if (existing) { const contentMatch = existing.content === cfRec.content && existing.ttl === cfRec.ttl && existing.proxied === proxied && existing.name === cfRec.name && existing.record_type.toUpperCase() === cfRec.type.toUpperCase(); if (!contentMatch && existing.sync_status !== SYNC_PENDING_PUSH2) { repos4.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT2, cfId, null); changed += 1; } else if (contentMatch && existing.sync_status === SYNC_CONFLICT2) { repos4.setDnsSyncStatus(db, existing.id, SYNC_SYNCED2, cfId, null); changed += 1; } } else { repos4.insertDnsRecord( db, domain.id, cfRec.type, cfRec.name, cfRec.content, cfRec.ttl, proxied, cfRec.priority ?? null, SYNC_SYNCED2, "cloudflare", cfId ); changed += 1; } } for (const rec of local) { if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) { if (rec.sync_status !== "pending_delete") { repos4.setDnsSyncStatus( db, rec.id, SYNC_CONFLICT2, rec.cf_record_id, "missing in 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); repos4.upsertSubdomain(db, domain.id, label, fqdn); changed += 1; } repos4.setDomainLastSynced(db, domain.id); return changed; } async function syncDomain(db, cf, domainId) { const jobId = randomUUID(); repos4.createSyncJob(db, jobId, domainId); const domain = repos4.getDomain(db, domainId); try { const changes = await pullSync(db, cf, domain); repos4.finishSyncJob(db, jobId, "completed", `${changes} changes`); return { jobId, changes }; } catch (e) { repos4.finishSyncJob( db, jobId, "failed", e instanceof Error ? e.message : String(e) ); throw e; } } async function syncAll(db, cf) { const jobId = randomUUID(); repos4.createSyncJob(db, jobId, null); const all = repos4.listAllDomains(db); let total = 0; for (const domain of all) { try { total += await pullSync(db, cf, domain); } catch { } } repos4.finishSyncJob(db, jobId, "completed", `${total} total changes`); return jobId; } function getJob(db, jobId) { return repos4.getSyncJob(db, jobId); } // src/services/domain-service.ts function listDomains(db, groupId) { return repos5.listDomainsEnriched(db, groupId); } function getDomain(db, id) { return repos5.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((z8) => z8.name.toLowerCase() === trimmed.toLowerCase()); if (!zone) { const names = zones.map((z8) => z8.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 repos5.createDomain(db, groupId, zone.name, zone.id); } function updateDomain(db, id, groupId, status) { return repos5.updateDomain(db, id, groupId, status); } function deleteDomain(db, id) { repos5.deleteDomain(db, id); } async function setDomainServices2(db, domainId, serviceIds) { return setDomainServices(db, domainId, serviceIds); } async function importZoneRecords(db, cf, domainId) { const domain = repos5.getDomain(db, domainId); return pullSync(db, cf, domain); } // src/services/service-config-service.ts function fqdnToDisplay(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; } async function collectKnownZones(db, cf) { const dbDomains = repos6.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; } async function buildView(db, serviceId) { const service = repos6.getService(db, serviceId); const ips = repos6.listServiceIps(db, serviceId); const bindings = repos6.listBindingsByService(db, serviceId); const domainViews = bindings.map((binding) => { const records = repos6.listRecordsForBinding(db, binding.id); const statuses = records.map((r) => r.sync_status); const targetIps = repos6.listBindingIps(db, binding.id); const linkedCname = records.find( (record) => record.record_type.toUpperCase() === "CNAME" ); const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null; return { binding_id: binding.id, domain_id: binding.domain_id, zone_name: binding.zone_name, hostname: binding.hostname, fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), record_type: targetCname ? "CNAME" : "A", target_ips: targetCname ? [] : targetIps, target_cname: targetCname, sync_status: aggregateSyncStatus(statuses) }; }); return { id: service.id, name: service.name, slug: service.slug, service_group_id: service.service_group_id, subdomain: service.subdomain, enabled: service.enabled, computed_fqdn: null, created_at: service.created_at, updated_at: service.updated_at, ips, domains: domainViews }; } async function listViews(db) { return Promise.all( repos6.listServices(db).map((s) => buildView(db, s.id)) ); } async function getView(db, id) { repos6.getService(db, id); return buildView(db, id); } async function listGroupViews(db) { const groups = repos6.listServiceGroups(db); const groupViews = await Promise.all( groups.map(async (group) => { const services = repos6.listServicesByGroup(db, group.id); const serviceViews = await Promise.all( services.map((s) => buildView(db, s.id)) ); return { ...group, services: serviceViews }; }) ); const ungroupedServices = repos6.listUngroupedServices(db); const ungrouped = await Promise.all( ungroupedServices.map((s) => buildView(db, s.id)) ); return { groups: groupViews, ungrouped }; } function shouldPushDns(db, service) { if (!service.enabled) return false; if (!service.service_group_id) return true; const group = repos6.getServiceGroup(db, service.service_group_id); return group.enabled; } async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) { const domain = repos6.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" ); if (existingCname) { effectiveCname = existingCname.content; repos6.setBindingCnameTarget(db, bindingId, effectiveCname); repos6.replaceBindingIps(db, bindingId, []); } } if (effectiveCname) { await syncBindingCnameDns( db, cf, bindingId, domainId, hostname, effectiveCname ); return; } await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); } async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget) { const domain = repos6.getDomain(db, domainId); const zoneName = domain.zone_name; const normalized = normalizeCnameTarget(cnameTarget, zoneName); const existingRecords = repos6.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "A") { repos6.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } const refreshed = repos6.listRecordsForBinding(db, bindingId); const existingCname = refreshed.find( (record) => record.record_type.toUpperCase() === "CNAME" ); let recordId; if (existingCname) { if (!cnameContentMatches(existingCname.content, normalized, zoneName) || existingCname.name !== hostname) { await update(db, cf, domainId, existingCname.id, { record_type: "CNAME", name: hostname, content: normalized, proxied: false }); } recordId = existingCname.id; } else { const adopted = await findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "CNAME", normalized ); if (adopted) { repos6.linkBindingRecord(db, bindingId, adopted.id); if (!cnameContentMatches(adopted.content, normalized, zoneName)) { await update(db, cf, domainId, adopted.id, { record_type: "CNAME", name: hostname, content: normalized, proxied: false }); } recordId = adopted.id; } else { const record = await create(db, cf, domainId, { record_type: "CNAME", name: hostname, content: normalized, ttl: 1, proxied: false }); repos6.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } repos6.setBindingDnsRecordId(db, bindingId, recordId); repos6.setBindingCnameTarget(db, bindingId, normalized); } async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) { const domain = repos6.getDomain(db, domainId); const zoneName = domain.zone_name; const existingRecords = repos6.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "CNAME") { repos6.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } else if (!desiredIps.includes(record.content)) { repos6.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } repos6.setBindingCnameTarget(db, bindingId, null); if (desiredIps.length === 0) { repos6.setBindingDnsRecordId(db, bindingId, null); return; } const refreshed = repos6.listRecordsForBinding(db, bindingId); let primaryId = null; for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); let recordId; if (existing) { if (existing.name !== hostname) { await update(db, cf, domainId, existing.id, { record_type: "A", name: hostname, content: ip, proxied: false }); } recordId = existing.id; } else { const adopted = await findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "A", ip ); if (adopted) { repos6.linkBindingRecord(db, bindingId, adopted.id); recordId = adopted.id; } else { const record = await create(db, cf, domainId, { record_type: "A", name: hostname, content: ip, ttl: 1, proxied: false }); repos6.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } if (primaryId == null) primaryId = recordId; } repos6.setBindingDnsRecordId(db, bindingId, primaryId); } async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) { await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null); } async function cleanupServiceDnsOnly(db, cf, serviceId) { const bindings = repos6.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 cnameContentMatches(left, right, zoneName) { return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName); } function dnsHostnameMatches(recordName, hostname, zoneName) { const label = dnsNameToSubdomainLabel2(recordName, zoneName); if (label != null) return label === hostname; return recordName === hostname; } function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) { const records = repos6.listDnsByDomain(db, domainId); return records.find( (record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsHostnameMatches(record.name, hostname, zoneName) ) ?? null; } async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content) { const local = findLocalDnsRecord( db, domainId, zoneName, hostname, recordType, content ); if (local) return local; const domain = repos6.getDomain(db, domainId); const remote = await cf.listDnsRecords(domain.cf_zone_id); 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 (!dnsHostnameMatches(cfRec.name, hostname, zoneName)) continue; if (!cfRec.id) continue; const existing = repos6.findDnsByCfId(db, domainId, cfRec.id); if (existing) return existing; return repos6.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) { return findOrImportDnsRecord( db, cf, domainId, zoneName, hostname, "A", content ); } async function serviceBindingsExistInDns(db, cf, serviceId) { const bindings = repos6.listBindingsByService(db, serviceId); if (bindings.length === 0) return false; for (const binding of bindings) { const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) { const record = await findOrImportDnsRecord( db, cf, binding.domain_id, binding.zone_name, binding.hostname, "CNAME", cnameTarget ); if (!record) return false; continue; } const targetIps = repos6.listBindingIps(db, binding.id); if (targetIps.length === 0) return false; for (const ip of targetIps) { const record = await findOrImportDnsARecord( db, cf, binding.domain_id, binding.zone_name, binding.hostname, ip ); if (!record) return false; } } return true; } async function syncServiceBindingsToDns(db, cf, serviceId) { const bindings = repos6.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 = repos6.listBindingIps(db, binding.id); return targetIps.length > 0; }); const ips = repos6.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"); } 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 ); continue; } const targetIps = repos6.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 ${fqdnToDisplay(binding.hostname, binding.zone_name)}` ); } validateTargetIpsInPool(targetIps, ips); await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, targetIps, null ); } } async function collectGroupDnsIps(db, groupId) { const services = repos6.listServicesByGroup(db, groupId); const ips = []; for (const service of services) { if (!service.enabled) continue; const bindings = repos6.listBindingsByService(db, service.id); for (const binding of bindings) { for (const ip of repos6.listBindingIps(db, binding.id)) { if (!ips.includes(ip)) ips.push(ip); } } } ips.sort(); return ips; } async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps) { const existingRecords = repos6.listGroupDnsRecords(db, groupId); for (const record of existingRecords) { if (!desiredIps.includes(record.content)) { repos6.unlinkGroupDnsRecord(db, groupId, record.id); await deleteRecord(db, cf, domainId, record.id); } } if (desiredIps.length === 0) return; const refreshed = repos6.listGroupDnsRecords(db, groupId); for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { if (existing.name !== hostname) { await update(db, cf, domainId, existing.id, { record_type: "A", name: hostname, content: ip, proxied: false }); } continue; } const domain = repos6.getDomain(db, domainId); const adopted = await findOrImportDnsARecord( db, cf, domainId, domain.zone_name, hostname, ip ); if (adopted) { repos6.linkGroupDnsRecord(db, groupId, adopted.id); continue; } const record = await create(db, cf, domainId, { record_type: "A", name: hostname, content: ip, ttl: 1, proxied: false }); repos6.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 = repos6.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 = repos6.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) { const group = repos6.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 desiredIps = await collectGroupDnsIps(db, groupId); await syncGroupDomainDnsRecords( db, cf, groupId, domainId, hostname, desiredIps ); } async function syncGroupDomainForService(db, cf, serviceId) { const service = repos6.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 = repos6.getServiceGroup(db, groupId); if (!group.enabled || !group.domain?.trim()) return; const services = repos6.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 fqdnToDisplay(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 = repos6.findDomainByZoneName(db, zoneName); if (!domain) return; const services = repos6.listServicesByGroup(db, groupId); for (const service of services) { const binding = repos6.findBinding( db, service.id, domain.id, hostname ); if (!binding) continue; await cleanupBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname ); repos6.deleteBinding(db, binding.id); } } async function updateConfig(db, cf, id, req) { if (req.name && req.slug) { repos6.updateService(db, id, req.name, req.slug); } else if (req.name) { const existing = repos6.getService(db, id); repos6.updateService(db, id, req.name, existing.slug); } else if (req.slug) { const existing = repos6.getService(db, id); repos6.updateService(db, id, existing.name, req.slug); } if (req.service_group_id !== void 0) { repos6.setServiceGroup(db, id, req.service_group_id); } const ipsUpdated = req.ips !== void 0; const knownZones = await collectKnownZones(db, cf); const ips = req.ips ? normalizeIps(req.ips) : repos6.listServiceIps(db, id); if (ipsUpdated) repos6.replaceServiceIps(db, id, ips); const keptBindingIds = []; let service = repos6.getService(db, id); const pushDns = shouldPushDns(db, service); if (req.domains) { if (req.domains.length > 0) { 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 = repos6.findBinding(db, id, domainId, hostname) ?? repos6.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); repos6.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps); repos6.setBindingCnameTarget(db, binding.id, targetCname); if (pushDns) { await syncBindingDns( db, cf, binding.id, domainId, hostname, targetIps, targetCname ); } } const removed = repos6.bindingsToRemove(db, id, keptBindingIds); for (const binding of removed) { await cleanupBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname ); } repos6.deleteBindingsExcept(db, id, keptBindingIds); } } else if (ipsUpdated) { const bindings = repos6.listBindingsByService(db, id); for (const binding of bindings) { const targetIps = repos6.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 ${fqdnToDisplay(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 = repos6.getService(db, id); if (shouldPushDns(db, service)) { await syncServiceBindingsToDns(db, cf, id); await syncGroupDomainForService(db, cf, id); } else if (req.domains && req.domains.length > 0 && !service.enabled && await serviceBindingsExistInDns(db, cf, id)) { repos6.setServiceEnabled(db, id, true); await syncServiceBindingsToDns(db, cf, id); await syncGroupDomainForService(db, cf, id); } return buildView(db, id); } async function createGroup2(db, cf, body) { const groupType = body.type?.trim() || "custom"; const domain = await normalizeGroupDomain(db, cf, body.domain); return repos6.createServiceGroup( db, body.name, groupType, body.icon ?? null, domain ); } async function updateGroup2(db, cf, id, body) { const groupType = body.type?.trim() || "custom"; const previous = repos6.getServiceGroup(db, id); 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 = repos6.updateServiceGroup( db, id, body.name, groupType, body.icon ?? null, domain ); if (!domain && group.enabled) { repos6.setServiceGroupEnabled(db, id, false); group = repos6.getServiceGroup(db, id); } await syncEnabledServicesInGroup(db, cf, id); return group; } function deleteGroup2(db, id) { repos6.deleteServiceGroup(db, id); } async function toggleService(db, cf, serviceId, enabled) { const service = repos6.getService(db, serviceId); if (enabled && service.service_group_id) { const group = repos6.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"); } } repos6.setServiceEnabled(db, serviceId, enabled); if (!enabled) { await cleanupServiceDnsOnly(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); return buildView(db, serviceId); } await syncServiceBindingsToDns(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); return buildView(db, serviceId); } async function toggleGroup(db, cf, groupId, enabled) { const group = repos6.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"); } repos6.setServiceGroupEnabled(db, groupId, enabled); if (!enabled) { const services = repos6.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { repos6.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) { repos6.getServiceGroup(db, groupId); } repos6.reorderServices(db, groupId, serviceIds); } // 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 (request) => { return listViews(request.server.db); }); app2.patch("/services/reorder", async (request) => { const body = reorderServicesSchema.parse(request.body); reorderServices( request.server.db, body.group_id, body.service_ids ); return { ok: true }; }); app2.post("/services", async (request) => { const body = createSchema.parse(request.body); const service = repos7.createService( request.server.db, body.name, body.slug ); if (body.service_group_id != null) { repos7.setServiceGroup( request.server.db, service.id, body.service_group_id ); } return getView(request.server.db, service.id); }); app2.get("/services/:id", async (request) => { const { id } = request.params; return getView(request.server.db, Number(id)); }); app2.patch("/services/:id", async (request) => { const { id } = request.params; return updateConfig( request.server.db, request.server.cf, Number(id), request.body ); }); app2.delete("/services/:id", async (request) => { const { id } = request.params; repos7.deleteService(request.server.db, Number(id)); return { deleted: true }; }); app2.patch("/services/:id/toggle", async (request) => { const { id } = request.params; const body = z3.object({ enabled: z3.boolean() }).parse(request.body); return toggleService( request.server.db, request.server.cf, Number(id), body.enabled ); }); } // src/routes/service-groups.ts import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared"; async function serviceGroupRoutes(app2) { const bodySchema = createServiceGroupSchema; app2.get("/service-groups", async (request) => { return listGroupViews(request.server.db); }); app2.post("/service-groups", async (request) => { const body = bodySchema.parse(request.body); return createGroup2( request.server.db, request.server.cf, body ); }); app2.patch("/service-groups/:id", async (request) => { const { id } = request.params; const body = bodySchema.parse(request.body); return updateGroup2( request.server.db, request.server.cf, Number(id), body ); }); app2.delete("/service-groups/:id", async (request) => { const { id } = request.params; deleteGroup2(request.server.db, Number(id)); return { deleted: true }; }); app2.patch("/service-groups/:id/toggle", async (request) => { const { id } = request.params; const body = toggleEnabledSchema.parse(request.body); return toggleGroup( request.server.db, request.server.cf, Number(id), body.enabled ); }); } // src/routes/service-bindings.ts import { z as z4 } from "zod"; 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() }); app2.get("/service-bindings", async (request) => { return listAll(request.server.db); }); app2.post("/service-bindings", async (request) => { const body = createSchema.parse(request.body); return create2( request.server.db, request.server.cf, body ); }); app2.get("/service-bindings/:id", async (request) => { const { id } = request.params; const { repos: repos10 } = await import("@cfdm/db"); return repos10.getBindingView(request.server.db, Number(id)); }); app2.patch("/service-bindings/:id", async (request) => { const { id } = request.params; const body = updateSchema.parse(request.body); return update2( request.server.db, request.server.cf, Number(id), body ); }); app2.delete("/service-bindings/:id", async (request) => { const { id } = request.params; remove(request.server.db, Number(id)); return { deleted: true }; }); app2.get("/domains/:id/service-bindings", async (request) => { const { id } = request.params; return listByDomain(request.server.db, Number(id)); }); } // src/routes/domains.ts import { z as z5 } from "zod"; async function domainRoutes(app2) { const createSchema = z5.object({ zone_name: z5.string(), group_id: z5.number().nullable().optional() }); const updateSchema = z5.object({ group_id: z5.number().nullable().optional(), status: z5.string().optional() }); app2.get("/domains", async (request) => { const query = request.query; const groupId = query.group_id ? Number(query.group_id) : void 0; return listDomains(request.server.db, groupId); }); app2.post("/domains", async (request) => { const body = createSchema.parse(request.body); return createDomain( request.server.db, request.server.cf, body.group_id ?? null, body.zone_name ); }); app2.get("/domains/:id", async (request) => { const { id } = request.params; return getDomain(request.server.db, Number(id)); }); app2.patch("/domains/:id", async (request) => { const { id } = request.params; const body = updateSchema.parse(request.body); const existing = getDomain(request.server.db, Number(id)); return updateDomain( request.server.db, Number(id), body.group_id !== void 0 ? body.group_id : existing.group_id, body.status ?? existing.status ); }); app2.delete("/domains/:id", async (request) => { const { id } = request.params; deleteDomain(request.server.db, Number(id)); return { deleted: true }; }); app2.post("/domains/:id/import", async (request) => { const { id } = request.params; const imported = await importZoneRecords( request.server.db, request.server.cf, Number(id) ); return { imported }; }); app2.put("/domains/:id/services", async (request) => { const { id } = request.params; const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request.body); const serviceIds = await setDomainServices2( request.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 (request) => { const { id } = request.params; const q = request.query; return list(request.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 (request) => { const { id } = request.params; const body = createSchema.parse(request.body); return create( request.server.db, request.server.cf, Number(id), body ); }); app2.post("/domains/:id/dns/bulk", async (request) => { const { id } = request.params; const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request.body); return bulk( request.server.db, request.server.cf, Number(id), body.operations ); }); app2.get("/domains/:id/dns/:recordId", async (request) => { const { id, recordId } = request.params; return get( request.server.db, Number(id), Number(recordId) ); }); app2.patch("/domains/:id/dns/:recordId", async (request) => { const { id, recordId } = request.params; return update( request.server.db, request.server.cf, Number(id), Number(recordId), request.body ); }); app2.delete("/domains/:id/dns/:recordId", async (request) => { const { id, recordId } = request.params; await deleteRecord( request.server.db, request.server.cf, Number(id), Number(recordId) ); return { deleted: true }; }); app2.post("/domains/:id/dns/:recordId/resolve", async (request) => { const { id, recordId } = request.params; const body = z6.object({ source: z6.string() }).parse(request.body); return resolveConflict( request.server.db, request.server.cf, Number(id), Number(recordId), body ); }); } // src/routes/subdomains.ts import { updateSubdomainSchema } from "@cfdm/shared"; import { repos as repos8 } from "@cfdm/db"; import { z as z7 } from "zod"; async function subdomainRoutes(app2) { app2.get("/domains/:id/subdomains", async (request) => { const { id } = request.params; repos8.getDomain(request.server.db, Number(id)); return repos8.listSubdomainsByDomain(request.server.db, Number(id)); }); app2.post("/domains/:id/subdomains", async (request) => { const { id } = request.params; const body = z7.object({ name: z7.string() }).parse(request.body); const domain = repos8.getDomain(request.server.db, Number(id)); const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; return repos8.createSubdomain( request.server.db, Number(id), body.name, fqdn ); }); app2.get("/subdomains/:id", async (request) => { const { id } = request.params; return repos8.getSubdomain(request.server.db, Number(id)); }); app2.patch("/subdomains/:id", async (request) => { const { id } = request.params; const body = updateSubdomainSchema.parse(request.body); const sub = repos8.getSubdomain(request.server.db, Number(id)); const domain = repos8.getDomain(request.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; } return repos8.updateSubdomain(request.server.db, Number(id), patch); }); app2.delete("/subdomains/:id", async (request) => { const { id } = request.params; repos8.deleteSubdomain(request.server.db, Number(id)); return { deleted: true }; }); } // src/services/certificate-service.ts import { connect } from "net"; import { connect as tlsConnect } from "tls"; import { repos as repos9 } from "@cfdm/db"; import { CERT_ERROR, CERT_UNKNOWN, certStatusFromExpiry as certStatusFromExpiry2 } from "@cfdm/shared"; function listCertificates(db, status) { return repos9.listCertificates(db, status); } function getCertificate(db, id) { return repos9.getCertificate(db, id); } async function checkHostname(hostname) { return new Promise((resolve4) => { const socket = connect({ host: hostname, port: 443, timeout: 1e4 }); socket.on( "error", (e) => resolve4({ expiresAt: null, error: e.message }) ); socket.on("timeout", () => { socket.destroy(); resolve4({ 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) { resolve4({ expiresAt: null, error: "no peer certificates" }); return; } resolve4({ expiresAt: new Date(cert.valid_to), error: null }); } ); tlsSocket.on( "error", (e) => resolve4({ expiresAt: null, error: e.message }) ); }); }); } async function checkAndStore(db, domainId, subdomainId, hostname) { const { expiresAt, error } = await checkHostname(hostname); if (error) { return repos9.upsertCertificateCheck( db, domainId, subdomainId, hostname, null, CERT_ERROR, error ); } if (expiresAt) { const days = Math.floor( (expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24) ); return repos9.upsertCertificateCheck( db, domainId, subdomainId, hostname, expiresAt.toISOString(), certStatusFromExpiry2(days), null ); } return repos9.upsertCertificateCheck( db, domainId, subdomainId, hostname, null, CERT_UNKNOWN, "unknown expiry" ); } async function runAllChecks(db) { let count = 0; for (const domain of repos9.listAllDomains(db)) { await checkAndStore(db, domain.id, null, domain.zone_name); count += 1; } for (const sub of repos9.listAllSubdomains(db)) { await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn); count += 1; } return count; } function statusSummary(db) { return repos9.countCertificatesByStatus(db); } // src/routes/certificates.ts async function certificateRoutes(app2) { app2.get("/certificates", async (request) => { const query = request.query; return listCertificates( request.server.db, query.status ); }); app2.get("/certificates/summary", async (request) => { return statusSummary(request.server.db); }); app2.post("/certificates/check", async (request) => { const checked = await runAllChecks(request.server.db); return { checked }; }); app2.get("/certificates/:id", async (request) => { const { id } = request.params; return getCertificate( request.server.db, Number(id) ); }); } // src/routes/sync.ts async function syncRoutes(app2) { app2.post("/sync", async (request) => { const jobId = await syncAll( request.server.db, request.server.cf ); return { job_id: jobId }; }); app2.post("/domains/:id/sync", async (request) => { const { id } = request.params; const result = await syncDomain( request.server.db, request.server.cf, Number(id) ); return { job_id: result.jobId, changes: result.changes }; }); app2.get("/sync/jobs/:id", async (request) => { const { id } = request.params; return getJob(request.server.db, id); }); } // src/app.ts import { AsyncTask, CronJob } 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( 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); }, { 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) { await app2.register(import("@fastify/schedule")); const certTask = new AsyncTask( "certificate-check", async () => { const n = await runAllChecks(app2.db); app2.log.info({ checked: n }, "certificate check completed"); }, (err) => { app2.log.warn({ err }, "certificate check failed"); } ); app2.scheduler.addCronJob( new CronJob( { cronExpression: config2.certCheckCron }, certTask, { preventOverrun: true } ) ); } return app2; } // src/server.ts for (const path of [ resolve3(import.meta.dirname, "../../../.env"), ".env", "../.env" ]) { if (!existsSync(path)) continue; const content = readFileSync(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); }