Files
cloudflare-domain-manager/apps/api/dist/server.js
T
Denozordec 6d1d8ca4f3
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
2026-06-22 13:52:33 +07:00

2302 lines
71 KiB
JavaScript

// 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);
}