Refactor project to transition from Rust backend to Node.js with Fastify; update Dockerfile and Docker configurations for new build process; enhance local development instructions in CONTRIBUTING.md; implement health checks in Docker Compose; update pnpm-lock.yaml with new dependencies for API and shared packages; revise README.md to reflect new stack and development setup.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled

This commit is contained in:
Denozordec
2026-06-19 12:06:32 +07:00
parent 0a0a1a92f1
commit d11414666f
2065 changed files with 15782 additions and 16699 deletions
+111
View File
@@ -0,0 +1,111 @@
import { resolve } from "node:path";
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js";
import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js";
import corsPlugin from "./plugins/cors.js";
import dbPlugin from "./plugins/db.js";
import errorHandlerPlugin from "./plugins/error-handler.js";
import { authRoutes, healthRoutes } from "./routes/health.js";
import { groupRoutes } from "./routes/groups.js";
import { serviceRoutes } from "./routes/services.js";
import { serviceGroupRoutes } from "./routes/service-groups.js";
import { serviceBindingRoutes } from "./routes/service-bindings.js";
import { domainRoutes } from "./routes/domains.js";
import { dnsRoutes } from "./routes/dns.js";
import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js";
import * as certificateService from "./services/certificate-service.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
config?: AppConfig;
memory?: boolean;
}
export async function buildApp(opts: BuildAppOptions = {}) {
const config = opts.config ?? loadConfig();
const app = Fastify({
logger: { level: config.logLevel },
}).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(import("@fastify/sensible"));
await app.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
await app.register(import("@fastify/rate-limit"), {
max: 300,
timeWindow: "1 minute",
});
await app.register(corsPlugin);
await app.register(errorHandlerPlugin);
await app.register(dbPlugin, { config, memory: opts.memory });
await app.register(cfClientPlugin, { config });
await app.register(authPlugin, { config });
await app.register(healthRoutes);
await app.register(authRoutes, { prefix: "/api/v1" });
await app.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 = config.staticDir ?? resolve(process.cwd(), "static");
if (config.staticDir !== null) {
await app.register(import("@fastify/static"), {
root: staticDir,
wildcard: false,
});
app.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile("index.html");
});
}
if (!opts.memory) {
await app.register(import("@fastify/schedule"));
const certTask = new AsyncTask(
"certificate-check",
async () => {
const n = await certificateService.runAllChecks(app.db);
app.log.info({ checked: n }, "certificate check completed");
},
(err) => {
app.log.warn({ err }, "certificate check failed");
},
);
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: config.certCheckCron },
certTask,
{ preventOverrun: true },
),
);
}
return app;
}
+32
View File
@@ -0,0 +1,32 @@
import { resolve } from "node:path";
export interface AppConfig {
databaseUrl: string;
cloudflareApiToken: string;
jwtSecret: string;
jwtTtlHours: number;
adminUsername: string;
adminPasswordHash: string;
serverPort: number;
staticDir: string | null;
certCheckCron: string;
logLevel: string;
}
export function loadConfig(): AppConfig {
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",
};
}
+68
View File
@@ -0,0 +1,68 @@
import { NotFoundError, ConflictError } from "@cfdm/db";
import { ValidationError } from "@cfdm/shared";
export type ErrorCode =
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "CONFLICT"
| "CLOUDFLARE_ERROR"
| "INTERNAL_ERROR";
export class AppError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = "AppError";
}
static notFound(message: string) {
return new AppError("NOT_FOUND", message, 404);
}
static validation(message: string) {
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: string) {
return new AppError("CONFLICT", message, 409);
}
static cloudflare(message: string) {
return new AppError("CLOUDFLARE_ERROR", message, 502);
}
static internal(message: string) {
return new AppError("INTERNAL_ERROR", message, 500);
}
}
export function toAppError(err: unknown): AppError {
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));
}
export function errorBody(err: AppError) {
return {
error: {
code: err.code,
message: err.message,
},
};
}
+152
View File
@@ -0,0 +1,152 @@
import type {
CfDnsRecord,
CfZone,
CreateDnsRecordPayload,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { withRetry, parseRetryAfter } from "./cf-retry.js";
const BASE_URL = "https://api.cloudflare.com/client/v4";
interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export class CloudflareClient {
constructor(private readonly token: string) {}
private async handleResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ??
"unknown cloudflare error";
throw AppError.cloudflare(`${operation}: ${msg}`);
}
if (body.result === undefined) {
throw AppError.cloudflare(`${operation}: empty result`);
}
return body.result;
}
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
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(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfZone[]>(
response,
"list_zones",
);
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
}
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "get_zone");
}
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
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(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
}
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
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(30_000),
});
return this.handleResponse(response, "create_dns_record");
}
async updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
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(30_000),
},
);
return this.handleResponse(response, "update_dns_record");
}
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
},
);
await this.handleResponse(response, "delete_dns_record");
}
}
+28
View File
@@ -0,0 +1,28 @@
export async function withRetry<T>(
operation: () => Promise<T>,
maxAttempts = 3,
): Promise<T> {
let delay = 500;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
await new Promise((r) => setTimeout(r, delay));
delay *= 2;
}
}
}
throw lastError;
}
export function parseRetryAfter(headers: Headers): number | null {
const value = headers.get("retry-after");
if (!value) return null;
const seconds = Number(value);
return Number.isFinite(seconds) ? seconds * 1000 : null;
}
+6
View File
@@ -0,0 +1,6 @@
export {
validateDnsRecord,
certStatusFromExpiry,
isValidIpv4,
ValidationError,
} from "@cfdm/shared";
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import fp from "fastify-plugin";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
async function authPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
await app.register(import("@fastify/jwt"), {
secret: opts.config.jwtSecret,
});
}
export async function requireAuth(request: FastifyRequest): Promise<void> {
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();
}
}
export default fp(authPlugin, { name: "auth" });
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { CloudflareClient } from "../lib/cf-client.js";
import type { AppConfig } from "../config.js";
declare module "fastify" {
interface FastifyInstance {
cf: CloudflareClient;
config: AppConfig;
}
}
async function cfClientPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
app.decorate("config", opts.config);
app.decorate("cf", new CloudflareClient(opts.config.cloudflareApiToken));
}
export default fp(cfClientPlugin, { name: "cf-client" });
+12
View File
@@ -0,0 +1,12 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
async function corsPlugin(app: FastifyInstance) {
await app.register(import("@fastify/cors"), {
origin: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
});
}
export default fp(corsPlugin, { name: "cors" });
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import {
createDb,
createMemoryDb,
healthCheck,
runMigrations,
type Db,
type Sqlite,
} from "@cfdm/db";
import type { AppConfig } from "../config.js";
declare module "fastify" {
interface FastifyInstance {
db: Db;
sqlite: Sqlite;
}
}
export interface DbPluginOptions {
config?: AppConfig;
memory?: boolean;
}
async function dbPlugin(
app: FastifyInstance,
opts: DbPluginOptions,
) {
const { db, sqlite } = opts.memory
? createMemoryDb()
: createDb(opts.config!.databaseUrl);
runMigrations(sqlite);
app.decorate("db", db);
app.decorate("sqlite", sqlite);
app.addHook("onClose", async () => {
sqlite.close();
});
}
export default fp(dbPlugin, { name: "db" });
export { healthCheck };
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { AppError, errorBody, toAppError } from "../errors.js";
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _request, reply) => {
if (reply.sent) return;
const appErr =
err.statusCode === 401
? AppError.unauthorized()
: toAppError(err);
reply.status(appErr.statusCode).send(errorBody(appErr));
});
}
export default fp(errorHandlerPlugin, { name: "error-handler" });
+29
View File
@@ -0,0 +1,29 @@
import type { FastifyInstance } from "fastify";
import * as certificateService from "../services/certificate-service.js";
export async function certificateRoutes(app: FastifyInstance) {
app.get("/certificates", async (request) => {
const query = request.query as { status?: string };
return certificateService.listCertificates(
request.server.db,
query.status,
);
});
app.get("/certificates/summary", async (request) => {
return certificateService.statusSummary(request.server.db);
});
app.post("/certificates/check", async (request) => {
const checked = await certificateService.runAllChecks(request.server.db);
return { checked };
});
app.get("/certificates/:id", async (request) => {
const { id } = request.params as { id: string };
return certificateService.getCertificate(
request.server.db,
Number(id),
);
});
}
+109
View File
@@ -0,0 +1,109 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as dnsService from "../services/dns-service.js";
export async function dnsRoutes(app: FastifyInstance) {
const createSchema = z.object({
record_type: z.string(),
name: z.string(),
content: z.string(),
ttl: z.number().optional(),
proxied: z.boolean().optional(),
priority: z.number().optional(),
});
app.get("/domains/:id/dns", async (request) => {
const { id } = request.params as { id: string };
const q = request.query as Record<string, string | undefined>;
return dnsService.list(request.server.db, Number(id), {
record_type: q.record_type,
name: q.name,
content: q.content,
proxied: q.proxied != null ? q.proxied === "true" : undefined,
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,
});
});
app.post("/domains/:id/dns", async (request) => {
const { id } = request.params as { id: string };
const body = createSchema.parse(request.body);
return dnsService.create(
request.server.db,
request.server.cf,
Number(id),
body,
);
});
app.post("/domains/:id/dns/bulk", async (request) => {
const { id } = request.params as { id: string };
const body = z
.object({ operations: z.array(z.record(z.unknown())) })
.parse(request.body);
return dnsService.bulk(
request.server.db,
request.server.cf,
Number(id),
body.operations as dnsService.BulkDnsOp[],
);
});
app.get("/domains/:id/dns/:recordId", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
return dnsService.get(
request.server.db,
Number(id),
Number(recordId),
);
});
app.patch("/domains/:id/dns/:recordId", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
return dnsService.update(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
request.body as dnsService.UpdateDnsRequest,
);
});
app.delete("/domains/:id/dns/:recordId", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
await dnsService.deleteRecord(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
);
return { deleted: true };
});
app.post("/domains/:id/dns/:recordId/resolve", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
const body = z.object({ source: z.string() }).parse(request.body);
return dnsService.resolveConflict(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
body,
);
});
}
+75
View File
@@ -0,0 +1,75 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as domainService from "../services/domain-service.js";
export async function domainRoutes(app: FastifyInstance) {
const createSchema = z.object({
zone_name: z.string(),
group_id: z.number().nullable().optional(),
});
const updateSchema = z.object({
group_id: z.number().nullable().optional(),
status: z.string().optional(),
});
app.get("/domains", async (request) => {
const query = request.query as { group_id?: string };
const groupId = query.group_id ? Number(query.group_id) : undefined;
return domainService.listDomains(request.server.db, groupId);
});
app.post("/domains", async (request) => {
const body = createSchema.parse(request.body);
return domainService.createDomain(
request.server.db,
request.server.cf,
body.group_id ?? null,
body.zone_name,
);
});
app.get("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
return domainService.getDomain(request.server.db, Number(id));
});
app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateSchema.parse(request.body);
const existing = domainService.getDomain(request.server.db, Number(id));
return domainService.updateDomain(
request.server.db,
Number(id),
body.group_id !== undefined ? body.group_id : existing.group_id,
body.status ?? existing.status,
);
});
app.delete("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
domainService.deleteDomain(request.server.db, Number(id));
return { deleted: true };
});
app.post("/domains/:id/import", async (request) => {
const { id } = request.params as { id: string };
const imported = await domainService.importZoneRecords(
request.server.db,
request.server.cf,
Number(id),
);
return { imported };
});
app.put("/domains/:id/services", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ service_ids: z.array(z.number()) }).parse(request.body);
const serviceIds = await domainService.setDomainServices(
request.server.db,
Number(id),
body.service_ids,
);
return { service_ids: serviceIds };
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as groupService from "../services/group-service.js";
export async function groupRoutes(app: FastifyInstance) {
const bodySchema = z.object({
name: z.string(),
slug: z.string(),
});
app.get("/groups", async (request) => {
return groupService.listGroups(request.server.db);
});
app.post("/groups", async (request) => {
const body = bodySchema.parse(request.body);
return groupService.createGroup(request.server.db, body.name, body.slug);
});
app.get("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
return groupService.getGroupWithStats(request.server.db, Number(id));
});
app.patch("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
return groupService.updateGroup(
request.server.db,
Number(id),
body.name,
body.slug,
);
});
app.delete("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
groupService.deleteGroup(request.server.db, Number(id));
return { deleted: true };
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { healthCheck } from "../plugins/db.js";
import * as authService from "../services/auth.js";
export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async (request, reply) => {
healthCheck(request.server.sqlite);
return { status: "ok" };
});
app.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,
};
});
}
export async function authRoutes(app: FastifyInstance) {
const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
app.post("/auth/login", async (request, reply) => {
const body = loginSchema.parse(request.body);
const result = await authService.login(
request.server.config,
(payload) => request.server.jwt.sign(payload),
body,
);
return result;
});
}
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as bindingService from "../services/binding-service.js";
export async function serviceBindingRoutes(app: FastifyInstance) {
const createSchema = z.object({
domain_id: z.number(),
service_id: z.number(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
});
const updateSchema = z.object({
service_id: z.number().optional(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
});
app.get("/service-bindings", async (request) => {
return bindingService.listAll(request.server.db);
});
app.post("/service-bindings", async (request) => {
const body = createSchema.parse(request.body);
return bindingService.create(
request.server.db,
request.server.cf,
body,
);
});
app.get("/service-bindings/:id", async (request) => {
const { id } = request.params as { id: string };
const { repos } = await import("@cfdm/db");
return repos.getBindingView(request.server.db, Number(id));
});
app.patch("/service-bindings/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateSchema.parse(request.body);
return bindingService.update(
request.server.db,
request.server.cf,
Number(id),
body,
);
});
app.delete("/service-bindings/:id", async (request) => {
const { id } = request.params as { id: string };
bindingService.remove(request.server.db, Number(id));
return { deleted: true };
});
app.get("/domains/:id/service-bindings", async (request) => {
const { id } = request.params as { id: string };
return bindingService.listByDomain(request.server.db, Number(id));
});
}
+53
View File
@@ -0,0 +1,53 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as serviceConfig from "../services/service-config-service.js";
export async function serviceGroupRoutes(app: FastifyInstance) {
const bodySchema = z.object({
name: z.string(),
type: z.string().optional(),
icon: z.string().optional(),
domain: z.string().optional(),
});
app.get("/service-groups", async (request) => {
return serviceConfig.listGroupViews(request.server.db);
});
app.post("/service-groups", async (request) => {
const body = bodySchema.parse(request.body);
return serviceConfig.createGroup(
request.server.db,
request.server.cf,
body,
);
});
app.patch("/service-groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
return serviceConfig.updateGroup(
request.server.db,
request.server.cf,
Number(id),
body,
);
});
app.delete("/service-groups/:id", async (request) => {
const { id } = request.params as { id: string };
serviceConfig.deleteGroup(request.server.db, Number(id));
return { deleted: true };
});
app.patch("/service-groups/:id/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ enabled: z.boolean() }).parse(request.body);
return serviceConfig.toggleGroup(
request.server.db,
request.server.cf,
Number(id),
body.enabled,
);
});
}
+65
View File
@@ -0,0 +1,65 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
export async function serviceRoutes(app: FastifyInstance) {
const createSchema = z.object({
name: z.string(),
slug: z.string(),
service_group_id: z.number().nullable().optional(),
});
app.get("/services", async (request) => {
return serviceConfig.listViews(request.server.db);
});
app.post("/services", async (request) => {
const body = createSchema.parse(request.body);
const service = repos.createService(
request.server.db,
body.name,
body.slug,
);
if (body.service_group_id != null) {
repos.setServiceGroup(
request.server.db,
service.id,
body.service_group_id,
);
}
return serviceConfig.getView(request.server.db, service.id);
});
app.get("/services/:id", async (request) => {
const { id } = request.params as { id: string };
return serviceConfig.getView(request.server.db, Number(id));
});
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
return serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
request.body as serviceConfig.UpdateServiceConfigRequest,
);
});
app.delete("/services/:id", async (request) => {
const { id } = request.params as { id: string };
repos.deleteService(request.server.db, Number(id));
return { deleted: true };
});
app.patch("/services/:id/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ enabled: z.boolean() }).parse(request.body);
return serviceConfig.toggleService(
request.server.db,
request.server.cf,
Number(id),
body.enabled,
);
});
}
+52
View File
@@ -0,0 +1,52 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { repos } from "@cfdm/db";
export async function subdomainRoutes(app: FastifyInstance) {
app.get("/domains/:id/subdomains", async (request) => {
const { id } = request.params as { id: string };
repos.getDomain(request.server.db, Number(id));
return repos.listSubdomainsByDomain(request.server.db, Number(id));
});
app.post("/domains/:id/subdomains", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ name: z.string() }).parse(request.body);
const domain = repos.getDomain(request.server.db, Number(id));
const fqdn =
body.name === "@"
? domain.zone_name
: `${body.name}.${domain.zone_name}`;
return repos.createSubdomain(
request.server.db,
Number(id),
body.name,
fqdn,
);
});
app.get("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
return repos.getSubdomain(request.server.db, Number(id));
});
app.patch("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ name: z.string() }).parse(request.body);
const sub = repos.getSubdomain(request.server.db, Number(id));
const domain = repos.getDomain(request.server.db, sub.domain_id);
const fqdn = `${body.name}.${domain.zone_name}`;
return repos.updateSubdomain(
request.server.db,
Number(id),
body.name,
fqdn,
);
});
app.delete("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
repos.deleteSubdomain(request.server.db, Number(id));
return { deleted: true };
});
}
+27
View File
@@ -0,0 +1,27 @@
import type { FastifyInstance } from "fastify";
import * as syncService from "../services/sync-service.js";
export async function syncRoutes(app: FastifyInstance) {
app.post("/sync", async (request) => {
const jobId = await syncService.syncAll(
request.server.db,
request.server.cf,
);
return { job_id: jobId };
});
app.post("/domains/:id/sync", async (request) => {
const { id } = request.params as { id: string };
const result = await syncService.syncDomain(
request.server.db,
request.server.cf,
Number(id),
);
return { job_id: result.jobId, changes: result.changes };
});
app.get("/sync/jobs/:id", async (request) => {
const { id } = request.params as { id: string };
return syncService.getJob(request.server.db, id);
});
}
+47
View File
@@ -0,0 +1,47 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
for (const path of [
resolve(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;
}
const config = loadConfig();
if (!config.cloudflareApiToken) {
console.warn(
"CLOUDFLARE_API_TOKEN не задан — импорт доменов из Cloudflare недоступен",
);
}
const 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);
}
+40
View File
@@ -0,0 +1,40 @@
import { verify } from "@node-rs/argon2";
import type { JwtClaims, LoginRequest, LoginResponse } from "@cfdm/shared";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
export async function verifyPassword(
config: AppConfig,
password: string,
): Promise<void> {
if (config.adminPasswordHash === "devplaceholder") {
if (password === "admin") return;
throw AppError.unauthorized();
}
const ok = await verify(config.adminPasswordHash, password);
if (!ok) throw AppError.unauthorized();
}
export async function login(
config: AppConfig,
sign: (payload: JwtClaims) => string,
req: LoginRequest,
): Promise<LoginResponse> {
if (req.username !== config.adminUsername) {
throw AppError.unauthorized();
}
await verifyPassword(config, req.password);
const expiresAt = new Date(
Date.now() + config.jwtTtlHours * 60 * 60 * 1000,
);
const token = sign({
sub: req.username,
exp: Math.floor(expiresAt.getTime() / 1000),
});
return {
token,
expires_at: expiresAt.toISOString(),
};
}
+157
View File
@@ -0,0 +1,157 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { ServiceBindingView } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import * as dnsService from "./dns-service.js";
export interface CreateBindingRequest {
domain_id: number;
service_id: number;
hostname?: string;
target_ip?: string;
}
export interface UpdateBindingRequest {
service_id?: number;
hostname?: string;
target_ip?: string;
}
function normalizeHostname(hostname?: string): string {
const h = hostname?.trim();
return h ? h : "@";
}
async function syncTargetIp(
db: Db,
cf: CloudflareClient,
domainId: number,
bindingId: number,
hostname: string,
dnsRecordId: number | null,
targetIp: string,
): Promise<number> {
if (dnsRecordId) {
await dnsService.update(db, cf, domainId, dnsRecordId, {
record_type: "A",
name: hostname,
content: targetIp,
proxied: false,
});
return dnsRecordId;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: targetIp,
ttl: 1,
proxied: false,
});
repos.setBindingDnsRecordId(db, bindingId, record.id);
return record.id;
}
export function listAll(db: Db): ServiceBindingView[] {
return repos.listAllBindings(db);
}
export function listByDomain(db: Db, domainId: number): ServiceBindingView[] {
repos.getDomain(db, domainId);
return repos.listBindingsByDomain(db, domainId);
}
export async function create(
db: Db,
cf: CloudflareClient,
req: CreateBindingRequest,
): Promise<ServiceBindingView> {
repos.getDomain(db, req.domain_id);
repos.getService(db, req.service_id);
const hostname = normalizeHostname(req.hostname);
const binding = repos.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 repos.getBindingView(db, binding.id);
}
export async function update(
db: Db,
cf: CloudflareClient,
id: number,
req: UpdateBindingRequest,
): Promise<ServiceBindingView> {
const existing = repos.getBinding(db, id);
const serviceId = req.service_id ?? existing.service_id;
if (req.service_id) repos.getService(db, req.service_id);
const hostname = req.hostname
? normalizeHostname(req.hostname)
: existing.hostname;
repos.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 repos.getBindingView(db, id);
}
export function remove(db: Db, id: number): void {
repos.getBinding(db, id);
repos.deleteBinding(db, id);
}
export async function setDomainServices(
db: Db,
domainId: number,
serviceIds: number[],
): Promise<number[]> {
repos.getDomain(db, domainId);
for (const sid of serviceIds) {
repos.getService(db, sid);
}
const existing = repos.listBindingsByDomain(db, domainId);
for (const binding of existing) {
if (!serviceIds.includes(binding.service_id)) {
repos.deleteBinding(db, binding.id);
}
}
for (const sid of serviceIds) {
const already = existing.some((b) => b.service_id === sid);
if (!already) {
repos.insertBinding(db, domainId, sid, "@", null);
}
}
return repos
.listBindingsByDomain(db, domainId)
.map((b) => b.service_id);
}
@@ -0,0 +1,116 @@
import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate } from "@cfdm/shared";
import {
CERT_ERROR,
CERT_UNKNOWN,
certStatusFromExpiry,
} from "@cfdm/shared";
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
return repos.listCertificates(db, status);
}
export function getCertificate(db: Db, id: number): Certificate {
return repos.getCertificate(db, id);
}
export async function checkHostname(
hostname: string,
): Promise<{ expiresAt: Date | null; error: string | null }> {
return new Promise((resolve) => {
const socket = connect({ host: hostname, port: 443, timeout: 10_000 });
socket.on("error", (e) =>
resolve({ expiresAt: null, error: e.message }),
);
socket.on("timeout", () => {
socket.destroy();
resolve({ 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) {
resolve({ expiresAt: null, error: "no peer certificates" });
return;
}
resolve({ expiresAt: new Date(cert.valid_to), error: null });
},
);
tlsSocket.on("error", (e) =>
resolve({ expiresAt: null, error: e.message }),
);
});
});
}
export async function checkAndStore(
db: Db,
domainId: number,
subdomainId: number | null,
hostname: string,
): Promise<Certificate> {
const { expiresAt, error } = await checkHostname(hostname);
if (error) {
return repos.upsertCertificateCheck(
db,
domainId,
subdomainId,
hostname,
null,
CERT_ERROR,
error,
);
}
if (expiresAt) {
const days = Math.floor(
(expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
);
return repos.upsertCertificateCheck(
db,
domainId,
subdomainId,
hostname,
expiresAt.toISOString(),
certStatusFromExpiry(days),
null,
);
}
return repos.upsertCertificateCheck(
db,
domainId,
subdomainId,
hostname,
null,
CERT_UNKNOWN,
"unknown expiry",
);
}
export async function runAllChecks(db: Db): Promise<number> {
let count = 0;
for (const domain of repos.listAllDomains(db)) {
await checkAndStore(db, domain.id, null, domain.zone_name);
count += 1;
}
for (const sub of repos.listAllSubdomains(db)) {
await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn);
count += 1;
}
return count;
}
export function statusSummary(db: Db): Array<[string, number]> {
return repos.countCertificatesByStatus(db);
}
+306
View File
@@ -0,0 +1,306 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { validateDnsRecord } from "../lib/validators.js";
export interface CreateDnsRequest {
record_type: string;
name: string;
content: string;
ttl?: number;
proxied?: boolean;
priority?: number;
}
export interface UpdateDnsRequest {
record_type?: string;
name?: string;
content?: string;
ttl?: number;
proxied?: boolean;
priority?: number;
}
export interface BulkDnsOp {
action: string;
id?: number;
record?: CreateDnsRequest;
}
export interface BulkDnsResult {
id?: number;
success: boolean;
error?: string;
}
export interface ResolveDnsRequest {
source: string;
}
function toCfPayload(
recordType: string,
name: string,
content: string,
ttl: number,
proxied: boolean,
priority: number | null,
): CreateDnsRecordPayload {
return {
type: recordType.toUpperCase(),
name,
content,
ttl,
proxied,
priority: priority ?? undefined,
};
}
async function pushRecord(
db: Db,
cf: CloudflareClient,
domainId: number,
cfZoneId: string,
record: DnsRecord,
): Promise<DnsRecord> {
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);
repos.updateDnsFields(
db,
record.id,
record.record_type,
record.name,
record.content,
record.ttl,
record.proxied,
record.priority,
SYNC_SYNCED,
cfRec.id ?? null,
null,
);
return repos.getDnsRecord(db, domainId, record.id);
} catch (e) {
repos.setDnsSyncStatus(
db,
record.id,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
export async function create(
db: Db,
cf: CloudflareClient,
domainId: number,
req: CreateDnsRequest,
): Promise<DnsRecord> {
const domain = repos.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 = repos.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);
}
export async function update(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
req: UpdateDnsRequest,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.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);
repos.updateDnsFields(
db,
recordId,
recordType,
name,
content,
ttl,
proxied,
priority,
SYNC_PENDING_PUSH,
existing.cf_record_id,
null,
);
const updated = repos.getDnsRecord(db, domainId, recordId);
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
}
export async function deleteRecord(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const record = repos.getDnsRecord(db, domainId, recordId);
repos.markDnsPendingDelete(db, recordId);
if (record.cf_record_id) {
try {
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
} catch (e) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
repos.deleteDnsRecord(db, recordId);
}
export function list(
db: Db,
domainId: number,
filter: DnsListFilter,
): DnsRecord[] {
repos.getDomain(db, domainId);
return repos.listDnsRecords(db, domainId, filter);
}
export function get(db: Db, domainId: number, recordId: number): DnsRecord {
return repos.getDnsRecord(db, domainId, recordId);
}
export async function bulk(
db: Db,
cf: CloudflareClient,
domainId: number,
ops: BulkDnsOp[],
): Promise<BulkDnsResult[]> {
const results: BulkDnsResult[] = [];
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;
}
export async function resolveConflict(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
req: ResolveDnsRequest,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const record = repos.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) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
}
return repos.getDnsRecord(db, domainId, recordId);
}
if (req.source === "local") {
const updated = repos.getDnsRecord(db, domainId, recordId);
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
}
throw AppError.validation("source must be cloudflare or local");
}
+71
View File
@@ -0,0 +1,71 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Domain, DomainListItem } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import * as bindingService from "./binding-service.js";
import * as syncService from "./sync-service.js";
export function listDomains(
db: Db,
groupId?: number,
): DomainListItem[] {
return repos.listDomainsEnriched(db, groupId);
}
export function getDomain(db: Db, id: number): Domain {
return repos.getDomain(db, id);
}
export async function createDomain(
db: Db,
cf: CloudflareClient,
groupId: number | null,
zoneName: string,
): Promise<Domain> {
const trimmed = zoneName.trim();
const zones = await cf.listZones();
if (zones.length === 0) {
throw AppError.notFound(
"нет доступных зон в Cloudflare — проверьте CLOUDFLARE_API_TOKEN и права Zone:Read",
);
}
const zone = zones.find((z) => z.name.toLowerCase() === trimmed.toLowerCase());
if (!zone) {
const names = zones.map((z) => z.name).join(", ");
throw AppError.notFound(
`зона «${trimmed}» не найдена в Cloudflare. Доступные: ${names}`,
);
}
return repos.createDomain(db, groupId, zone.name, zone.id);
}
export function updateDomain(
db: Db,
id: number,
groupId: number | null,
status: string,
): Domain {
return repos.updateDomain(db, id, groupId, status);
}
export function deleteDomain(db: Db, id: number): void {
repos.deleteDomain(db, id);
}
export async function setDomainServices(
db: Db,
domainId: number,
serviceIds: number[],
): Promise<number[]> {
return bindingService.setDomainServices(db, domainId, serviceIds);
}
export async function importZoneRecords(
db: Db,
cf: CloudflareClient,
domainId: number,
): Promise<number> {
const domain = repos.getDomain(db, domainId);
return syncService.pullSync(db, cf, domain);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Group } from "@cfdm/shared";
export function listGroups(db: Db): Group[] {
return repos.listGroups(db);
}
export function createGroup(db: Db, name: string, slug: string): Group {
return repos.createGroup(db, name, slug);
}
export function updateGroup(
db: Db,
id: number,
name: string,
slug: string,
): Group {
return repos.updateGroup(db, id, name, slug);
}
export function deleteGroup(db: Db, id: number): void {
repos.deleteGroup(db, id);
}
export function getGroupWithStats(db: Db, id: number) {
return repos.getGroupWithStats(db, id);
}
@@ -0,0 +1,705 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
Service,
ServiceGroup,
ServiceGroupsResponse,
ServiceView,
} from "@cfdm/shared";
import {
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import * as dnsService from "./dns-service.js";
import * as domainService from "./domain-service.js";
export interface ServiceDomainInput {
fqdn: string;
target_ips?: string[];
target_ip?: string;
}
export interface ToggleRequest {
enabled: boolean;
}
export interface ServiceGroupBody {
name: string;
type?: string;
icon?: string;
domain?: string;
}
export interface UpdateServiceConfigRequest {
name?: string;
slug?: string;
service_group_id?: number | null;
ips?: string[];
domains?: ServiceDomainInput[];
}
export function fqdnToDisplay(hostname: string, zoneName: string): string {
return hostname === "@" ? zoneName : `${hostname}.${zoneName}`;
}
export function parseFqdn(
fqdn: string,
knownZones: string[],
): { zoneName: string; hostname: string } {
const normalized = fqdn.trim().toLowerCase();
if (!normalized) throw AppError.validation("укажите 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(
`не удалось определить зону для «${fqdn}» — зона должна существовать в Cloudflare`,
);
}
function normalizeIps(ips: string[]): string[] {
const out: string[] = [];
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: string[]): string | null {
if (statuses.length === 0) return null;
if (statuses.some((s) => s === SYNC_ERROR)) return SYNC_ERROR;
if (statuses.some((s) => s === SYNC_PENDING_PUSH)) return SYNC_PENDING_PUSH;
if (statuses.every((s) => s === SYNC_SYNCED)) return SYNC_SYNCED;
return statuses[0] ?? null;
}
async function collectKnownZones(
db: Db,
cf: CloudflareClient,
): Promise<string[]> {
const dbDomains = repos.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: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ips = repos.listServiceIps(db, serviceId);
const bindings = repos.listBindingsByService(db, serviceId);
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIps = repos.listBindingIps(db, binding.id);
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),
target_ips: targetIps,
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,
};
}
export async function listViews(db: Db): Promise<ServiceView[]> {
return Promise.all(
repos.listServices(db).map((s) => buildView(db, s.id)),
);
}
export async function getView(db: Db, id: number): Promise<ServiceView> {
repos.getService(db, id);
return buildView(db, id);
}
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groups = repos.listServiceGroups(db);
const groupViews = await Promise.all(
groups.map(async (group) => {
const services = repos.listServicesByGroup(db, group.id);
const serviceViews = await Promise.all(
services.map((s) => buildView(db, s.id)),
);
return { ...group, services: serviceViews };
}),
);
const ungroupedServices = repos.listUngroupedServices(db);
const ungrouped = await Promise.all(
ungroupedServices.map((s) => buildView(db, s.id)),
);
return { groups: groupViews, ungrouped };
}
function shouldPushDns(db: Db, service: Service): boolean {
if (!service.enabled) return false;
if (!service.service_group_id) return true;
const group = repos.getServiceGroup(db, service.service_group_id);
return group.enabled;
}
async function syncBindingDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
desiredIps: string[],
): Promise<void> {
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
return;
}
const refreshed = repos.listRecordsForBinding(db, bindingId);
let primaryId: number | null = null;
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
let recordId: number;
if (existing) {
if (existing.name !== hostname) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: hostname,
content: ip,
proxied: false,
});
}
recordId = existing.id;
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: ip,
ttl: 1,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
if (primaryId == null) primaryId = recordId;
}
repos.setBindingDnsRecordId(db, bindingId, primaryId);
}
async function cleanupBindingDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
): Promise<void> {
await syncBindingDns(db, cf, bindingId, domainId, hostname, []);
}
async function cleanupServiceDnsOnly(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<void> {
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
}
}
function validateTargetIpsInPool(targetIps: string[], ips: string[]): void {
for (const ip of targetIps) {
if (!isValidIpv4(ip)) {
throw AppError.validation(`некорректный IPv4: ${ip}`);
}
if (!ips.includes(ip)) {
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
}
}
}
function bindingTargetIps(input: ServiceDomainInput): string[] {
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("некорректные IP в привязке домена");
}
return normalized;
}
async function syncServiceBindingsToDns(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<void> {
const ips = repos.listServiceIps(db, serviceId);
if (ips.length === 0) {
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
const bindings = repos.listBindingsByService(db, serviceId);
if (bindings.length === 0) {
throw AppError.validation("настройте FQDN в редакторе сервиса");
}
for (const binding of bindings) {
const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) {
throw AppError.validation(
`укажите IP для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
);
}
validateTargetIpsInPool(targetIps, ips);
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
targetIps,
);
}
}
async function collectGroupDnsIps(
db: Db,
groupId: number,
): Promise<string[]> {
const services = repos.listServicesByGroup(db, groupId);
const ips: string[] = [];
for (const service of services) {
if (!service.enabled) continue;
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
for (const ip of repos.listBindingIps(db, binding.id)) {
if (!ips.includes(ip)) ips.push(ip);
}
}
}
ips.sort();
return ips;
}
async function syncGroupDomainDnsRecords(
db: Db,
cf: CloudflareClient,
groupId: number,
domainId: number,
hostname: string,
desiredIps: string[],
): Promise<void> {
const existingRecords = repos.listGroupDnsRecords(db, groupId);
for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) {
repos.unlinkGroupDnsRecord(db, groupId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
if (desiredIps.length === 0) return;
const refreshed = repos.listGroupDnsRecords(db, groupId);
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
if (existing) {
if (existing.name !== hostname) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: hostname,
content: ip,
proxied: false,
});
}
continue;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: ip,
ttl: 1,
proxied: false,
});
repos.linkGroupDnsRecord(db, groupId, record.id);
}
}
async function resolveDomainId(
db: Db,
cf: CloudflareClient,
zoneName: string,
): Promise<number> {
const trimmed = zoneName.trim();
if (!trimmed) throw AppError.validation("укажите имя зоны");
const existing = repos.findDomainByZoneName(db, trimmed);
if (existing) return existing.id;
const created = await domainService.createDomain(db, cf, null, trimmed);
return created.id;
}
async function cleanupGroupDomainDns(
db: Db,
cf: CloudflareClient,
groupId: number,
): Promise<void> {
const group = repos.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: Db,
cf: CloudflareClient,
groupId: number,
): Promise<void> {
const group = repos.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: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<void> {
const service = repos.getService(db, serviceId);
if (!service.service_group_id) return;
await syncGroupDomainDns(db, cf, service.service_group_id);
}
async function syncEnabledServicesInGroup(
db: Db,
cf: CloudflareClient,
groupId: number,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
if (!group.enabled || !group.domain?.trim()) return;
const services = repos.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: Db,
cf: CloudflareClient,
domain?: string,
): Promise<string | null> {
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: Db,
cf: CloudflareClient,
groupId: number,
fqdn: string,
): Promise<void> {
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
if (hostname === "@") return;
const domain = repos.findDomainByZoneName(db, zoneName);
if (!domain) return;
const services = repos.listServicesByGroup(db, groupId);
for (const service of services) {
const binding = repos.findBinding(
db,
service.id,
domain.id,
hostname,
);
if (!binding) continue;
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
repos.deleteBinding(db, binding.id);
}
}
export async function updateConfig(
db: Db,
cf: CloudflareClient,
id: number,
req: UpdateServiceConfigRequest,
): Promise<ServiceView> {
if (req.name && req.slug) {
repos.updateService(db, id, req.name, req.slug);
} else if (req.name) {
const existing = repos.getService(db, id);
repos.updateService(db, id, req.name, existing.slug);
} else if (req.slug) {
const existing = repos.getService(db, id);
repos.updateService(db, id, existing.name, req.slug);
}
if (req.service_group_id !== undefined) {
repos.setServiceGroup(db, id, req.service_group_id);
}
const ipsUpdated = req.ips !== undefined;
const knownZones = await collectKnownZones(db, cf);
const ips = req.ips ? normalizeIps(req.ips) : repos.listServiceIps(db, id);
if (ipsUpdated) repos.replaceServiceIps(db, id, ips);
const keptBindingIds: number[] = [];
let service = repos.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 targetIps = bindingTargetIps(input);
validateTargetIpsInPool(targetIps, ips);
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const binding =
repos.findBinding(db, id, domainId, hostname) ??
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
repos.replaceBindingIps(db, binding.id, targetIps);
if (pushDns) {
await syncBindingDns(
db,
cf,
binding.id,
domainId,
hostname,
targetIps,
);
}
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
for (const binding of removed) {
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
}
repos.deleteBindingsExcept(db, id, keptBindingIds);
}
} else if (ipsUpdated) {
const bindings = repos.listBindingsByService(db, id);
for (const binding of bindings) {
const targetIps = repos.listBindingIps(db, binding.id);
for (const ip of targetIps) {
if (!ips.includes(ip)) {
throw AppError.validation(
`IP ${ip} привязан к ${fqdnToDisplay(binding.hostname, binding.zone_name)}, но отсутствует в новом пуле адресов`,
);
}
}
}
}
service = repos.getService(db, id);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
}
return buildView(db, id);
}
export async function createGroup(
db: Db,
cf: CloudflareClient,
body: ServiceGroupBody,
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const domain = await normalizeGroupDomain(db, cf, body.domain);
return repos.createServiceGroup(
db,
body.name,
groupType,
body.icon ?? null,
domain,
);
}
export async function updateGroup(
db: Db,
cf: CloudflareClient,
id: number,
body: ServiceGroupBody,
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const previous = repos.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);
const group = repos.updateServiceGroup(
db,
id,
body.name,
groupType,
body.icon ?? null,
domain,
);
await syncEnabledServicesInGroup(db, cf, id);
return group;
}
export function deleteGroup(db: Db, id: number): void {
repos.deleteServiceGroup(db, id);
}
export async function toggleService(
db: Db,
cf: CloudflareClient,
serviceId: number,
enabled: boolean,
): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
if (enabled && service.service_group_id) {
const group = repos.getServiceGroup(db, service.service_group_id);
if (!group.enabled) {
throw AppError.validation("сначала включите группу сервисов");
}
if (!group.domain?.trim()) {
throw AppError.validation("укажите домен у группы сервисов");
}
}
repos.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);
}
export async function toggleGroup(
db: Db,
cf: CloudflareClient,
groupId: number,
enabled: boolean,
): Promise<ServiceGroupsResponse> {
repos.setServiceGroupEnabled(db, groupId, enabled);
if (!enabled) {
const services = repos.listServicesByGroup(db, groupId);
for (const service of services) {
if (service.enabled) {
repos.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);
}
+141
View File
@@ -0,0 +1,141 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Domain, SyncJob } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsNameToSubdomainLabel,
subdomainLabelToFqdn,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { randomUUID } from "node:crypto";
export async function pullSync(
db: Db,
cf: CloudflareClient,
domain: Domain,
): Promise<number> {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const local = repos.listDnsByDomain(db, domain.id);
let changed = 0;
const remoteIds = new Set(
remote.map((r) => r.id).filter((id): id is string => Boolean(id)),
);
for (const cfRec of remote) {
const cfId = cfRec.id;
if (!cfId) continue;
const proxied = cfRec.proxied ?? false;
const existing = repos.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_PUSH) {
repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);
changed += 1;
} else if (contentMatch && existing.sync_status === SYNC_CONFLICT) {
repos.setDnsSyncStatus(db, existing.id, SYNC_SYNCED, cfId, null);
changed += 1;
}
} else {
repos.insertDnsRecord(
db,
domain.id,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
proxied,
cfRec.priority ?? null,
SYNC_SYNCED,
"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") {
repos.setDnsSyncStatus(
db,
rec.id,
SYNC_CONFLICT,
rec.cf_record_id,
"missing in cloudflare",
);
changed += 1;
}
}
}
const labels = new Set<string>();
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);
repos.upsertSubdomain(db, domain.id, label, fqdn);
changed += 1;
}
repos.setDomainLastSynced(db, domain.id);
return changed;
}
export async function syncDomain(
db: Db,
cf: CloudflareClient,
domainId: number,
): Promise<{ jobId: string; changes: number }> {
const jobId = randomUUID();
repos.createSyncJob(db, jobId, domainId);
const domain = repos.getDomain(db, domainId);
try {
const changes = await pullSync(db, cf, domain);
repos.finishSyncJob(db, jobId, "completed", `${changes} changes`);
return { jobId, changes };
} catch (e) {
repos.finishSyncJob(
db,
jobId,
"failed",
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
export async function syncAll(
db: Db,
cf: CloudflareClient,
): Promise<string> {
const jobId = randomUUID();
repos.createSyncJob(db, jobId, null);
const all = repos.listAllDomains(db);
let total = 0;
for (const domain of all) {
try {
total += await pullSync(db, cf, domain);
} catch {
// continue other domains
}
}
repos.finishSyncJob(db, jobId, "completed", `${total} total changes`);
return jobId;
}
export function getJob(db: Db, jobId: string): SyncJob {
return repos.getSyncJob(db, jobId);
}