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