Introduced a new `verify_tls` boolean option in health check configurations across various services, allowing users to specify whether to validate TLS certificates during health checks. Updated related components and services to accommodate this new option, ensuring proper handling in both the backend and frontend. Enhanced tests to validate the new functionality and ensure correct behavior with different configurations.
1484 lines
42 KiB
TypeScript
1484 lines
42 KiB
TypeScript
import type { Db } from "@cfdm/db";
|
|
import { repos } from "@cfdm/db";
|
|
import type {
|
|
DnsRecord,
|
|
HealthCheckScope,
|
|
HealthCheckType,
|
|
IpHealthState,
|
|
LbMode,
|
|
Service,
|
|
ServiceGroup,
|
|
ServiceGroupsResponse,
|
|
ServiceView,
|
|
} from "@cfdm/shared";
|
|
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";
|
|
import { isValidIpv4 } from "../lib/validators.js";
|
|
import * as dnsService from "./dns-service.js";
|
|
import * as domainService from "./domain-service.js";
|
|
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
|
|
|
export interface ServiceDomainInput {
|
|
fqdn: string;
|
|
target_ips?: string[];
|
|
target_ip?: string;
|
|
target_cname?: string;
|
|
target_ip_weights?: Record<string, number>;
|
|
target_ip_priorities?: Record<string, number>;
|
|
lb_mode?: LbMode;
|
|
health_check_enabled?: boolean;
|
|
health_check_type?: HealthCheckType;
|
|
health_check_port?: number | null;
|
|
health_check_path?: string | null;
|
|
health_check_expected_status?: number | null;
|
|
health_check_interval_sec?: number;
|
|
health_check_timeout_ms?: number;
|
|
health_check_verify_tls?: boolean;
|
|
}
|
|
|
|
export interface ToggleRequest {
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface ServiceGroupBody {
|
|
name: string;
|
|
type?: string;
|
|
icon?: string | null;
|
|
domain?: string | null;
|
|
lb_mode?: LbMode;
|
|
health_check_enabled?: boolean;
|
|
health_check_type?: HealthCheckType;
|
|
health_check_port?: number | null;
|
|
health_check_path?: string | null;
|
|
health_check_expected_status?: number | null;
|
|
health_check_interval_sec?: number;
|
|
health_check_timeout_ms?: number;
|
|
health_check_verify_tls?: boolean;
|
|
}
|
|
|
|
export interface UpdateServiceGroupBody {
|
|
name?: string;
|
|
type?: string;
|
|
icon?: string | null;
|
|
domain?: string | null;
|
|
lb_mode?: LbMode;
|
|
health_check_enabled?: boolean;
|
|
health_check_type?: HealthCheckType;
|
|
health_check_port?: number | null;
|
|
health_check_path?: string | null;
|
|
health_check_expected_status?: number | null;
|
|
health_check_interval_sec?: number;
|
|
health_check_timeout_ms?: number;
|
|
health_check_verify_tls?: boolean;
|
|
}
|
|
|
|
export interface UpdateServiceConfigRequest {
|
|
name?: string;
|
|
slug?: string;
|
|
service_group_id?: number | null;
|
|
ips?: string[];
|
|
lb_weight?: number;
|
|
lb_priority?: number;
|
|
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;
|
|
}
|
|
|
|
function isHealthy(state: IpHealthState): boolean {
|
|
return state === "up" || state === "unknown";
|
|
}
|
|
|
|
export interface LbTargetConfig {
|
|
lb_mode: LbMode;
|
|
health_check_enabled: boolean;
|
|
}
|
|
|
|
export interface LbIpRow {
|
|
ip: string;
|
|
weight: number;
|
|
priority: number;
|
|
health: IpHealthState;
|
|
}
|
|
|
|
export function selectActiveIpsByMode(
|
|
config: LbTargetConfig,
|
|
rows: LbIpRow[],
|
|
): string[] {
|
|
if (rows.length === 0) return [];
|
|
|
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
|
const pool = healthy.length > 0 ? healthy : rows;
|
|
|
|
if (config.lb_mode === "failover") {
|
|
const sorted = [...pool].sort(
|
|
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
|
);
|
|
const minPriority = sorted[0]!.priority;
|
|
const primaries = sorted.filter((r) => r.priority === minPriority);
|
|
if (healthy.length > 0) {
|
|
return primaries.map((r) => r.ip);
|
|
}
|
|
return [sorted[0]!.ip];
|
|
}
|
|
|
|
if (config.lb_mode === "weighted") {
|
|
// Cloudflare не допускает дублирования A-записей с одинаковым name+content,
|
|
// поэтому weighted на уровне DNS реализован как RR по одному A на IP.
|
|
// Веса сохраняются в БД и используются для приоритизации/отображения;
|
|
// точное weighted-распределение требует CF Load Balancer (см. README).
|
|
return pool.map((r) => r.ip);
|
|
}
|
|
|
|
return pool.map((r) => r.ip);
|
|
}
|
|
|
|
function getBindingLbState(
|
|
db: Db,
|
|
bindingId: number,
|
|
): { config: LbTargetConfig; rows: LbIpRow[] } {
|
|
const binding = repos.getBinding(db, bindingId);
|
|
const ipMetas = repos.listBindingIpsWithMeta(db, bindingId);
|
|
const rows: LbIpRow[] = ipMetas.map((entry) => {
|
|
const status = repos.getIpHealthStatusRow(db, "binding", bindingId, entry.ip);
|
|
return {
|
|
ip: entry.ip,
|
|
weight: entry.weight,
|
|
priority: entry.priority,
|
|
health: status ? (status.status as IpHealthState) : "unknown",
|
|
};
|
|
});
|
|
return {
|
|
config: {
|
|
lb_mode: binding.lb_mode,
|
|
health_check_enabled: binding.health_check_enabled,
|
|
},
|
|
rows,
|
|
};
|
|
}
|
|
|
|
function getGroupLbState(
|
|
db: Db,
|
|
groupId: number,
|
|
): { config: LbTargetConfig; rows: LbIpRow[] } {
|
|
const group = repos.getServiceGroup(db, groupId);
|
|
const services = repos.listServicesByGroup(db, groupId);
|
|
const seen = new Map<string, LbIpRow>();
|
|
for (const service of services) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos.listBindingsByService(db, service.id);
|
|
for (const binding of bindings) {
|
|
const ipMetas = repos.listBindingIpsWithMeta(db, binding.id);
|
|
for (const entry of ipMetas) {
|
|
const status = repos.getIpHealthStatusRow(db, "group", groupId, entry.ip);
|
|
const existing = seen.get(entry.ip);
|
|
const weight = entry.weight * service.lb_weight;
|
|
const priority = Math.min(entry.priority, service.lb_priority);
|
|
if (!existing) {
|
|
seen.set(entry.ip, {
|
|
ip: entry.ip,
|
|
weight,
|
|
priority,
|
|
health: status ? (status.status as IpHealthState) : "unknown",
|
|
});
|
|
} else {
|
|
existing.weight += weight;
|
|
existing.priority = Math.min(existing.priority, priority);
|
|
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
|
|
existing.health = status.status as IpHealthState;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
config: {
|
|
lb_mode: group.lb_mode,
|
|
health_check_enabled: group.health_check_enabled,
|
|
},
|
|
rows: [...seen.values()],
|
|
};
|
|
}
|
|
|
|
function computeActiveIps(
|
|
db: Db,
|
|
scope: HealthCheckScope,
|
|
refId: number,
|
|
): string[] {
|
|
const state =
|
|
scope === "binding"
|
|
? getBindingLbState(db, refId)
|
|
: getGroupLbState(db, refId);
|
|
return selectActiveIpsByMode(state.config, state.rows);
|
|
}
|
|
|
|
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 targetIpsWithMeta = repos.listBindingIpsWithMeta(db, binding.id);
|
|
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
|
|
const linkedCname = records.find(
|
|
(record) => record.record_type.toUpperCase() === "CNAME",
|
|
);
|
|
const targetCname =
|
|
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
|
|
|
|
const target_ip_weights: Record<string, number> = {};
|
|
const target_ip_priorities: Record<string, number> = {};
|
|
for (const entry of targetIpsWithMeta) {
|
|
target_ip_weights[entry.ip] = entry.weight;
|
|
target_ip_priorities[entry.ip] = entry.priority;
|
|
}
|
|
for (const ip of targetIps) {
|
|
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
|
|
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
|
}
|
|
|
|
return {
|
|
binding_id: binding.id,
|
|
domain_id: binding.domain_id,
|
|
zone_name: binding.zone_name,
|
|
hostname: binding.hostname,
|
|
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
|
record_type: targetCname ? ("CNAME" as const) : ("A" as const),
|
|
target_ips: targetCname ? [] : targetIps,
|
|
target_ip_weights,
|
|
target_ip_priorities,
|
|
target_cname: targetCname,
|
|
lb_mode: binding.lb_mode,
|
|
health_check_enabled: binding.health_check_enabled,
|
|
health_check_type: binding.health_check_type,
|
|
health_check_port: binding.health_check_port,
|
|
health_check_path: binding.health_check_path,
|
|
health_check_expected_status: binding.health_check_expected_status,
|
|
health_check_interval_sec: binding.health_check_interval_sec,
|
|
health_check_timeout_ms: binding.health_check_timeout_ms,
|
|
health_check_verify_tls: binding.health_check_verify_tls,
|
|
sync_status: aggregateSyncStatus(statuses),
|
|
};
|
|
});
|
|
|
|
return {
|
|
id: service.id,
|
|
name: service.name,
|
|
slug: service.slug,
|
|
service_group_id: service.service_group_id ?? null,
|
|
subdomain: service.subdomain ?? "",
|
|
enabled: Boolean(service.enabled),
|
|
computed_fqdn: null,
|
|
lb_weight: service.lb_weight,
|
|
lb_priority: service.lb_priority,
|
|
created_at: service.created_at,
|
|
updated_at: service.updated_at,
|
|
ips,
|
|
domains: domainViews,
|
|
health_status: "unknown",
|
|
health_latency_ms: null,
|
|
};
|
|
}
|
|
|
|
function attachServiceHealth(
|
|
db: Db,
|
|
views: ServiceView[],
|
|
): ServiceView[] {
|
|
const healthByService = repos.aggregateIpHealthByServiceIds(
|
|
db,
|
|
views.map((v) => v.id),
|
|
);
|
|
return views.map((view) => {
|
|
const health = healthByService.get(view.id);
|
|
return {
|
|
...view,
|
|
health_status: health?.health_status ?? "unknown",
|
|
health_latency_ms: health?.health_latency_ms ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function listViews(db: Db): Promise<ServiceView[]> {
|
|
const views = await Promise.all(
|
|
repos.listServices(db).map((s) => buildView(db, s.id)),
|
|
);
|
|
return attachServiceHealth(db, views);
|
|
}
|
|
|
|
export async function getView(db: Db, id: number): Promise<ServiceView> {
|
|
repos.getService(db, id);
|
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
|
return view!;
|
|
}
|
|
|
|
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|
const groups = repos.listServiceGroups(db);
|
|
const groupViewsRaw = 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 ungroupedRaw = await Promise.all(
|
|
ungroupedServices.map((s) => buildView(db, s.id)),
|
|
);
|
|
|
|
const allServiceViews = [
|
|
...groupViewsRaw.flatMap((g) => g.services),
|
|
...ungroupedRaw,
|
|
];
|
|
const withHealth = attachServiceHealth(db, allServiceViews);
|
|
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
|
|
|
const groupHealthById = repos.aggregateIpHealthByRefs(
|
|
db,
|
|
"group",
|
|
groups.map((g) => g.id),
|
|
);
|
|
|
|
const groupViews = groupViewsRaw.map((group) => {
|
|
const services = group.services.map(
|
|
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
|
);
|
|
const groupScopeHealth = groupHealthById.get(group.id);
|
|
const merged = repos.mergeHealthAggregates([
|
|
groupScopeHealth,
|
|
...services.map((s) => ({
|
|
health_status: s.health_status,
|
|
health_latency_ms: s.health_latency_ms,
|
|
})),
|
|
]);
|
|
return {
|
|
...group,
|
|
services,
|
|
health_status: merged.health_status,
|
|
health_latency_ms: merged.health_latency_ms,
|
|
};
|
|
});
|
|
|
|
const ungrouped = ungroupedRaw.map(
|
|
(s) =>
|
|
healthById.get(s.id) ?? {
|
|
...s,
|
|
health_status: "unknown" as const,
|
|
health_latency_ms: null,
|
|
},
|
|
);
|
|
|
|
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[],
|
|
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 (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;
|
|
}
|
|
|
|
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 (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
|
await dnsService.update(db, cf, domainId, existing.id, {
|
|
record_type: "A",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: ip,
|
|
proxied: false,
|
|
});
|
|
}
|
|
recordId = existing.id;
|
|
} else {
|
|
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;
|
|
}
|
|
|
|
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, [], null);
|
|
}
|
|
|
|
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[] {
|
|
if (input.target_cname?.trim()) return [];
|
|
const raw = input.target_ips
|
|
? input.target_ips
|
|
: input.target_ip?.trim()
|
|
? [input.target_ip.trim()]
|
|
: [];
|
|
const normalized = normalizeIps(raw);
|
|
if (raw.length > 0 && normalized.length === 0) {
|
|
throw AppError.validation("некорректные IP в привязке домена");
|
|
}
|
|
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 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;
|
|
}
|
|
|
|
let targetIps = repos.listBindingIps(db, binding.id);
|
|
if (targetIps.length === 0) {
|
|
throw AppError.validation(
|
|
`укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
|
|
);
|
|
}
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
|
|
if (binding.health_check_enabled) {
|
|
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
if (activeIps.length > 0) {
|
|
targetIps = activeIps;
|
|
}
|
|
}
|
|
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
targetIps,
|
|
null,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 domain = repos.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
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 (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
|
await dnsService.update(db, cf, domainId, existing.id, {
|
|
record_type: "A",
|
|
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: dnsNameForBinding(hostname, zoneName),
|
|
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 = group.health_check_enabled
|
|
? computeActiveIps(db, "group", groupId)
|
|
: 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 | null,
|
|
): 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);
|
|
}
|
|
|
|
if (req.lb_weight !== undefined || req.lb_priority !== undefined) {
|
|
const existing = repos.getService(db, id);
|
|
repos.setServiceLb(
|
|
db,
|
|
id,
|
|
req.lb_weight ?? existing.lb_weight,
|
|
req.lb_priority ?? existing.lb_priority,
|
|
);
|
|
}
|
|
|
|
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);
|
|
let removedBindingIds: number[] = [];
|
|
|
|
if (req.domains) {
|
|
if (req.domains.length > 0) {
|
|
for (const input of req.domains) {
|
|
const fqdn = input.fqdn.trim();
|
|
if (!fqdn) continue;
|
|
const targetCname = bindingTargetCname(input);
|
|
const targetIps = bindingTargetIps(input);
|
|
if (!targetCname) {
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
} else if (targetIps.length > 0) {
|
|
throw AppError.validation(
|
|
`укажите либо IP, либо CNAME для ${fqdn}`,
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
const targetIpWeights = input.target_ip_weights ?? {};
|
|
const targetIpPriorities = input.target_ip_priorities ?? {};
|
|
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
|
|
ip,
|
|
weight: targetIpWeights[ip] ?? 1,
|
|
priority: targetIpPriorities[ip] ?? 1,
|
|
}));
|
|
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
|
repos.setBindingCnameTarget(db, binding.id, targetCname);
|
|
|
|
if (
|
|
input.lb_mode !== undefined ||
|
|
input.health_check_enabled !== undefined ||
|
|
input.health_check_type !== undefined ||
|
|
input.health_check_port !== undefined ||
|
|
input.health_check_path !== undefined ||
|
|
input.health_check_expected_status !== undefined ||
|
|
input.health_check_interval_sec !== undefined ||
|
|
input.health_check_timeout_ms !== undefined ||
|
|
input.health_check_verify_tls !== undefined
|
|
) {
|
|
repos.updateBindingLbConfig(db, binding.id, {
|
|
lb_mode: input.lb_mode,
|
|
health_check_enabled: input.health_check_enabled,
|
|
health_check_type: input.health_check_type,
|
|
health_check_port: input.health_check_port,
|
|
health_check_path: input.health_check_path,
|
|
health_check_expected_status: input.health_check_expected_status,
|
|
health_check_interval_sec: input.health_check_interval_sec,
|
|
health_check_timeout_ms: input.health_check_timeout_ms,
|
|
health_check_verify_tls: input.health_check_verify_tls,
|
|
});
|
|
}
|
|
|
|
if (pushDns) {
|
|
let effectiveIps = targetIps;
|
|
const refreshedBinding = repos.getBinding(db, binding.id);
|
|
if (refreshedBinding.health_check_enabled) {
|
|
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
if (activeIps.length > 0) {
|
|
effectiveIps = activeIps;
|
|
}
|
|
}
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
domainId,
|
|
hostname,
|
|
effectiveIps,
|
|
targetCname,
|
|
);
|
|
}
|
|
}
|
|
|
|
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
|
removedBindingIds = removed.map((binding) => binding.id);
|
|
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);
|
|
} 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);
|
|
}
|
|
|
|
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
|
|
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
|
return view!;
|
|
}
|
|
|
|
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,
|
|
{
|
|
lb_mode: body.lb_mode,
|
|
health_check_enabled: body.health_check_enabled,
|
|
health_check_type: body.health_check_type,
|
|
health_check_port: body.health_check_port,
|
|
health_check_path: body.health_check_path,
|
|
health_check_expected_status: body.health_check_expected_status,
|
|
health_check_interval_sec: body.health_check_interval_sec,
|
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
|
health_check_verify_tls: body.health_check_verify_tls,
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function updateGroup(
|
|
db: Db,
|
|
cf: CloudflareClient,
|
|
id: number,
|
|
body: UpdateServiceGroupBody,
|
|
): Promise<ServiceGroup> {
|
|
const groupType = body.type?.trim() || "custom";
|
|
const previous = repos.getServiceGroup(db, id);
|
|
const name = body.name ?? previous.name;
|
|
const oldDomain = previous.domain?.trim();
|
|
if (oldDomain) {
|
|
await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);
|
|
await cleanupGroupDomainDns(db, cf, id);
|
|
}
|
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
|
let group = repos.updateServiceGroup(
|
|
db,
|
|
id,
|
|
name,
|
|
groupType,
|
|
body.icon ?? null,
|
|
domain,
|
|
{
|
|
lb_mode: body.lb_mode,
|
|
health_check_enabled: body.health_check_enabled,
|
|
health_check_type: body.health_check_type,
|
|
health_check_port: body.health_check_port,
|
|
health_check_path: body.health_check_path,
|
|
health_check_expected_status: body.health_check_expected_status,
|
|
health_check_interval_sec: body.health_check_interval_sec,
|
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
|
health_check_verify_tls: body.health_check_verify_tls,
|
|
},
|
|
);
|
|
if (!domain && group.enabled) {
|
|
repos.setServiceGroupEnabled(db, id, false);
|
|
group = repos.getServiceGroup(db, id);
|
|
}
|
|
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.domain?.trim() && !group.enabled) {
|
|
throw AppError.validation("сначала включите группу сервисов");
|
|
}
|
|
}
|
|
|
|
repos.setServiceEnabled(db, serviceId, enabled);
|
|
|
|
if (!enabled) {
|
|
await cleanupServiceDnsOnly(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
const [disabledView] = attachServiceHealth(db, [
|
|
await buildView(db, serviceId),
|
|
]);
|
|
return disabledView!;
|
|
}
|
|
|
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
const [enabledView] = attachServiceHealth(db, [
|
|
await buildView(db, serviceId),
|
|
]);
|
|
return enabledView!;
|
|
}
|
|
|
|
export async function toggleGroup(
|
|
db: Db,
|
|
cf: CloudflareClient,
|
|
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) {
|
|
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);
|
|
}
|
|
|
|
export function reorderServices(
|
|
db: Db,
|
|
groupId: number | null,
|
|
serviceIds: number[],
|
|
): void {
|
|
if (groupId !== null) {
|
|
repos.getServiceGroup(db, groupId);
|
|
}
|
|
repos.reorderServices(db, groupId, serviceIds);
|
|
}
|
|
|
|
export async function reconcileDnsForTarget(
|
|
db: Db,
|
|
cf: CloudflareClient,
|
|
scope: HealthCheckScope,
|
|
refId: number,
|
|
): Promise<void> {
|
|
if (scope === "binding") {
|
|
const binding = repos.getBinding(db, refId);
|
|
if (!binding.health_check_enabled) return;
|
|
const service = repos.getService(db, binding.service_id);
|
|
if (!shouldPushDns(db, service)) return;
|
|
const cnameTarget = binding.cname_target?.trim() || null;
|
|
if (cnameTarget) return;
|
|
const ips = repos.listServiceIps(db, service.id);
|
|
const targetIps = repos.listBindingIps(db, binding.id);
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
const activeIps = computeActiveIps(db, "binding", refId);
|
|
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
desiredIps,
|
|
null,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const group = repos.getServiceGroup(db, refId);
|
|
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
|
|
return;
|
|
}
|
|
await syncGroupDomainDns(db, cf, refId);
|
|
}
|