Refactor project to transition from Rust backend to Node.js with Fastify; update Dockerfile and Docker configurations for new build process; enhance local development instructions in CONTRIBUTING.md; implement health checks in Docker Compose; update pnpm-lock.yaml with new dependencies for API and shared packages; revise README.md to reflect new stack and development setup.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import type { JwtClaims, LoginRequest, LoginResponse } from "@cfdm/shared";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
|
||||
export async function verifyPassword(
|
||||
config: AppConfig,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (config.adminPasswordHash === "devplaceholder") {
|
||||
if (password === "admin") return;
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
const ok = await verify(config.adminPasswordHash, password);
|
||||
if (!ok) throw AppError.unauthorized();
|
||||
}
|
||||
|
||||
export async function login(
|
||||
config: AppConfig,
|
||||
sign: (payload: JwtClaims) => string,
|
||||
req: LoginRequest,
|
||||
): Promise<LoginResponse> {
|
||||
if (req.username !== config.adminUsername) {
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
await verifyPassword(config, req.password);
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + config.jwtTtlHours * 60 * 60 * 1000,
|
||||
);
|
||||
const token = sign({
|
||||
sub: req.username,
|
||||
exp: Math.floor(expiresAt.getTime() / 1000),
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { ServiceBindingView } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
import * as dnsService from "./dns-service.js";
|
||||
|
||||
export interface CreateBindingRequest {
|
||||
domain_id: number;
|
||||
service_id: number;
|
||||
hostname?: string;
|
||||
target_ip?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBindingRequest {
|
||||
service_id?: number;
|
||||
hostname?: string;
|
||||
target_ip?: string;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname?: string): string {
|
||||
const h = hostname?.trim();
|
||||
return h ? h : "@";
|
||||
}
|
||||
|
||||
async function syncTargetIp(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
bindingId: number,
|
||||
hostname: string,
|
||||
dnsRecordId: number | null,
|
||||
targetIp: string,
|
||||
): Promise<number> {
|
||||
if (dnsRecordId) {
|
||||
await dnsService.update(db, cf, domainId, dnsRecordId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: targetIp,
|
||||
proxied: false,
|
||||
});
|
||||
return dnsRecordId;
|
||||
}
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: targetIp,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
});
|
||||
repos.setBindingDnsRecordId(db, bindingId, record.id);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
export function listAll(db: Db): ServiceBindingView[] {
|
||||
return repos.listAllBindings(db);
|
||||
}
|
||||
|
||||
export function listByDomain(db: Db, domainId: number): ServiceBindingView[] {
|
||||
repos.getDomain(db, domainId);
|
||||
return repos.listBindingsByDomain(db, domainId);
|
||||
}
|
||||
|
||||
export async function create(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
req: CreateBindingRequest,
|
||||
): Promise<ServiceBindingView> {
|
||||
repos.getDomain(db, req.domain_id);
|
||||
repos.getService(db, req.service_id);
|
||||
|
||||
const hostname = normalizeHostname(req.hostname);
|
||||
const binding = repos.insertBinding(
|
||||
db,
|
||||
req.domain_id,
|
||||
req.service_id,
|
||||
hostname,
|
||||
null,
|
||||
);
|
||||
|
||||
const ip = req.target_ip?.trim();
|
||||
if (ip) {
|
||||
await syncTargetIp(db, cf, req.domain_id, binding.id, hostname, null, ip);
|
||||
}
|
||||
|
||||
return repos.getBindingView(db, binding.id);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
id: number,
|
||||
req: UpdateBindingRequest,
|
||||
): Promise<ServiceBindingView> {
|
||||
const existing = repos.getBinding(db, id);
|
||||
const serviceId = req.service_id ?? existing.service_id;
|
||||
if (req.service_id) repos.getService(db, req.service_id);
|
||||
const hostname = req.hostname
|
||||
? normalizeHostname(req.hostname)
|
||||
: existing.hostname;
|
||||
|
||||
repos.updateBindingFields(
|
||||
db,
|
||||
id,
|
||||
serviceId,
|
||||
hostname,
|
||||
existing.dns_record_id,
|
||||
);
|
||||
|
||||
const ip = req.target_ip?.trim();
|
||||
if (ip) {
|
||||
await syncTargetIp(
|
||||
db,
|
||||
cf,
|
||||
existing.domain_id,
|
||||
id,
|
||||
hostname,
|
||||
existing.dns_record_id,
|
||||
ip,
|
||||
);
|
||||
}
|
||||
|
||||
return repos.getBindingView(db, id);
|
||||
}
|
||||
|
||||
export function remove(db: Db, id: number): void {
|
||||
repos.getBinding(db, id);
|
||||
repos.deleteBinding(db, id);
|
||||
}
|
||||
|
||||
export async function setDomainServices(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
serviceIds: number[],
|
||||
): Promise<number[]> {
|
||||
repos.getDomain(db, domainId);
|
||||
for (const sid of serviceIds) {
|
||||
repos.getService(db, sid);
|
||||
}
|
||||
|
||||
const existing = repos.listBindingsByDomain(db, domainId);
|
||||
for (const binding of existing) {
|
||||
if (!serviceIds.includes(binding.service_id)) {
|
||||
repos.deleteBinding(db, binding.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sid of serviceIds) {
|
||||
const already = existing.some((b) => b.service_id === sid);
|
||||
if (!already) {
|
||||
repos.insertBinding(db, domainId, sid, "@", null);
|
||||
}
|
||||
}
|
||||
|
||||
return repos
|
||||
.listBindingsByDomain(db, domainId)
|
||||
.map((b) => b.service_id);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { connect } from "node:net";
|
||||
import { connect as tlsConnect } from "node:tls";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Certificate } from "@cfdm/shared";
|
||||
import {
|
||||
CERT_ERROR,
|
||||
CERT_UNKNOWN,
|
||||
certStatusFromExpiry,
|
||||
} from "@cfdm/shared";
|
||||
|
||||
export function listCertificates(
|
||||
db: Db,
|
||||
status?: string,
|
||||
): Certificate[] {
|
||||
return repos.listCertificates(db, status);
|
||||
}
|
||||
|
||||
export function getCertificate(db: Db, id: number): Certificate {
|
||||
return repos.getCertificate(db, id);
|
||||
}
|
||||
|
||||
export async function checkHostname(
|
||||
hostname: string,
|
||||
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ host: hostname, port: 443, timeout: 10_000 });
|
||||
socket.on("error", (e) =>
|
||||
resolve({ expiresAt: null, error: e.message }),
|
||||
);
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve({ expiresAt: null, error: "connection timeout" });
|
||||
});
|
||||
socket.on("connect", () => {
|
||||
const tlsSocket = tlsConnect(
|
||||
{ socket, servername: hostname, rejectUnauthorized: true },
|
||||
() => {
|
||||
const cert = tlsSocket.getPeerCertificate();
|
||||
tlsSocket.end();
|
||||
if (!cert?.valid_to) {
|
||||
resolve({ expiresAt: null, error: "no peer certificates" });
|
||||
return;
|
||||
}
|
||||
resolve({ expiresAt: new Date(cert.valid_to), error: null });
|
||||
},
|
||||
);
|
||||
tlsSocket.on("error", (e) =>
|
||||
resolve({ expiresAt: null, error: e.message }),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkAndStore(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
subdomainId: number | null,
|
||||
hostname: string,
|
||||
): Promise<Certificate> {
|
||||
const { expiresAt, error } = await checkHostname(hostname);
|
||||
|
||||
if (error) {
|
||||
return repos.upsertCertificateCheck(
|
||||
db,
|
||||
domainId,
|
||||
subdomainId,
|
||||
hostname,
|
||||
null,
|
||||
CERT_ERROR,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (expiresAt) {
|
||||
const days = Math.floor(
|
||||
(expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
return repos.upsertCertificateCheck(
|
||||
db,
|
||||
domainId,
|
||||
subdomainId,
|
||||
hostname,
|
||||
expiresAt.toISOString(),
|
||||
certStatusFromExpiry(days),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
return repos.upsertCertificateCheck(
|
||||
db,
|
||||
domainId,
|
||||
subdomainId,
|
||||
hostname,
|
||||
null,
|
||||
CERT_UNKNOWN,
|
||||
"unknown expiry",
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAllChecks(db: Db): Promise<number> {
|
||||
let count = 0;
|
||||
for (const domain of repos.listAllDomains(db)) {
|
||||
await checkAndStore(db, domain.id, null, domain.zone_name);
|
||||
count += 1;
|
||||
}
|
||||
for (const sub of repos.listAllSubdomains(db)) {
|
||||
await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn);
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function statusSummary(db: Db): Array<[string, number]> {
|
||||
return repos.countCertificatesByStatus(db);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos, type DnsListFilter } from "@cfdm/db";
|
||||
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
|
||||
import {
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import { validateDnsRecord } from "../lib/validators.js";
|
||||
|
||||
export interface CreateDnsRequest {
|
||||
record_type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDnsRequest {
|
||||
record_type?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface BulkDnsOp {
|
||||
action: string;
|
||||
id?: number;
|
||||
record?: CreateDnsRequest;
|
||||
}
|
||||
|
||||
export interface BulkDnsResult {
|
||||
id?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ResolveDnsRequest {
|
||||
source: string;
|
||||
}
|
||||
|
||||
function toCfPayload(
|
||||
recordType: string,
|
||||
name: string,
|
||||
content: string,
|
||||
ttl: number,
|
||||
proxied: boolean,
|
||||
priority: number | null,
|
||||
): CreateDnsRecordPayload {
|
||||
return {
|
||||
type: recordType.toUpperCase(),
|
||||
name,
|
||||
content,
|
||||
ttl,
|
||||
proxied,
|
||||
priority: priority ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function pushRecord(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
cfZoneId: string,
|
||||
record: DnsRecord,
|
||||
): Promise<DnsRecord> {
|
||||
const payload = toCfPayload(
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
record.ttl,
|
||||
record.proxied,
|
||||
record.priority,
|
||||
);
|
||||
|
||||
try {
|
||||
const cfRec = record.cf_record_id
|
||||
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
|
||||
: await cf.createDnsRecord(cfZoneId, payload);
|
||||
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
record.ttl,
|
||||
record.proxied,
|
||||
record.priority,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
} catch (e) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
SYNC_ERROR,
|
||||
record.cf_record_id,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
req: CreateDnsRequest,
|
||||
): Promise<DnsRecord> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const ttl = req.ttl ?? 1;
|
||||
const proxied = req.proxied ?? false;
|
||||
validateDnsRecord(req.record_type, req.name, req.content, ttl, proxied);
|
||||
|
||||
const record = repos.insertDnsRecord(
|
||||
db,
|
||||
domainId,
|
||||
req.record_type,
|
||||
req.name,
|
||||
req.content,
|
||||
ttl,
|
||||
proxied,
|
||||
req.priority ?? null,
|
||||
SYNC_PENDING_PUSH,
|
||||
"local",
|
||||
null,
|
||||
);
|
||||
|
||||
return pushRecord(db, cf, domainId, domain.cf_zone_id, record);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
recordId: number,
|
||||
req: UpdateDnsRequest,
|
||||
): Promise<DnsRecord> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const existing = repos.getDnsRecord(db, domainId, recordId);
|
||||
|
||||
const recordType = req.record_type ?? existing.record_type;
|
||||
const name = req.name ?? existing.name;
|
||||
const content = req.content ?? existing.content;
|
||||
const ttl = req.ttl ?? existing.ttl;
|
||||
const proxied = req.proxied ?? existing.proxied;
|
||||
const priority = req.priority ?? existing.priority;
|
||||
|
||||
validateDnsRecord(recordType, name, content, ttl, proxied);
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
recordId,
|
||||
recordType,
|
||||
name,
|
||||
content,
|
||||
ttl,
|
||||
proxied,
|
||||
priority,
|
||||
SYNC_PENDING_PUSH,
|
||||
existing.cf_record_id,
|
||||
null,
|
||||
);
|
||||
|
||||
const updated = repos.getDnsRecord(db, domainId, recordId);
|
||||
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
||||
}
|
||||
|
||||
export async function deleteRecord(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
recordId: number,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||
repos.markDnsPendingDelete(db, recordId);
|
||||
|
||||
if (record.cf_record_id) {
|
||||
try {
|
||||
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
|
||||
} catch (e) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
recordId,
|
||||
SYNC_ERROR,
|
||||
record.cf_record_id,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
repos.deleteDnsRecord(db, recordId);
|
||||
}
|
||||
|
||||
export function list(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
filter: DnsListFilter,
|
||||
): DnsRecord[] {
|
||||
repos.getDomain(db, domainId);
|
||||
return repos.listDnsRecords(db, domainId, filter);
|
||||
}
|
||||
|
||||
export function get(db: Db, domainId: number, recordId: number): DnsRecord {
|
||||
return repos.getDnsRecord(db, domainId, recordId);
|
||||
}
|
||||
|
||||
export async function bulk(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
ops: BulkDnsOp[],
|
||||
): Promise<BulkDnsResult[]> {
|
||||
const results: BulkDnsResult[] = [];
|
||||
for (const op of ops) {
|
||||
try {
|
||||
if (op.action === "create") {
|
||||
if (!op.record) throw AppError.validation("record required");
|
||||
const r = await create(db, cf, domainId, op.record);
|
||||
results.push({ id: r.id, success: true });
|
||||
} else if (op.action === "update") {
|
||||
if (op.id == null) throw AppError.validation("id required");
|
||||
if (!op.record) throw AppError.validation("record required");
|
||||
await update(db, cf, domainId, op.id, {
|
||||
record_type: op.record.record_type,
|
||||
name: op.record.name,
|
||||
content: op.record.content,
|
||||
ttl: op.record.ttl,
|
||||
proxied: op.record.proxied,
|
||||
priority: op.record.priority,
|
||||
});
|
||||
results.push({ id: op.id, success: true });
|
||||
} else if (op.action === "delete") {
|
||||
if (op.id == null) throw AppError.validation("id required");
|
||||
await deleteRecord(db, cf, domainId, op.id);
|
||||
results.push({ id: op.id, success: true });
|
||||
} else {
|
||||
results.push({
|
||||
id: op.id,
|
||||
success: false,
|
||||
error: `unknown action: ${op.action}`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
results.push({
|
||||
id: op.id,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function resolveConflict(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
recordId: number,
|
||||
req: ResolveDnsRequest,
|
||||
): Promise<DnsRecord> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||
if (record.sync_status !== SYNC_CONFLICT) {
|
||||
throw AppError.validation("record is not in conflict state");
|
||||
}
|
||||
|
||||
if (req.source === "cloudflare") {
|
||||
if (record.cf_record_id) {
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const r = remote.find((x) => x.id === record.cf_record_id);
|
||||
if (r) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
recordId,
|
||||
r.type,
|
||||
r.name,
|
||||
r.content,
|
||||
r.ttl,
|
||||
r.proxied ?? false,
|
||||
r.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
r.id ?? null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
return repos.getDnsRecord(db, domainId, recordId);
|
||||
}
|
||||
|
||||
if (req.source === "local") {
|
||||
const updated = repos.getDnsRecord(db, domainId, recordId);
|
||||
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
||||
}
|
||||
|
||||
throw AppError.validation("source must be cloudflare or local");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Domain, DomainListItem } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import * as bindingService from "./binding-service.js";
|
||||
import * as syncService from "./sync-service.js";
|
||||
|
||||
export function listDomains(
|
||||
db: Db,
|
||||
groupId?: number,
|
||||
): DomainListItem[] {
|
||||
return repos.listDomainsEnriched(db, groupId);
|
||||
}
|
||||
|
||||
export function getDomain(db: Db, id: number): Domain {
|
||||
return repos.getDomain(db, id);
|
||||
}
|
||||
|
||||
export async function createDomain(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number | null,
|
||||
zoneName: string,
|
||||
): Promise<Domain> {
|
||||
const trimmed = zoneName.trim();
|
||||
const zones = await cf.listZones();
|
||||
if (zones.length === 0) {
|
||||
throw AppError.notFound(
|
||||
"нет доступных зон в Cloudflare — проверьте CLOUDFLARE_API_TOKEN и права Zone:Read",
|
||||
);
|
||||
}
|
||||
const zone = zones.find((z) => z.name.toLowerCase() === trimmed.toLowerCase());
|
||||
if (!zone) {
|
||||
const names = zones.map((z) => z.name).join(", ");
|
||||
throw AppError.notFound(
|
||||
`зона «${trimmed}» не найдена в Cloudflare. Доступные: ${names}`,
|
||||
);
|
||||
}
|
||||
return repos.createDomain(db, groupId, zone.name, zone.id);
|
||||
}
|
||||
|
||||
export function updateDomain(
|
||||
db: Db,
|
||||
id: number,
|
||||
groupId: number | null,
|
||||
status: string,
|
||||
): Domain {
|
||||
return repos.updateDomain(db, id, groupId, status);
|
||||
}
|
||||
|
||||
export function deleteDomain(db: Db, id: number): void {
|
||||
repos.deleteDomain(db, id);
|
||||
}
|
||||
|
||||
export async function setDomainServices(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
serviceIds: number[],
|
||||
): Promise<number[]> {
|
||||
return bindingService.setDomainServices(db, domainId, serviceIds);
|
||||
}
|
||||
|
||||
export async function importZoneRecords(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
): Promise<number> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
return syncService.pullSync(db, cf, domain);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Group } from "@cfdm/shared";
|
||||
|
||||
export function listGroups(db: Db): Group[] {
|
||||
return repos.listGroups(db);
|
||||
}
|
||||
|
||||
export function createGroup(db: Db, name: string, slug: string): Group {
|
||||
return repos.createGroup(db, name, slug);
|
||||
}
|
||||
|
||||
export function updateGroup(
|
||||
db: Db,
|
||||
id: number,
|
||||
name: string,
|
||||
slug: string,
|
||||
): Group {
|
||||
return repos.updateGroup(db, id, name, slug);
|
||||
}
|
||||
|
||||
export function deleteGroup(db: Db, id: number): void {
|
||||
repos.deleteGroup(db, id);
|
||||
}
|
||||
|
||||
export function getGroupWithStats(db: Db, id: number) {
|
||||
return repos.getGroupWithStats(db, id);
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type {
|
||||
Service,
|
||||
ServiceGroup,
|
||||
ServiceGroupsResponse,
|
||||
ServiceView,
|
||||
} from "@cfdm/shared";
|
||||
import {
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import { isValidIpv4 } from "../lib/validators.js";
|
||||
import * as dnsService from "./dns-service.js";
|
||||
import * as domainService from "./domain-service.js";
|
||||
|
||||
export interface ServiceDomainInput {
|
||||
fqdn: string;
|
||||
target_ips?: string[];
|
||||
target_ip?: string;
|
||||
}
|
||||
|
||||
export interface ToggleRequest {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceGroupBody {
|
||||
name: string;
|
||||
type?: string;
|
||||
icon?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigRequest {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
service_group_id?: number | null;
|
||||
ips?: string[];
|
||||
domains?: ServiceDomainInput[];
|
||||
}
|
||||
|
||||
export function fqdnToDisplay(hostname: string, zoneName: string): string {
|
||||
return hostname === "@" ? zoneName : `${hostname}.${zoneName}`;
|
||||
}
|
||||
|
||||
export function parseFqdn(
|
||||
fqdn: string,
|
||||
knownZones: string[],
|
||||
): { zoneName: string; hostname: string } {
|
||||
const normalized = fqdn.trim().toLowerCase();
|
||||
if (!normalized) throw AppError.validation("укажите FQDN");
|
||||
|
||||
const zones = [...knownZones].sort((a, b) => b.length - a.length);
|
||||
for (const zone of zones) {
|
||||
const zoneLower = zone.toLowerCase();
|
||||
if (normalized === zoneLower) {
|
||||
return { zoneName: zone, hostname: "@" };
|
||||
}
|
||||
const suffix = `.${zoneLower}`;
|
||||
if (normalized.endsWith(suffix)) {
|
||||
const prefix = normalized.slice(0, -suffix.length);
|
||||
if (prefix) return { zoneName: zone, hostname: prefix };
|
||||
}
|
||||
}
|
||||
|
||||
throw AppError.validation(
|
||||
`не удалось определить зону для «${fqdn}» — зона должна существовать в Cloudflare`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIps(ips: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const ip of ips) {
|
||||
const trimmed = ip.trim();
|
||||
if (!trimmed || !isValidIpv4(trimmed)) continue;
|
||||
if (!out.includes(trimmed)) out.push(trimmed);
|
||||
}
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
function aggregateSyncStatus(statuses: string[]): string | null {
|
||||
if (statuses.length === 0) return null;
|
||||
if (statuses.some((s) => s === SYNC_ERROR)) return SYNC_ERROR;
|
||||
if (statuses.some((s) => s === SYNC_PENDING_PUSH)) return SYNC_PENDING_PUSH;
|
||||
if (statuses.every((s) => s === SYNC_SYNCED)) return SYNC_SYNCED;
|
||||
return statuses[0] ?? null;
|
||||
}
|
||||
|
||||
async function collectKnownZones(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
): Promise<string[]> {
|
||||
const dbDomains = repos.listDomains(db);
|
||||
const zones = dbDomains.map((d) => d.zone_name);
|
||||
const cfZones = await cf.listZones();
|
||||
for (const zone of cfZones) {
|
||||
if (!zones.some((n) => n.toLowerCase() === zone.name.toLowerCase())) {
|
||||
zones.push(zone.name);
|
||||
}
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
const service = repos.getService(db, serviceId);
|
||||
const ips = repos.listServiceIps(db, serviceId);
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
|
||||
const domainViews = bindings.map((binding) => {
|
||||
const records = repos.listRecordsForBinding(db, binding.id);
|
||||
const statuses = records.map((r) => r.sync_status);
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
zone_name: binding.zone_name,
|
||||
hostname: binding.hostname,
|
||||
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
||||
target_ips: targetIps,
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
slug: service.slug,
|
||||
service_group_id: service.service_group_id,
|
||||
subdomain: service.subdomain,
|
||||
enabled: service.enabled,
|
||||
computed_fqdn: null,
|
||||
created_at: service.created_at,
|
||||
updated_at: service.updated_at,
|
||||
ips,
|
||||
domains: domainViews,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listViews(db: Db): Promise<ServiceView[]> {
|
||||
return Promise.all(
|
||||
repos.listServices(db).map((s) => buildView(db, s.id)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getView(db: Db, id: number): Promise<ServiceView> {
|
||||
repos.getService(db, id);
|
||||
return buildView(db, id);
|
||||
}
|
||||
|
||||
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
||||
const groups = repos.listServiceGroups(db);
|
||||
const groupViews = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const services = repos.listServicesByGroup(db, group.id);
|
||||
const serviceViews = await Promise.all(
|
||||
services.map((s) => buildView(db, s.id)),
|
||||
);
|
||||
return { ...group, services: serviceViews };
|
||||
}),
|
||||
);
|
||||
|
||||
const ungroupedServices = repos.listUngroupedServices(db);
|
||||
const ungrouped = await Promise.all(
|
||||
ungroupedServices.map((s) => buildView(db, s.id)),
|
||||
);
|
||||
|
||||
return { groups: groupViews, ungrouped };
|
||||
}
|
||||
|
||||
function shouldPushDns(db: Db, service: Service): boolean {
|
||||
if (!service.enabled) return false;
|
||||
if (!service.service_group_id) return true;
|
||||
const group = repos.getServiceGroup(db, service.service_group_id);
|
||||
return group.enabled;
|
||||
}
|
||||
|
||||
async function syncBindingDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
bindingId: number,
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
): Promise<void> {
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
if (!desiredIps.includes(record.content)) {
|
||||
repos.unlinkBindingRecord(db, bindingId, record.id);
|
||||
await dnsService.deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (desiredIps.length === 0) {
|
||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = repos.listRecordsForBinding(db, bindingId);
|
||||
let primaryId: number | null = null;
|
||||
|
||||
for (const ip of desiredIps) {
|
||||
const existing = refreshed.find((r) => r.content === ip);
|
||||
let recordId: number;
|
||||
if (existing) {
|
||||
if (existing.name !== hostname) {
|
||||
await dnsService.update(db, cf, domainId, existing.id, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: ip,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
recordId = existing.id;
|
||||
} else {
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
});
|
||||
repos.linkBindingRecord(db, bindingId, record.id);
|
||||
recordId = record.id;
|
||||
}
|
||||
if (primaryId == null) primaryId = recordId;
|
||||
}
|
||||
|
||||
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||
}
|
||||
|
||||
async function cleanupBindingDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
bindingId: number,
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
): Promise<void> {
|
||||
await syncBindingDns(db, cf, bindingId, domainId, hostname, []);
|
||||
}
|
||||
|
||||
async function cleanupServiceDnsOnly(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
serviceId: number,
|
||||
): Promise<void> {
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
for (const binding of bindings) {
|
||||
await cleanupBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTargetIpsInPool(targetIps: string[], ips: string[]): void {
|
||||
for (const ip of targetIps) {
|
||||
if (!isValidIpv4(ip)) {
|
||||
throw AppError.validation(`некорректный IPv4: ${ip}`);
|
||||
}
|
||||
if (!ips.includes(ip)) {
|
||||
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindingTargetIps(input: ServiceDomainInput): string[] {
|
||||
const raw = input.target_ips
|
||||
? input.target_ips
|
||||
: input.target_ip?.trim()
|
||||
? [input.target_ip.trim()]
|
||||
: [];
|
||||
const normalized = normalizeIps(raw);
|
||||
if (raw.length > 0 && normalized.length === 0) {
|
||||
throw AppError.validation("некорректные IP в привязке домена");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function syncServiceBindingsToDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
serviceId: number,
|
||||
): Promise<void> {
|
||||
const ips = repos.listServiceIps(db, serviceId);
|
||||
if (ips.length === 0) {
|
||||
throw AppError.validation("добавьте IP-адреса в пул сервиса");
|
||||
}
|
||||
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
if (bindings.length === 0) {
|
||||
throw AppError.validation("настройте FQDN в редакторе сервиса");
|
||||
}
|
||||
|
||||
for (const binding of bindings) {
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
if (targetIps.length === 0) {
|
||||
throw AppError.validation(
|
||||
`укажите IP для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
|
||||
);
|
||||
}
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
targetIps,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectGroupDnsIps(
|
||||
db: Db,
|
||||
groupId: number,
|
||||
): Promise<string[]> {
|
||||
const services = repos.listServicesByGroup(db, groupId);
|
||||
const ips: string[] = [];
|
||||
for (const service of services) {
|
||||
if (!service.enabled) continue;
|
||||
const bindings = repos.listBindingsByService(db, service.id);
|
||||
for (const binding of bindings) {
|
||||
for (const ip of repos.listBindingIps(db, binding.id)) {
|
||||
if (!ips.includes(ip)) ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
ips.sort();
|
||||
return ips;
|
||||
}
|
||||
|
||||
async function syncGroupDomainDnsRecords(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
): Promise<void> {
|
||||
const existingRecords = repos.listGroupDnsRecords(db, groupId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
if (!desiredIps.includes(record.content)) {
|
||||
repos.unlinkGroupDnsRecord(db, groupId, record.id);
|
||||
await dnsService.deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (desiredIps.length === 0) return;
|
||||
|
||||
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
||||
for (const ip of desiredIps) {
|
||||
const existing = refreshed.find((r) => r.content === ip);
|
||||
if (existing) {
|
||||
if (existing.name !== hostname) {
|
||||
await dnsService.update(db, cf, domainId, existing.id, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: ip,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
});
|
||||
repos.linkGroupDnsRecord(db, groupId, record.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDomainId(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
zoneName: string,
|
||||
): Promise<number> {
|
||||
const trimmed = zoneName.trim();
|
||||
if (!trimmed) throw AppError.validation("укажите имя зоны");
|
||||
const existing = repos.findDomainByZoneName(db, trimmed);
|
||||
if (existing) return existing.id;
|
||||
const created = await domainService.createDomain(db, cf, null, trimmed);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function cleanupGroupDomainDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
): Promise<void> {
|
||||
const group = repos.getServiceGroup(db, groupId);
|
||||
const domainValue = group.domain?.trim();
|
||||
if (!domainValue) return;
|
||||
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []);
|
||||
}
|
||||
|
||||
async function syncGroupDomainDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
): Promise<void> {
|
||||
const group = repos.getServiceGroup(db, groupId);
|
||||
if (!group.enabled) {
|
||||
await cleanupGroupDomainDns(db, cf, groupId);
|
||||
return;
|
||||
}
|
||||
const domainValue = group.domain?.trim();
|
||||
if (!domainValue) return;
|
||||
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
const desiredIps = await collectGroupDnsIps(db, groupId);
|
||||
await syncGroupDomainDnsRecords(
|
||||
db,
|
||||
cf,
|
||||
groupId,
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function syncGroupDomainForService(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
serviceId: number,
|
||||
): Promise<void> {
|
||||
const service = repos.getService(db, serviceId);
|
||||
if (!service.service_group_id) return;
|
||||
await syncGroupDomainDns(db, cf, service.service_group_id);
|
||||
}
|
||||
|
||||
async function syncEnabledServicesInGroup(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
): Promise<void> {
|
||||
const group = repos.getServiceGroup(db, groupId);
|
||||
if (!group.enabled || !group.domain?.trim()) return;
|
||||
|
||||
const services = repos.listServicesByGroup(db, groupId);
|
||||
for (const service of services) {
|
||||
if (service.enabled) {
|
||||
await syncServiceBindingsToDns(db, cf, service.id);
|
||||
}
|
||||
}
|
||||
await syncGroupDomainDns(db, cf, groupId);
|
||||
}
|
||||
|
||||
async function normalizeGroupDomain(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domain?: string,
|
||||
): Promise<string | null> {
|
||||
const raw = domain?.trim();
|
||||
if (!raw) return null;
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
const { zoneName, hostname } = parseFqdn(raw, knownZones);
|
||||
return fqdnToDisplay(hostname, zoneName);
|
||||
}
|
||||
|
||||
async function cleanupStaleGroupFqdnBindings(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
fqdn: string,
|
||||
): Promise<void> {
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
||||
if (hostname === "@") return;
|
||||
|
||||
const domain = repos.findDomainByZoneName(db, zoneName);
|
||||
if (!domain) return;
|
||||
|
||||
const services = repos.listServicesByGroup(db, groupId);
|
||||
for (const service of services) {
|
||||
const binding = repos.findBinding(
|
||||
db,
|
||||
service.id,
|
||||
domain.id,
|
||||
hostname,
|
||||
);
|
||||
if (!binding) continue;
|
||||
await cleanupBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
);
|
||||
repos.deleteBinding(db, binding.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
id: number,
|
||||
req: UpdateServiceConfigRequest,
|
||||
): Promise<ServiceView> {
|
||||
if (req.name && req.slug) {
|
||||
repos.updateService(db, id, req.name, req.slug);
|
||||
} else if (req.name) {
|
||||
const existing = repos.getService(db, id);
|
||||
repos.updateService(db, id, req.name, existing.slug);
|
||||
} else if (req.slug) {
|
||||
const existing = repos.getService(db, id);
|
||||
repos.updateService(db, id, existing.name, req.slug);
|
||||
}
|
||||
|
||||
if (req.service_group_id !== undefined) {
|
||||
repos.setServiceGroup(db, id, req.service_group_id);
|
||||
}
|
||||
|
||||
const ipsUpdated = req.ips !== undefined;
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
|
||||
const ips = req.ips ? normalizeIps(req.ips) : repos.listServiceIps(db, id);
|
||||
if (ipsUpdated) repos.replaceServiceIps(db, id, ips);
|
||||
|
||||
const keptBindingIds: number[] = [];
|
||||
let service = repos.getService(db, id);
|
||||
const pushDns = shouldPushDns(db, service);
|
||||
|
||||
if (req.domains) {
|
||||
if (req.domains.length > 0) {
|
||||
for (const input of req.domains) {
|
||||
const fqdn = input.fqdn.trim();
|
||||
if (!fqdn) continue;
|
||||
const targetIps = bindingTargetIps(input);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
|
||||
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
|
||||
const binding =
|
||||
repos.findBinding(db, id, domainId, hostname) ??
|
||||
repos.insertBinding(db, domainId, id, hostname, null);
|
||||
|
||||
keptBindingIds.push(binding.id);
|
||||
repos.replaceBindingIps(db, binding.id, targetIps);
|
||||
|
||||
if (pushDns) {
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
domainId,
|
||||
hostname,
|
||||
targetIps,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
||||
for (const binding of removed) {
|
||||
await cleanupBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
);
|
||||
}
|
||||
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
||||
}
|
||||
} else if (ipsUpdated) {
|
||||
const bindings = repos.listBindingsByService(db, id);
|
||||
for (const binding of bindings) {
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
for (const ip of targetIps) {
|
||||
if (!ips.includes(ip)) {
|
||||
throw AppError.validation(
|
||||
`IP ${ip} привязан к ${fqdnToDisplay(binding.hostname, binding.zone_name)}, но отсутствует в новом пуле адресов`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service = repos.getService(db, id);
|
||||
if (shouldPushDns(db, service)) {
|
||||
await syncServiceBindingsToDns(db, cf, id);
|
||||
await syncGroupDomainForService(db, cf, id);
|
||||
}
|
||||
|
||||
return buildView(db, id);
|
||||
}
|
||||
|
||||
export async function createGroup(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
body: ServiceGroupBody,
|
||||
): Promise<ServiceGroup> {
|
||||
const groupType = body.type?.trim() || "custom";
|
||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||
return repos.createServiceGroup(
|
||||
db,
|
||||
body.name,
|
||||
groupType,
|
||||
body.icon ?? null,
|
||||
domain,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateGroup(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
id: number,
|
||||
body: ServiceGroupBody,
|
||||
): Promise<ServiceGroup> {
|
||||
const groupType = body.type?.trim() || "custom";
|
||||
const previous = repos.getServiceGroup(db, id);
|
||||
const oldDomain = previous.domain?.trim();
|
||||
if (oldDomain) {
|
||||
await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);
|
||||
await cleanupGroupDomainDns(db, cf, id);
|
||||
}
|
||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||
const group = repos.updateServiceGroup(
|
||||
db,
|
||||
id,
|
||||
body.name,
|
||||
groupType,
|
||||
body.icon ?? null,
|
||||
domain,
|
||||
);
|
||||
await syncEnabledServicesInGroup(db, cf, id);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function deleteGroup(db: Db, id: number): void {
|
||||
repos.deleteServiceGroup(db, id);
|
||||
}
|
||||
|
||||
export async function toggleService(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
serviceId: number,
|
||||
enabled: boolean,
|
||||
): Promise<ServiceView> {
|
||||
const service = repos.getService(db, serviceId);
|
||||
|
||||
if (enabled && service.service_group_id) {
|
||||
const group = repos.getServiceGroup(db, service.service_group_id);
|
||||
if (!group.enabled) {
|
||||
throw AppError.validation("сначала включите группу сервисов");
|
||||
}
|
||||
if (!group.domain?.trim()) {
|
||||
throw AppError.validation("укажите домен у группы сервисов");
|
||||
}
|
||||
}
|
||||
|
||||
repos.setServiceEnabled(db, serviceId, enabled);
|
||||
|
||||
if (!enabled) {
|
||||
await cleanupServiceDnsOnly(db, cf, serviceId);
|
||||
await syncGroupDomainForService(db, cf, serviceId);
|
||||
return buildView(db, serviceId);
|
||||
}
|
||||
|
||||
await syncServiceBindingsToDns(db, cf, serviceId);
|
||||
await syncGroupDomainForService(db, cf, serviceId);
|
||||
return buildView(db, serviceId);
|
||||
}
|
||||
|
||||
export async function toggleGroup(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
groupId: number,
|
||||
enabled: boolean,
|
||||
): Promise<ServiceGroupsResponse> {
|
||||
repos.setServiceGroupEnabled(db, groupId, enabled);
|
||||
|
||||
if (!enabled) {
|
||||
const services = repos.listServicesByGroup(db, groupId);
|
||||
for (const service of services) {
|
||||
if (service.enabled) {
|
||||
repos.setServiceEnabled(db, service.id, false);
|
||||
await cleanupServiceDnsOnly(db, cf, service.id);
|
||||
}
|
||||
}
|
||||
await cleanupGroupDomainDns(db, cf, groupId);
|
||||
} else {
|
||||
await syncEnabledServicesInGroup(db, cf, groupId);
|
||||
}
|
||||
|
||||
return listGroupViews(db);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Domain, SyncJob } from "@cfdm/shared";
|
||||
import {
|
||||
SYNC_CONFLICT,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsNameToSubdomainLabel,
|
||||
subdomainLabelToFqdn,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export async function pullSync(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domain: Domain,
|
||||
): Promise<number> {
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const local = repos.listDnsByDomain(db, domain.id);
|
||||
let changed = 0;
|
||||
|
||||
const remoteIds = new Set(
|
||||
remote.map((r) => r.id).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
for (const cfRec of remote) {
|
||||
const cfId = cfRec.id;
|
||||
if (!cfId) continue;
|
||||
const proxied = cfRec.proxied ?? false;
|
||||
|
||||
const existing = repos.findDnsByCfId(db, domain.id, cfId);
|
||||
if (existing) {
|
||||
const contentMatch =
|
||||
existing.content === cfRec.content &&
|
||||
existing.ttl === cfRec.ttl &&
|
||||
existing.proxied === proxied &&
|
||||
existing.name === cfRec.name &&
|
||||
existing.record_type.toUpperCase() === cfRec.type.toUpperCase();
|
||||
|
||||
if (!contentMatch && existing.sync_status !== SYNC_PENDING_PUSH) {
|
||||
repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);
|
||||
changed += 1;
|
||||
} else if (contentMatch && existing.sync_status === SYNC_CONFLICT) {
|
||||
repos.setDnsSyncStatus(db, existing.id, SYNC_SYNCED, cfId, null);
|
||||
changed += 1;
|
||||
}
|
||||
} else {
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
cfRec.type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
proxied,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
cfId,
|
||||
);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const rec of local) {
|
||||
if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) {
|
||||
if (rec.sync_status !== "pending_delete") {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
rec.id,
|
||||
SYNC_CONFLICT,
|
||||
rec.cf_record_id,
|
||||
"missing in cloudflare",
|
||||
);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const labels = new Set<string>();
|
||||
for (const rec of remote) {
|
||||
const label = dnsNameToSubdomainLabel(rec.name, domain.zone_name);
|
||||
if (label) labels.add(label);
|
||||
}
|
||||
for (const label of labels) {
|
||||
const fqdn = subdomainLabelToFqdn(label, domain.zone_name);
|
||||
repos.upsertSubdomain(db, domain.id, label, fqdn);
|
||||
changed += 1;
|
||||
}
|
||||
|
||||
repos.setDomainLastSynced(db, domain.id);
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function syncDomain(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
): Promise<{ jobId: string; changes: number }> {
|
||||
const jobId = randomUUID();
|
||||
repos.createSyncJob(db, jobId, domainId);
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
|
||||
try {
|
||||
const changes = await pullSync(db, cf, domain);
|
||||
repos.finishSyncJob(db, jobId, "completed", `${changes} changes`);
|
||||
return { jobId, changes };
|
||||
} catch (e) {
|
||||
repos.finishSyncJob(
|
||||
db,
|
||||
jobId,
|
||||
"failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncAll(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
): Promise<string> {
|
||||
const jobId = randomUUID();
|
||||
repos.createSyncJob(db, jobId, null);
|
||||
const all = repos.listAllDomains(db);
|
||||
let total = 0;
|
||||
for (const domain of all) {
|
||||
try {
|
||||
total += await pullSync(db, cf, domain);
|
||||
} catch {
|
||||
// continue other domains
|
||||
}
|
||||
}
|
||||
repos.finishSyncJob(db, jobId, "completed", `${total} total changes`);
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export function getJob(db: Db, jobId: string): SyncJob {
|
||||
return repos.getSyncJob(db, jobId);
|
||||
}
|
||||
Reference in New Issue
Block a user