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
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:
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
|
||||
export { }
|
||||
Vendored
+1999
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@cfdm/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup src/server.ts --format esm --dts",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cfdm/db": "workspace:*",
|
||||
"@cfdm/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/schedule": "^6.0.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"p-limit": "^6.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"toad-scheduler": "^4.0.1",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.32",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
validateDnsRecord,
|
||||
certStatusFromExpiry,
|
||||
isValidIpv4,
|
||||
ValidationError,
|
||||
} from "@cfdm/shared";
|
||||
@@ -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" });
|
||||
@@ -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" });
|
||||
@@ -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" });
|
||||
@@ -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 };
|
||||
@@ -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" });
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
describe("health", () => {
|
||||
it("GET /health returns ok", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/health" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ status: "ok" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET /ready returns database status", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/ready" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.database).toBe(true);
|
||||
expect(["ready", "degraded"]).toContain(body.status);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
typecheck: {
|
||||
tsconfig: "./tsconfig.test.json",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user