// 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 * * *", healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *", healthDegradedFailures: Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1, healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2, healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1e3, 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, updateServiceConfigSchema } 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, dnsRecordNamesMatch as dnsRecordNamesMatch2, 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 repos2 } 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); repos2.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 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; const name = normalizeDnsRecordName(req.name, domain.zone_name); validateDnsRecord(req.record_type, name, req.content, ttl, proxied); const record = repos2.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 = repos2.getDomain(db, domainId); const existing = repos2.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); 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, dnsRecordNamesMatch, subdomainLabelToFqdn } from "@cfdm/shared"; import { randomUUID } 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) { repos4.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) { repos4.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 = 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; let existing = repos4.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 { repos4.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 = repos4.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") { repos4.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()) { repos4.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); 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, certMonitoring) { return repos5.updateDomain(db, id, groupId, status, certMonitoring); } 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; } function isHealthy(state) { return state === "up" || state === "unknown"; } function selectActiveIpsByMode(config2, rows) { if (rows.length === 0) return []; const healthy = rows.filter((r) => isHealthy(r.health)); const pool = healthy.length > 0 ? healthy : rows; if (config2.lb_mode === "failover") { 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 (healthy.length > 0) { return primaries.map((r) => r.ip); } return [sorted[0].ip]; } if (config2.lb_mode === "weighted") { return pool.map((r) => r.ip); } return pool.map((r) => r.ip); } function getBindingLbState(db, bindingId) { const binding = repos6.getBinding(db, bindingId); const ipMetas = repos6.listBindingIpsWithMeta(db, bindingId); const rows = ipMetas.map((entry) => { const status = repos6.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 = repos6.getServiceGroup(db, groupId); const services = repos6.listServicesByGroup(db, groupId); const seen = /* @__PURE__ */ new Map(); for (const service of services) { if (!service.enabled) continue; const bindings = repos6.listBindingsByService(db, service.id); for (const binding of bindings) { const ipMetas = repos6.listBindingIpsWithMeta(db, binding.id); for (const entry of ipMetas) { const status = repos6.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 (isHealthy(existing.health) && status && !isHealthy(status.status)) { existing.health = status.status; } } } } } return { config: { lb_mode: group.lb_mode, health_check_enabled: group.health_check_enabled }, rows: [...seen.values()] }; } function computeActiveIps(db, scope, refId) { const state = scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId); return selectActiveIpsByMode(state.config, state.rows); } 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 targetIpsWithMeta = repos6.listBindingIpsWithMeta(db, 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; } 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_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, sync_status: aggregateSyncStatus(statuses) }; }); 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, 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) || !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 ); 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: 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 }); 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 (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) { await update(db, cf, domainId, existing.id, { record_type: "A", name: dnsNameForBinding(hostname, zoneName), 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: dnsNameForBinding(hostname, zoneName), 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 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 = repos6.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) { 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 (!dnsRecordNamesMatch2(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; } let 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); if (binding.health_check_enabled) { const activeIps = computeActiveIps(db, "binding", binding.id); if (activeIps.length > 0) { targetIps = activeIps; } } 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 domain = repos6.getDomain(db, domainId); const zoneName = domain.zone_name; 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 (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) { await update(db, cf, domainId, existing.id, { record_type: "A", name: dnsNameForBinding(hostname, zoneName), content: ip, proxied: false }); } continue; } const adopted = await findOrImportDnsARecord( db, cf, domainId, zoneName, hostname, ip ); if (adopted) { repos6.linkGroupDnsRecord(db, groupId, adopted.id); continue; } const record = await create(db, cf, domainId, { record_type: "A", name: dnsNameForBinding(hostname, zoneName), 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 = group.health_check_enabled ? computeActiveIps(db, "group", groupId) : 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); } if (req.lb_weight !== void 0 || req.lb_priority !== void 0) { const existing = repos6.getService(db, id); repos6.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) : 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); 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 })); repos6.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); repos6.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) { repos6.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 }); } if (pushDns) { let effectiveIps = targetIps; const refreshedBinding = repos6.getBinding(db, binding.id); if (refreshedBinding.health_check_enabled) { const activeIps = computeActiveIps(db, "binding", binding.id); if (activeIps.length > 0) { effectiveIps = activeIps; } } await syncBindingDns( db, cf, binding.id, domainId, hostname, effectiveIps, 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, { 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 } ); } 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, { 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 } ); 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); } async function reconcileDnsForTarget(db, cf, scope, refId) { if (scope === "binding") { const binding = repos6.getBinding(db, refId); if (!binding.health_check_enabled) return; const service = repos6.getService(db, binding.service_id); if (!shouldPushDns(db, service)) return; const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) return; const ips = repos6.listServiceIps(db, service.id); const targetIps = repos6.listBindingIps(db, binding.id); validateTargetIpsInPool(targetIps, ips); const activeIps = computeActiveIps(db, "binding", refId); const desiredIps = activeIps.length > 0 ? activeIps : targetIps; await syncBindingDns( db, cf, binding.id, binding.domain_id, binding.hostname, desiredIps, null ); return; } const group = repos6.getServiceGroup(db, refId); if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { return; } await syncGroupDomainDns(db, cf, refId); } // 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; const body = updateServiceConfigSchema.parse(request.body); return updateConfig( request.server.db, request.server.cf, Number(id), 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, updateServiceGroupSchema } from "@cfdm/shared"; async function serviceGroupRoutes(app2) { app2.get("/service-groups", async (request) => { return listGroupViews(request.server.db); }); app2.post("/service-groups", async (request) => { const body = createServiceGroupSchema.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 = updateServiceGroupSchema.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: repos11 } = await import("@cfdm/db"); return repos11.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 { 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 (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 = updateDomainSchema.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, body.cert_monitoring ); }); 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; } if (body.cert_monitoring !== void 0) { patch.cert_monitoring = body.cert_monitoring; } 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_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_UNKNOWN, certStatusFromExpiry as certStatusFromExpiry2, fqdnToDisplay as fqdnToDisplay2, parseFqdn as parseFqdn2, shouldMonitorService } 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" ); } function resolveMonitoringMode(domain, subdomain, fqdn) { if (subdomain) { return subdomain.cert_monitoring; } if (fqdn === domain.zone_name) { return domain.cert_monitoring; } return CERT_MONITOR_AUTO; } function bindingSubdomain(db, domainId, hostname) { if (hostname === "@") return null; return repos9.findSubdomainByDomainAndName(db, domainId, hostname); } function buildServiceCertificateFqdns(db) { const result = /* @__PURE__ */ new Map(); for (const binding of repos9.listAllBindings(db)) { const service = repos9.getService(db, binding.service_id); const group = service.service_group_id ? repos9.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 fqdn = fqdnToDisplay2(binding.hostname, binding.zone_name); result.set(fqdn, { domainId: binding.domain_id, subdomainId: subdomain?.id ?? null, hostname: fqdn }); } const knownZones = repos9.listAllDomains(db).map((d) => d.zone_name); for (const group of repos9.listServiceGroups(db)) { if (!group.enabled || !group.domain?.trim()) continue; const parsed = parseFqdn2(group.domain, knownZones); if (!parsed) continue; const domain = repos9.findDomainByZoneName(db, parsed.zoneName); if (!domain) continue; const subdomain = parsed.hostname === "@" ? null : bindingSubdomain(db, domain.id, parsed.hostname); if (subdomain && !subdomain.enabled) continue; result.set(parsed.fqdn, { domainId: domain.id, subdomainId: subdomain?.id ?? null, hostname: parsed.fqdn }); } return result; } function resolveCertificateTargets(db) { const serviceFqdns = buildServiceCertificateFqdns(db); const targets = /* @__PURE__ */ new Map(); for (const domain of repos9.listAllDomains(db)) { if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue; if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) { targets.set(domain.zone_name, { domainId: domain.id, subdomainId: null, hostname: domain.zone_name }); } } for (const sub of repos9.listAllSubdomains(db)) { if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue; if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) { targets.set(sub.fqdn, { domainId: sub.domain_id, subdomainId: sub.id, hostname: sub.fqdn }); } } for (const [fqdn, meta] of serviceFqdns) { const domain = repos9.getDomain(db, meta.domainId); const subdomain = meta.subdomainId ? repos9.getSubdomain(db, meta.subdomainId) : null; const monitoring = resolveMonitoringMode(domain, subdomain, fqdn); if (monitoring === CERT_MONITOR_SKIPPED) continue; if (monitoring === CERT_MONITOR_AUTO || monitoring === CERT_MONITOR_REQUIRED) { targets.set(fqdn, meta); } } return [...targets.values()]; } async function runAllChecks(db) { const targets = resolveCertificateTargets(db); for (const target of targets) { await checkAndStore( db, target.domainId, target.subdomainId, target.hostname ); } repos9.deleteCertificatesNotIn( db, targets.map((t) => t.hostname) ); return targets.length; } 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/routes/health-check.ts import { healthStatusQuerySchema } from "@cfdm/shared"; // src/services/health-check-service.ts import { connect as connect2 } from "net"; import { repos as repos10 } from "@cfdm/db"; function tcpProbe(ip, port, timeoutMs) { return new Promise((resolve4) => { 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(); resolve4(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 }) ); }); } async function httpProbe(ip, target, timeoutMs) { const started = Date.now(); const path = target.path?.trim() || "/"; const url = `http://${ip}${path.startsWith("/") ? path : `/${path}`}`; const hostHeader = target.hostname || ip; try { const response = await fetch(url, { method: "GET", headers: { Host: hostHeader }, signal: AbortSignal.timeout(timeoutMs), redirect: "manual" }); const latency = Date.now() - started; 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) }; } } 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); } return tcpProbe(target.ip, port, timeoutMs); } function deriveState(ok, latencyMs, prev, thresholds) { if (!ok) { const failures = (prev?.consecutive_failures ?? 0) + 1; if (failures >= thresholds.downFailures) { return { state: "down", failures }; } if (failures >= thresholds.degradedFailures) { return { state: "degraded", failures }; } return { state: "degraded", failures }; } if (latencyMs > thresholds.latencyWarnMs) { return { state: "degraded", failures: 0 }; } return { state: "up", failures: 0 }; } async function runAllChecks2(db, options) { const targets = repos10.listHealthCheckTargets(db); for (const target of targets) { const prev = repos10.getIpHealthStatusRow( db, target.scope, target.ref_id, target.ip ); const result = await probeTarget(target); const { state, failures } = deriveState( result.ok, result.latencyMs, prev ? { consecutive_failures: prev.consecutive_failures, status: prev.status } : null, options.thresholds ); const prevState = prev ? prev.status : null; repos10.upsertIpHealthStatus( db, target.scope, target.ref_id, target.ip, state, result.latencyMs, failures, result.error ); if (prevState !== state) { options.onStatusChange?.(target, prevState, state); } } return targets.length; } function listStatus(db, scope, refId) { return repos10.listIpHealthStatus(db, scope, refId); } // src/routes/health-check.ts async function healthCheckRoutes(app2) { app2.get("/health-status", async (request) => { const query = healthStatusQuerySchema.parse(request.query); return listStatus( request.server.db, query.scope, query.ref_id ); }); app2.post("/health-check/run", async (request) => { const config2 = request.server.config; const checked = await runAllChecks2(request.server.db, { thresholds: { degradedFailures: config2.healthDegradedFailures, downFailures: config2.healthDownFailures, latencyWarnMs: config2.healthLatencyWarnMs }, onStatusChange: async (target, _prev, _next) => { try { await reconcileDnsForTarget( request.server.db, request.server.cf, target.scope, target.ref_id ); } catch { } } }); return { checked }; }); } // 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); await protectedApi.register(healthCheckRoutes); }, { 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 } ) ); const healthTask = new AsyncTask( "health-check", async () => { const n = await runAllChecks2(app2.db, { thresholds: { degradedFailures: config2.healthDegradedFailures, downFailures: config2.healthDownFailures, latencyWarnMs: config2.healthLatencyWarnMs }, onStatusChange: async (target, _prev, _next) => { try { 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" ); } } }); app2.log.info({ checked: n }, "health check completed"); }, (err) => { app2.log.warn({ err }, "health check failed"); } ); app2.scheduler.addCronJob( new CronJob( { cronExpression: config2.healthCheckCron }, healthTask, { 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); }