feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-22 13:52:33 +07:00
parent 4a70cf5854
commit 6d1d8ca4f3
99 changed files with 6692 additions and 1483 deletions
+3 -6
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from "fastify";
import { updateDomainSchema } from "@cfdm/shared";
import { z } from "zod";
import * as domainService from "../services/domain-service.js";
@@ -8,11 +9,6 @@ export async function domainRoutes(app: FastifyInstance) {
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;
@@ -36,13 +32,14 @@ export async function domainRoutes(app: FastifyInstance) {
app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateSchema.parse(request.body);
const body = updateDomainSchema.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,
body.cert_monitoring,
);
});
+3 -8
View File
@@ -1,14 +1,9 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared";
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(),
});
const bodySchema = createServiceGroupSchema;
app.get("/service-groups", async (request) => {
return serviceConfig.listGroupViews(request.server.db);
@@ -42,7 +37,7 @@ export async function serviceGroupRoutes(app: FastifyInstance) {
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);
const body = toggleEnabledSchema.parse(request.body);
return serviceConfig.toggleGroup(
request.server.db,
request.server.cf,
+11
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { reorderServicesSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
@@ -14,6 +15,16 @@ export async function serviceRoutes(app: FastifyInstance) {
return serviceConfig.listViews(request.server.db);
});
app.patch("/services/reorder", async (request) => {
const body = reorderServicesSchema.parse(request.body);
serviceConfig.reorderServices(
request.server.db,
body.group_id,
body.service_ids,
);
return { ok: true };
});
app.post("/services", async (request) => {
const body = createSchema.parse(request.body);
const service = repos.createService(
+21 -9
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { updateSubdomainSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import { z } from "zod";
import type { UpdateSubdomainPatch } from "@cfdm/db";
export async function subdomainRoutes(app: FastifyInstance) {
app.get("/domains/:id/subdomains", async (request) => {
@@ -32,16 +34,26 @@ export async function subdomainRoutes(app: FastifyInstance) {
app.patch("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ name: z.string() }).parse(request.body);
const body = updateSubdomainSchema.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,
);
const patch: UpdateSubdomainPatch = {};
if (body.name !== undefined) {
patch.name = body.name;
patch.fqdn =
body.name === "@"
? domain.zone_name
: `${body.name}.${domain.zone_name}`;
}
if (body.enabled !== undefined) {
patch.enabled = body.enabled;
}
if (body.cert_monitoring !== undefined) {
patch.cert_monitoring = body.cert_monitoring;
}
return repos.updateSubdomain(request.server.db, Number(id), patch);
});
app.delete("/subdomains/:id", async (request) => {
+140 -8
View File
@@ -2,13 +2,25 @@ 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 type { Certificate, Domain, Subdomain } from "@cfdm/shared";
import {
CERT_ERROR,
CERT_MONITOR_AUTO,
CERT_MONITOR_REQUIRED,
CERT_MONITOR_SKIPPED,
CERT_UNKNOWN,
certStatusFromExpiry,
fqdnToDisplay,
parseFqdn,
shouldMonitorService,
} from "@cfdm/shared";
export interface CertificateTarget {
domainId: number;
subdomainId: number | null;
hostname: string;
}
export function listCertificates(
db: Db,
status?: string,
@@ -98,17 +110,137 @@ export async function checkAndStore(
);
}
export async function runAllChecks(db: Db): Promise<number> {
let count = 0;
function resolveMonitoringMode(
domain: Domain,
subdomain: Subdomain | null,
fqdn: string,
): string {
if (subdomain) {
return subdomain.cert_monitoring;
}
if (fqdn === domain.zone_name) {
return domain.cert_monitoring;
}
return CERT_MONITOR_AUTO;
}
function bindingSubdomain(
db: Db,
domainId: number,
hostname: string,
): Subdomain | null {
if (hostname === "@") return null;
return repos.findSubdomainByDomainAndName(db, domainId, hostname);
}
export function buildServiceCertificateFqdns(
db: Db,
): Map<string, CertificateTarget> {
const result = new Map<string, CertificateTarget>();
for (const binding of repos.listAllBindings(db)) {
const service = repos.getService(db, binding.service_id);
const group = service.service_group_id
? repos.getServiceGroup(db, service.service_group_id)
: null;
if (!shouldMonitorService(service, group)) continue;
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
if (subdomain && !subdomain.enabled) continue;
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
result.set(fqdn, {
domainId: binding.domain_id,
subdomainId: subdomain?.id ?? null,
hostname: fqdn,
});
}
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
for (const group of repos.listServiceGroups(db)) {
if (!group.enabled || !group.domain?.trim()) continue;
const parsed = parseFqdn(group.domain, knownZones);
if (!parsed) continue;
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
if (!domain) continue;
const subdomain =
parsed.hostname === "@"
? null
: bindingSubdomain(db, domain.id, parsed.hostname);
if (subdomain && !subdomain.enabled) continue;
result.set(parsed.fqdn, {
domainId: domain.id,
subdomainId: subdomain?.id ?? null,
hostname: parsed.fqdn,
});
}
return result;
}
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const serviceFqdns = buildServiceCertificateFqdns(db);
const targets = new Map<string, CertificateTarget>();
for (const domain of repos.listAllDomains(db)) {
await checkAndStore(db, domain.id, null, domain.zone_name);
count += 1;
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(domain.zone_name, {
domainId: domain.id,
subdomainId: null,
hostname: domain.zone_name,
});
}
}
for (const sub of repos.listAllSubdomains(db)) {
await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn);
count += 1;
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(sub.fqdn, {
domainId: sub.domain_id,
subdomainId: sub.id,
hostname: sub.fqdn,
});
}
}
return count;
for (const [fqdn, meta] of serviceFqdns) {
const domain = repos.getDomain(db, meta.domainId);
const subdomain = meta.subdomainId
? repos.getSubdomain(db, meta.subdomainId)
: null;
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
if (monitoring === CERT_MONITOR_SKIPPED) continue;
if (
monitoring === CERT_MONITOR_AUTO ||
monitoring === CERT_MONITOR_REQUIRED
) {
targets.set(fqdn, meta);
}
}
return [...targets.values()];
}
export async function runAllChecks(db: Db): Promise<number> {
const targets = resolveCertificateTargets(db);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
);
}
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
return targets.length;
}
export function statusSummary(db: Db): Array<[string, number]> {
+14 -9
View File
@@ -6,6 +6,7 @@ import {
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
@@ -87,12 +88,12 @@ async function pushRecord(
repos.updateDnsFields(
db,
record.id,
record.record_type,
record.name,
record.content,
record.ttl,
record.proxied,
record.priority,
cfRec.type ?? record.record_type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
cfRec.id ?? null,
null,
@@ -119,13 +120,14 @@ export async function create(
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 name = normalizeDnsRecordName(req.name, domain.zone_name);
validateDnsRecord(req.record_type, name, req.content, ttl, proxied);
const record = repos.insertDnsRecord(
db,
domainId,
req.record_type,
req.name,
name,
req.content,
ttl,
proxied,
@@ -149,7 +151,10 @@ export async function update(
const existing = repos.getDnsRecord(db, domainId, recordId);
const recordType = req.record_type ?? existing.record_type;
const name = req.name ?? existing.name;
const name = normalizeDnsRecordName(
req.name ?? existing.name,
domain.zone_name,
);
const content = req.content ?? existing.content;
const ttl = req.ttl ?? existing.ttl;
const proxied = req.proxied ?? existing.proxied;
+2 -1
View File
@@ -45,8 +45,9 @@ export function updateDomain(
id: number,
groupId: number | null,
status: string,
certMonitoring?: string,
): Domain {
return repos.updateDomain(db, id, groupId, status);
return repos.updateDomain(db, id, groupId, status, certMonitoring);
}
export function deleteDomain(db: Db, id: number): void {
+420 -33
View File
@@ -1,6 +1,7 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
DnsRecord,
Service,
ServiceGroup,
ServiceGroupsResponse,
@@ -10,6 +11,8 @@ import {
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
@@ -21,6 +24,7 @@ export interface ServiceDomainInput {
fqdn: string;
target_ips?: string[];
target_ip?: string;
target_cname?: string;
}
export interface ToggleRequest {
@@ -30,8 +34,8 @@ export interface ToggleRequest {
export interface ServiceGroupBody {
name: string;
type?: string;
icon?: string;
domain?: string;
icon?: string | null;
domain?: string | null;
}
export interface UpdateServiceConfigRequest {
@@ -114,13 +118,20 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIps = repos.listBindingIps(db, binding.id);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
target_ips: targetIps,
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_cname: targetCname,
sync_status: aggregateSyncStatus(statuses),
};
});
@@ -185,16 +196,144 @@ async function syncBindingDns(
domainId: number,
hostname: string,
desiredIps: string[],
cnameTarget: string | null,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
let effectiveCname = cnameTarget?.trim() || null;
if (!effectiveCname) {
const existingCname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
);
if (existingCname) {
effectiveCname = existingCname.content;
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
repos.replaceBindingIps(db, bindingId, []);
}
}
if (effectiveCname) {
await syncBindingCnameDns(
db,
cf,
bindingId,
domainId,
hostname,
effectiveCname,
);
return;
}
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
}
async function syncBindingCnameDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
cnameTarget: string,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) {
if (record.record_type.toUpperCase() === "A") {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
const refreshed = repos.listRecordsForBinding(db, bindingId);
const existingCname = refreshed.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
let recordId: number;
if (existingCname) {
if (
!cnameContentMatches(existingCname.content, normalized, zoneName) ||
!dnsRecordNamesMatch(existingCname.name, hostname, zoneName)
) {
await dnsService.update(db, cf, domainId, existingCname.id, {
record_type: "CNAME",
name: dnsNameForBinding(hostname, zoneName),
content: normalized,
proxied: false,
});
}
recordId = existingCname.id;
} else {
const adopted = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
normalized,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
if (!cnameContentMatches(adopted.content, normalized, zoneName)) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "CNAME",
name: dnsNameForBinding(hostname, zoneName),
content: normalized,
proxied: false,
});
}
recordId = adopted.id;
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "CNAME",
name: dnsNameForBinding(hostname, zoneName),
content: normalized,
ttl: 1,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
}
repos.setBindingDnsRecordId(db, bindingId, recordId);
repos.setBindingCnameTarget(db, bindingId, normalized);
}
async function syncBindingADns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
desiredIps: string[],
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
if (record.record_type.toUpperCase() === "CNAME") {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
} else if (!desiredIps.includes(record.content)) {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
repos.setBindingCnameTarget(db, bindingId, null);
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
return;
@@ -207,25 +346,39 @@ async function syncBindingDns(
const existing = refreshed.find((r) => r.content === ip);
let recordId: number;
if (existing) {
if (existing.name !== hostname) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: hostname,
name: dnsNameForBinding(hostname, zoneName),
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;
const adopted = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"A",
ip,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
recordId = adopted.id;
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
content: ip,
ttl: 1,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
}
if (primaryId == null) primaryId = recordId;
}
@@ -240,7 +393,7 @@ async function cleanupBindingDns(
domainId: number,
hostname: string,
): Promise<void> {
await syncBindingDns(db, cf, bindingId, domainId, hostname, []);
await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);
}
async function cleanupServiceDnsOnly(
@@ -272,6 +425,7 @@ function validateTargetIpsInPool(targetIps: string[], ips: string[]): void {
}
function bindingTargetIps(input: ServiceDomainInput): string[] {
if (input.target_cname?.trim()) return [];
const raw = input.target_ips
? input.target_ips
: input.target_ip?.trim()
@@ -284,26 +438,209 @@ function bindingTargetIps(input: ServiceDomainInput): string[] {
return normalized;
}
function bindingTargetCname(input: ServiceDomainInput): string | null {
const target = input.target_cname?.trim();
return target ? target : null;
}
function normalizeCnameTarget(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase();
if (!trimmed) {
throw AppError.validation("укажите CNAME-цель");
}
if (trimmed.includes(".")) return trimmed;
return `${trimmed}.${zoneName.toLowerCase()}`;
}
function dnsNameForBinding(hostname: string, zoneName: string): string {
return normalizeDnsRecordName(hostname, zoneName);
}
function cnameContentMatches(
left: string,
right: string,
zoneName: string,
): boolean {
return (
normalizeCnameTarget(left, zoneName) ===
normalizeCnameTarget(right, zoneName)
);
}
function findLocalDnsRecord(
db: Db,
domainId: number,
zoneName: string,
hostname: string,
recordType: "A" | "CNAME",
content?: string,
): DnsRecord | null {
const records = repos.listDnsByDomain(db, domainId);
return (
records.find(
(record) =>
record.record_type.toUpperCase() === recordType &&
(content == null || record.content === content) &&
dnsRecordNamesMatch(record.name, hostname, zoneName),
) ?? null
);
}
async function findOrImportDnsRecord(
db: Db,
cf: CloudflareClient,
domainId: number,
zoneName: string,
hostname: string,
recordType: "A" | "CNAME",
content?: string,
): Promise<DnsRecord | null> {
const local = findLocalDnsRecord(
db,
domainId,
zoneName,
hostname,
recordType,
content,
);
if (local) return local;
const domain = repos.getDomain(db, domainId);
const remote = await cf.listDnsRecords(domain.cf_zone_id);
for (const cfRec of remote) {
if (cfRec.type.toUpperCase() !== recordType) continue;
if (content != null) {
if (recordType === "CNAME") {
if (!cnameContentMatches(cfRec.content, content, zoneName)) continue;
} else if (cfRec.content !== content) {
continue;
}
}
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
if (!cfRec.id) continue;
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
if (existing) return existing;
return repos.insertDnsRecord(
db,
domainId,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
"cloudflare",
cfRec.id,
);
}
return null;
}
async function findOrImportDnsARecord(
db: Db,
cf: CloudflareClient,
domainId: number,
zoneName: string,
hostname: string,
content: string,
): Promise<DnsRecord | null> {
return findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"A",
content,
);
}
async function serviceBindingsExistInDns(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<boolean> {
const bindings = repos.listBindingsByService(db, serviceId);
if (bindings.length === 0) return false;
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
const record = await findOrImportDnsRecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
"CNAME",
cnameTarget,
);
if (!record) return false;
continue;
}
const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) return false;
for (const ip of targetIps) {
const record = await findOrImportDnsARecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
ip,
);
if (!record) return false;
}
}
return true;
}
async function syncServiceBindingsToDns(
db: 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 в редакторе сервиса");
}
const needsIpPool = bindings.some((binding) => {
if (binding.cname_target?.trim()) return false;
const targetIps = repos.listBindingIps(db, binding.id);
return targetIps.length > 0;
});
const ips = repos.listServiceIps(db, serviceId);
if (needsIpPool && ips.length === 0) {
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
[],
cnameTarget,
);
continue;
}
const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) {
throw AppError.validation(
`укажите IP для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
`укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
);
}
validateTargetIpsInPool(targetIps, ips);
@@ -314,6 +651,7 @@ async function syncServiceBindingsToDns(
binding.domain_id,
binding.hostname,
targetIps,
null,
);
}
}
@@ -345,6 +683,8 @@ async function syncGroupDomainDnsRecords(
hostname: string,
desiredIps: string[],
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const existingRecords = repos.listGroupDnsRecords(db, groupId);
for (const record of existingRecords) {
@@ -360,19 +700,31 @@ async function syncGroupDomainDnsRecords(
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
if (existing) {
if (existing.name !== hostname) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: hostname,
name: dnsNameForBinding(hostname, zoneName),
content: ip,
proxied: false,
});
}
continue;
}
const adopted = await findOrImportDnsARecord(
db,
cf,
domainId,
zoneName,
hostname,
ip,
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
continue;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
name: dnsNameForBinding(hostname, zoneName),
content: ip,
ttl: 1,
proxied: false,
@@ -466,7 +818,7 @@ async function syncEnabledServicesInGroup(
async function normalizeGroupDomain(
db: Db,
cf: CloudflareClient,
domain?: string,
domain?: string | null,
): Promise<string | null> {
const raw = domain?.trim();
if (!raw) return null;
@@ -543,8 +895,15 @@ export async function updateConfig(
for (const input of req.domains) {
const fqdn = input.fqdn.trim();
if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input);
validateTargetIpsInPool(targetIps, ips);
if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`укажите либо IP, либо CNAME для ${fqdn}`,
);
}
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
@@ -554,7 +913,8 @@ export async function updateConfig(
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
repos.replaceBindingIps(db, binding.id, targetIps);
repos.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (pushDns) {
await syncBindingDns(
@@ -564,6 +924,7 @@ export async function updateConfig(
domainId,
hostname,
targetIps,
targetCname,
);
}
}
@@ -598,6 +959,15 @@ export async function updateConfig(
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
} else if (
req.domains &&
req.domains.length > 0 &&
!service.enabled &&
(await serviceBindingsExistInDns(db, cf, id))
) {
repos.setServiceEnabled(db, id, true);
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
}
return buildView(db, id);
@@ -633,7 +1003,7 @@ export async function updateGroup(
await cleanupGroupDomainDns(db, cf, id);
}
const domain = await normalizeGroupDomain(db, cf, body.domain);
const group = repos.updateServiceGroup(
let group = repos.updateServiceGroup(
db,
id,
body.name,
@@ -641,6 +1011,10 @@ export async function updateGroup(
body.icon ?? null,
domain,
);
if (!domain && group.enabled) {
repos.setServiceGroupEnabled(db, id, false);
group = repos.getServiceGroup(db, id);
}
await syncEnabledServicesInGroup(db, cf, id);
return group;
}
@@ -659,12 +1033,9 @@ export async function toggleService(
if (enabled && service.service_group_id) {
const group = repos.getServiceGroup(db, service.service_group_id);
if (!group.enabled) {
if (group.domain?.trim() && !group.enabled) {
throw AppError.validation("сначала включите группу сервисов");
}
if (!group.domain?.trim()) {
throw AppError.validation("укажите домен у группы сервисов");
}
}
repos.setServiceEnabled(db, serviceId, enabled);
@@ -686,6 +1057,11 @@ export async function toggleGroup(
groupId: number,
enabled: boolean,
): Promise<ServiceGroupsResponse> {
const group = repos.getServiceGroup(db, groupId);
if (enabled && !group.domain?.trim()) {
throw AppError.validation("нельзя включить группу без домена");
}
repos.setServiceGroupEnabled(db, groupId, enabled);
if (!enabled) {
@@ -703,3 +1079,14 @@ export async function toggleGroup(
return listGroupViews(db);
}
export function reorderServices(
db: Db,
groupId: number | null,
serviceIds: number[],
): void {
if (groupId !== null) {
repos.getServiceGroup(db, groupId);
}
repos.reorderServices(db, groupId, serviceIds);
}
+123 -17
View File
@@ -1,16 +1,93 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Domain, SyncJob } from "@cfdm/shared";
import type { CfDnsRecord, Domain, DnsRecord, SyncJob } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsNameToSubdomainLabel,
dnsRecordNamesMatch,
subdomainLabelToFqdn,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { randomUUID } from "node:crypto";
function findLocalByRemote(
local: DnsRecord[],
cfRec: CfDnsRecord,
zoneName: string,
): DnsRecord | null {
return (
local.find(
(record) =>
record.record_type.toUpperCase() === cfRec.type.toUpperCase() &&
dnsRecordNamesMatch(record.name, cfRec.name, zoneName),
) ?? null
);
}
function dnsRecordsEquivalent(
existing: DnsRecord,
cfRec: CfDnsRecord,
zoneName: string,
): boolean {
const proxied = cfRec.proxied ?? false;
return (
existing.content === cfRec.content &&
existing.ttl === cfRec.ttl &&
existing.proxied === proxied &&
dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) &&
existing.record_type.toUpperCase() === cfRec.type.toUpperCase()
);
}
function applyRemoteRecord(
db: Db,
domainId: number,
cfRec: CfDnsRecord,
existing: DnsRecord,
zoneName: string,
): boolean {
const cfId = cfRec.id;
if (!cfId) return false;
const proxied = cfRec.proxied ?? false;
const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName);
if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH) {
repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);
return true;
}
if (!equivalent) return false;
if (
existing.name !== cfRec.name ||
existing.sync_status !== SYNC_SYNCED ||
existing.cf_record_id !== cfId ||
existing.content !== cfRec.content ||
existing.ttl !== cfRec.ttl ||
existing.proxied !== proxied
) {
repos.updateDnsFields(
db,
existing.id,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
proxied,
cfRec.priority ?? null,
SYNC_SYNCED,
cfId,
null,
);
return true;
}
return false;
}
export async function pullSync(
db: Db,
cf: CloudflareClient,
@@ -27,22 +104,14 @@ export async function pullSync(
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);
let existing = repos.findDnsByCfId(db, domain.id, cfId);
if (!existing) {
existing = findLocalByRemote(local, cfRec, domain.zone_name);
}
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);
if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) {
changed += 1;
}
} else {
@@ -53,7 +122,7 @@ export async function pullSync(
cfRec.name,
cfRec.content,
cfRec.ttl,
proxied,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
"cloudflare",
@@ -63,7 +132,9 @@ export async function pullSync(
}
}
for (const rec of local) {
const refreshedLocal = repos.listDnsByDomain(db, domain.id);
for (const rec of refreshedLocal) {
if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) {
if (rec.sync_status !== "pending_delete") {
repos.setDnsSyncStatus(
@@ -75,6 +146,41 @@ export async function pullSync(
);
changed += 1;
}
continue;
}
if (rec.sync_status === SYNC_PENDING_PUSH) continue;
const remoteSameType = remote.find(
(r) =>
r.id &&
dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) &&
r.type.toUpperCase() === rec.record_type.toUpperCase(),
);
if (remoteSameType?.id) {
if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) {
changed += 1;
}
continue;
}
const remoteSameHost = remote.find((r) =>
dnsRecordNamesMatch(r.name, rec.name, domain.zone_name),
);
if (
remoteSameHost &&
remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()
) {
repos.setDnsSyncStatus(
db,
rec.id,
SYNC_CONFLICT,
rec.cf_record_id,
"type mismatch with cloudflare",
);
changed += 1;
}
}