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
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:
Vendored
+389
-87
@@ -424,6 +424,7 @@ async function groupRoutes(app2) {
|
||||
|
||||
// src/routes/services.ts
|
||||
import { z as z3 } from "zod";
|
||||
import { reorderServicesSchema } from "@cfdm/shared";
|
||||
import { repos as repos7 } from "@cfdm/db";
|
||||
|
||||
// src/services/service-config-service.ts
|
||||
@@ -431,7 +432,8 @@ import { repos as repos6 } from "@cfdm/db";
|
||||
import {
|
||||
SYNC_ERROR as SYNC_ERROR2,
|
||||
SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3,
|
||||
SYNC_SYNCED as SYNC_SYNCED3
|
||||
SYNC_SYNCED as SYNC_SYNCED3,
|
||||
dnsNameToSubdomainLabel as dnsNameToSubdomainLabel2
|
||||
} from "@cfdm/shared";
|
||||
|
||||
// src/lib/validators.ts
|
||||
@@ -874,9 +876,9 @@ async function createDomain(db, cf, groupId, zoneName) {
|
||||
"\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read"
|
||||
);
|
||||
}
|
||||
const zone = zones.find((z9) => z9.name.toLowerCase() === trimmed.toLowerCase());
|
||||
const zone = zones.find((z8) => z8.name.toLowerCase() === trimmed.toLowerCase());
|
||||
if (!zone) {
|
||||
const names = zones.map((z9) => z9.name).join(", ");
|
||||
const names = zones.map((z8) => z8.name).join(", ");
|
||||
throw AppError.notFound(
|
||||
`\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}`
|
||||
);
|
||||
@@ -956,13 +958,19 @@ async function buildView(db, serviceId) {
|
||||
const records = repos6.listRecordsForBinding(db, binding.id);
|
||||
const statuses = records.map((r) => r.sync_status);
|
||||
const targetIps = repos6.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" : "A",
|
||||
target_ips: targetCname ? [] : targetIps,
|
||||
target_cname: targetCname,
|
||||
sync_status: aggregateSyncStatus(statuses)
|
||||
};
|
||||
});
|
||||
@@ -1012,14 +1020,114 @@ function shouldPushDns(db, service) {
|
||||
const group = repos6.getServiceGroup(db, service.service_group_id);
|
||||
return group.enabled;
|
||||
}
|
||||
async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps) {
|
||||
async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) {
|
||||
const domain = repos6.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;
|
||||
repos6.setBindingCnameTarget(db, bindingId, effectiveCname);
|
||||
repos6.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, cf, bindingId, domainId, hostname, cnameTarget) {
|
||||
const domain = repos6.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
||||
const existingRecords = repos6.listRecordsForBinding(db, bindingId);
|
||||
for (const record of existingRecords) {
|
||||
if (!desiredIps.includes(record.content)) {
|
||||
if (record.record_type.toUpperCase() === "A") {
|
||||
repos6.unlinkBindingRecord(db, bindingId, record.id);
|
||||
await deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
}
|
||||
const refreshed = repos6.listRecordsForBinding(db, bindingId);
|
||||
const existingCname = refreshed.find(
|
||||
(record) => record.record_type.toUpperCase() === "CNAME"
|
||||
);
|
||||
let recordId;
|
||||
if (existingCname) {
|
||||
if (!cnameContentMatches(existingCname.content, normalized, zoneName) || existingCname.name !== hostname) {
|
||||
await update(db, cf, domainId, existingCname.id, {
|
||||
record_type: "CNAME",
|
||||
name: hostname,
|
||||
content: normalized,
|
||||
proxied: false
|
||||
});
|
||||
}
|
||||
recordId = existingCname.id;
|
||||
} else {
|
||||
const adopted = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
normalized
|
||||
);
|
||||
if (adopted) {
|
||||
repos6.linkBindingRecord(db, bindingId, adopted.id);
|
||||
if (!cnameContentMatches(adopted.content, normalized, zoneName)) {
|
||||
await update(db, cf, domainId, adopted.id, {
|
||||
record_type: "CNAME",
|
||||
name: hostname,
|
||||
content: normalized,
|
||||
proxied: false
|
||||
});
|
||||
}
|
||||
recordId = adopted.id;
|
||||
} else {
|
||||
const record = await create(db, cf, domainId, {
|
||||
record_type: "CNAME",
|
||||
name: hostname,
|
||||
content: normalized,
|
||||
ttl: 1,
|
||||
proxied: false
|
||||
});
|
||||
repos6.linkBindingRecord(db, bindingId, record.id);
|
||||
recordId = record.id;
|
||||
}
|
||||
}
|
||||
repos6.setBindingDnsRecordId(db, bindingId, recordId);
|
||||
repos6.setBindingCnameTarget(db, bindingId, normalized);
|
||||
}
|
||||
async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) {
|
||||
const domain = repos6.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
const existingRecords = repos6.listRecordsForBinding(db, bindingId);
|
||||
for (const record of existingRecords) {
|
||||
if (record.record_type.toUpperCase() === "CNAME") {
|
||||
repos6.unlinkBindingRecord(db, bindingId, record.id);
|
||||
await deleteRecord(db, cf, domainId, record.id);
|
||||
} else if (!desiredIps.includes(record.content)) {
|
||||
repos6.unlinkBindingRecord(db, bindingId, record.id);
|
||||
await deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
}
|
||||
repos6.setBindingCnameTarget(db, bindingId, null);
|
||||
if (desiredIps.length === 0) {
|
||||
repos6.setBindingDnsRecordId(db, bindingId, null);
|
||||
return;
|
||||
@@ -1040,22 +1148,36 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps)
|
||||
}
|
||||
recordId = existing.id;
|
||||
} else {
|
||||
const record = await create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
proxied: false
|
||||
});
|
||||
repos6.linkBindingRecord(db, bindingId, record.id);
|
||||
recordId = record.id;
|
||||
const adopted = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"A",
|
||||
ip
|
||||
);
|
||||
if (adopted) {
|
||||
repos6.linkBindingRecord(db, bindingId, adopted.id);
|
||||
recordId = adopted.id;
|
||||
} else {
|
||||
const record = await create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
proxied: false
|
||||
});
|
||||
repos6.linkBindingRecord(db, bindingId, record.id);
|
||||
recordId = record.id;
|
||||
}
|
||||
}
|
||||
if (primaryId == null) primaryId = recordId;
|
||||
}
|
||||
repos6.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||
}
|
||||
async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) {
|
||||
await syncBindingDns(db, cf, bindingId, domainId, hostname, []);
|
||||
await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);
|
||||
}
|
||||
async function cleanupServiceDnsOnly(db, cf, serviceId) {
|
||||
const bindings = repos6.listBindingsByService(db, serviceId);
|
||||
@@ -1080,6 +1202,7 @@ function validateTargetIpsInPool(targetIps, ips) {
|
||||
}
|
||||
}
|
||||
function bindingTargetIps(input) {
|
||||
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) {
|
||||
@@ -1087,20 +1210,150 @@ function bindingTargetIps(input) {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
async function syncServiceBindingsToDns(db, cf, serviceId) {
|
||||
const ips = repos6.listServiceIps(db, serviceId);
|
||||
if (ips.length === 0) {
|
||||
throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
|
||||
function bindingTargetCname(input) {
|
||||
const target = input.target_cname?.trim();
|
||||
return target ? target : null;
|
||||
}
|
||||
function normalizeCnameTarget(target, zoneName) {
|
||||
const trimmed = target.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C");
|
||||
}
|
||||
if (trimmed.includes(".")) return trimmed;
|
||||
return `${trimmed}.${zoneName.toLowerCase()}`;
|
||||
}
|
||||
function cnameContentMatches(left, right, zoneName) {
|
||||
return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName);
|
||||
}
|
||||
function dnsHostnameMatches(recordName, hostname, zoneName) {
|
||||
const label = dnsNameToSubdomainLabel2(recordName, zoneName);
|
||||
if (label != null) return label === hostname;
|
||||
return recordName === hostname;
|
||||
}
|
||||
function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) {
|
||||
const records = repos6.listDnsByDomain(db, domainId);
|
||||
return records.find(
|
||||
(record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsHostnameMatches(record.name, hostname, zoneName)
|
||||
) ?? null;
|
||||
}
|
||||
async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content) {
|
||||
const local = findLocalDnsRecord(
|
||||
db,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
recordType,
|
||||
content
|
||||
);
|
||||
if (local) return local;
|
||||
const domain = repos6.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 (!dnsHostnameMatches(cfRec.name, hostname, zoneName)) continue;
|
||||
if (!cfRec.id) continue;
|
||||
const existing = repos6.findDnsByCfId(db, domainId, cfRec.id);
|
||||
if (existing) return existing;
|
||||
return repos6.insertDnsRecord(
|
||||
db,
|
||||
domainId,
|
||||
cfRec.type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED3,
|
||||
"cloudflare",
|
||||
cfRec.id
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content) {
|
||||
return findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"A",
|
||||
content
|
||||
);
|
||||
}
|
||||
async function serviceBindingsExistInDns(db, cf, serviceId) {
|
||||
const bindings = repos6.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 = repos6.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, cf, serviceId) {
|
||||
const bindings = repos6.listBindingsByService(db, serviceId);
|
||||
if (bindings.length === 0) {
|
||||
throw AppError.validation("\u043D\u0430\u0441\u0442\u0440\u043E\u0439\u0442\u0435 FQDN \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
|
||||
}
|
||||
const needsIpPool = bindings.some((binding) => {
|
||||
if (binding.cname_target?.trim()) return false;
|
||||
const targetIps = repos6.listBindingIps(db, binding.id);
|
||||
return targetIps.length > 0;
|
||||
});
|
||||
const ips = repos6.listServiceIps(db, serviceId);
|
||||
if (needsIpPool && ips.length === 0) {
|
||||
throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
|
||||
}
|
||||
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 = repos6.listBindingIps(db, binding.id);
|
||||
if (targetIps.length === 0) {
|
||||
throw AppError.validation(
|
||||
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}`
|
||||
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}`
|
||||
);
|
||||
}
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
@@ -1110,7 +1363,8 @@ async function syncServiceBindingsToDns(db, cf, serviceId) {
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
targetIps
|
||||
targetIps,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1406,19 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const domain = repos6.getDomain(db, domainId);
|
||||
const adopted = await findOrImportDnsARecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
domain.zone_name,
|
||||
hostname,
|
||||
ip
|
||||
);
|
||||
if (adopted) {
|
||||
repos6.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||
continue;
|
||||
}
|
||||
const record = await create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: hostname,
|
||||
@@ -1273,13 +1540,21 @@ async function updateConfig(db, cf, id, req) {
|
||||
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(
|
||||
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}`
|
||||
);
|
||||
}
|
||||
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
const binding = repos6.findBinding(db, id, domainId, hostname) ?? repos6.insertBinding(db, domainId, id, hostname, null);
|
||||
keptBindingIds.push(binding.id);
|
||||
repos6.replaceBindingIps(db, binding.id, targetIps);
|
||||
repos6.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps);
|
||||
repos6.setBindingCnameTarget(db, binding.id, targetCname);
|
||||
if (pushDns) {
|
||||
await syncBindingDns(
|
||||
db,
|
||||
@@ -1287,7 +1562,8 @@ async function updateConfig(db, cf, id, req) {
|
||||
binding.id,
|
||||
domainId,
|
||||
hostname,
|
||||
targetIps
|
||||
targetIps,
|
||||
targetCname
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1320,6 +1596,10 @@ async function updateConfig(db, cf, id, req) {
|
||||
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)) {
|
||||
repos6.setServiceEnabled(db, id, true);
|
||||
await syncServiceBindingsToDns(db, cf, id);
|
||||
await syncGroupDomainForService(db, cf, id);
|
||||
}
|
||||
return buildView(db, id);
|
||||
}
|
||||
@@ -1343,7 +1623,7 @@ async function updateGroup2(db, cf, id, body) {
|
||||
await cleanupGroupDomainDns(db, cf, id);
|
||||
}
|
||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||
const group = repos6.updateServiceGroup(
|
||||
let group = repos6.updateServiceGroup(
|
||||
db,
|
||||
id,
|
||||
body.name,
|
||||
@@ -1351,6 +1631,10 @@ async function updateGroup2(db, cf, id, body) {
|
||||
body.icon ?? null,
|
||||
domain
|
||||
);
|
||||
if (!domain && group.enabled) {
|
||||
repos6.setServiceGroupEnabled(db, id, false);
|
||||
group = repos6.getServiceGroup(db, id);
|
||||
}
|
||||
await syncEnabledServicesInGroup(db, cf, id);
|
||||
return group;
|
||||
}
|
||||
@@ -1361,12 +1645,9 @@ async function toggleService(db, cf, serviceId, enabled) {
|
||||
const service = repos6.getService(db, serviceId);
|
||||
if (enabled && service.service_group_id) {
|
||||
const group = repos6.getServiceGroup(db, service.service_group_id);
|
||||
if (!group.enabled) {
|
||||
if (group.domain?.trim() && !group.enabled) {
|
||||
throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432");
|
||||
}
|
||||
if (!group.domain?.trim()) {
|
||||
throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0434\u043E\u043C\u0435\u043D \u0443 \u0433\u0440\u0443\u043F\u043F\u044B \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432");
|
||||
}
|
||||
}
|
||||
repos6.setServiceEnabled(db, serviceId, enabled);
|
||||
if (!enabled) {
|
||||
@@ -1379,6 +1660,10 @@ async function toggleService(db, cf, serviceId, enabled) {
|
||||
return buildView(db, serviceId);
|
||||
}
|
||||
async function toggleGroup(db, cf, groupId, enabled) {
|
||||
const group = repos6.getServiceGroup(db, groupId);
|
||||
if (enabled && !group.domain?.trim()) {
|
||||
throw AppError.validation("\u043D\u0435\u043B\u044C\u0437\u044F \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u0443 \u0431\u0435\u0437 \u0434\u043E\u043C\u0435\u043D\u0430");
|
||||
}
|
||||
repos6.setServiceGroupEnabled(db, groupId, enabled);
|
||||
if (!enabled) {
|
||||
const services = repos6.listServicesByGroup(db, groupId);
|
||||
@@ -1394,6 +1679,12 @@ async function toggleGroup(db, cf, groupId, enabled) {
|
||||
}
|
||||
return listGroupViews(db);
|
||||
}
|
||||
function reorderServices(db, groupId, serviceIds) {
|
||||
if (groupId !== null) {
|
||||
repos6.getServiceGroup(db, groupId);
|
||||
}
|
||||
repos6.reorderServices(db, groupId, serviceIds);
|
||||
}
|
||||
|
||||
// src/routes/services.ts
|
||||
async function serviceRoutes(app2) {
|
||||
@@ -1405,6 +1696,15 @@ async function serviceRoutes(app2) {
|
||||
app2.get("/services", async (request) => {
|
||||
return listViews(request.server.db);
|
||||
});
|
||||
app2.patch("/services/reorder", async (request) => {
|
||||
const body = reorderServicesSchema.parse(request.body);
|
||||
reorderServices(
|
||||
request.server.db,
|
||||
body.group_id,
|
||||
body.service_ids
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
app2.post("/services", async (request) => {
|
||||
const body = createSchema.parse(request.body);
|
||||
const service = repos7.createService(
|
||||
@@ -1452,14 +1752,9 @@ async function serviceRoutes(app2) {
|
||||
}
|
||||
|
||||
// src/routes/service-groups.ts
|
||||
import { z as z4 } from "zod";
|
||||
import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared";
|
||||
async function serviceGroupRoutes(app2) {
|
||||
const bodySchema = z4.object({
|
||||
name: z4.string(),
|
||||
type: z4.string().optional(),
|
||||
icon: z4.string().optional(),
|
||||
domain: z4.string().optional()
|
||||
});
|
||||
const bodySchema = createServiceGroupSchema;
|
||||
app2.get("/service-groups", async (request) => {
|
||||
return listGroupViews(request.server.db);
|
||||
});
|
||||
@@ -1488,7 +1783,7 @@ async function serviceGroupRoutes(app2) {
|
||||
});
|
||||
app2.patch("/service-groups/:id/toggle", async (request) => {
|
||||
const { id } = request.params;
|
||||
const body = z4.object({ enabled: z4.boolean() }).parse(request.body);
|
||||
const body = toggleEnabledSchema.parse(request.body);
|
||||
return toggleGroup(
|
||||
request.server.db,
|
||||
request.server.cf,
|
||||
@@ -1499,18 +1794,18 @@ async function serviceGroupRoutes(app2) {
|
||||
}
|
||||
|
||||
// src/routes/service-bindings.ts
|
||||
import { z as z5 } from "zod";
|
||||
import { z as z4 } from "zod";
|
||||
async function serviceBindingRoutes(app2) {
|
||||
const createSchema = z5.object({
|
||||
domain_id: z5.number(),
|
||||
service_id: z5.number(),
|
||||
hostname: z5.string().optional(),
|
||||
target_ip: z5.string().optional()
|
||||
const createSchema = z4.object({
|
||||
domain_id: z4.number(),
|
||||
service_id: z4.number(),
|
||||
hostname: z4.string().optional(),
|
||||
target_ip: z4.string().optional()
|
||||
});
|
||||
const updateSchema = z5.object({
|
||||
service_id: z5.number().optional(),
|
||||
hostname: z5.string().optional(),
|
||||
target_ip: z5.string().optional()
|
||||
const updateSchema = z4.object({
|
||||
service_id: z4.number().optional(),
|
||||
hostname: z4.string().optional(),
|
||||
target_ip: z4.string().optional()
|
||||
});
|
||||
app2.get("/service-bindings", async (request) => {
|
||||
return listAll(request.server.db);
|
||||
@@ -1550,15 +1845,15 @@ async function serviceBindingRoutes(app2) {
|
||||
}
|
||||
|
||||
// src/routes/domains.ts
|
||||
import { z as z6 } from "zod";
|
||||
import { z as z5 } from "zod";
|
||||
async function domainRoutes(app2) {
|
||||
const createSchema = z6.object({
|
||||
zone_name: z6.string(),
|
||||
group_id: z6.number().nullable().optional()
|
||||
const createSchema = z5.object({
|
||||
zone_name: z5.string(),
|
||||
group_id: z5.number().nullable().optional()
|
||||
});
|
||||
const updateSchema = z6.object({
|
||||
group_id: z6.number().nullable().optional(),
|
||||
status: z6.string().optional()
|
||||
const updateSchema = z5.object({
|
||||
group_id: z5.number().nullable().optional(),
|
||||
status: z5.string().optional()
|
||||
});
|
||||
app2.get("/domains", async (request) => {
|
||||
const query = request.query;
|
||||
@@ -1605,7 +1900,7 @@ async function domainRoutes(app2) {
|
||||
});
|
||||
app2.put("/domains/:id/services", async (request) => {
|
||||
const { id } = request.params;
|
||||
const body = z6.object({ service_ids: z6.array(z6.number()) }).parse(request.body);
|
||||
const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request.body);
|
||||
const serviceIds = await setDomainServices2(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
@@ -1616,15 +1911,15 @@ async function domainRoutes(app2) {
|
||||
}
|
||||
|
||||
// src/routes/dns.ts
|
||||
import { z as z7 } from "zod";
|
||||
import { z as z6 } from "zod";
|
||||
async function dnsRoutes(app2) {
|
||||
const createSchema = z7.object({
|
||||
record_type: z7.string(),
|
||||
name: z7.string(),
|
||||
content: z7.string(),
|
||||
ttl: z7.number().optional(),
|
||||
proxied: z7.boolean().optional(),
|
||||
priority: z7.number().optional()
|
||||
const createSchema = z6.object({
|
||||
record_type: z6.string(),
|
||||
name: z6.string(),
|
||||
content: z6.string(),
|
||||
ttl: z6.number().optional(),
|
||||
proxied: z6.boolean().optional(),
|
||||
priority: z6.number().optional()
|
||||
});
|
||||
app2.get("/domains/:id/dns", async (request) => {
|
||||
const { id } = request.params;
|
||||
@@ -1653,7 +1948,7 @@ async function dnsRoutes(app2) {
|
||||
});
|
||||
app2.post("/domains/:id/dns/bulk", async (request) => {
|
||||
const { id } = request.params;
|
||||
const body = z7.object({ operations: z7.array(z7.record(z7.unknown())) }).parse(request.body);
|
||||
const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request.body);
|
||||
return bulk(
|
||||
request.server.db,
|
||||
request.server.cf,
|
||||
@@ -1691,7 +1986,7 @@ async function dnsRoutes(app2) {
|
||||
});
|
||||
app2.post("/domains/:id/dns/:recordId/resolve", async (request) => {
|
||||
const { id, recordId } = request.params;
|
||||
const body = z7.object({ source: z7.string() }).parse(request.body);
|
||||
const body = z6.object({ source: z6.string() }).parse(request.body);
|
||||
return resolveConflict(
|
||||
request.server.db,
|
||||
request.server.cf,
|
||||
@@ -1703,8 +1998,9 @@ async function dnsRoutes(app2) {
|
||||
}
|
||||
|
||||
// src/routes/subdomains.ts
|
||||
import { z as z8 } from "zod";
|
||||
import { updateSubdomainSchema } from "@cfdm/shared";
|
||||
import { repos as repos8 } from "@cfdm/db";
|
||||
import { z as z7 } from "zod";
|
||||
async function subdomainRoutes(app2) {
|
||||
app2.get("/domains/:id/subdomains", async (request) => {
|
||||
const { id } = request.params;
|
||||
@@ -1713,7 +2009,7 @@ async function subdomainRoutes(app2) {
|
||||
});
|
||||
app2.post("/domains/:id/subdomains", async (request) => {
|
||||
const { id } = request.params;
|
||||
const body = z8.object({ name: z8.string() }).parse(request.body);
|
||||
const body = z7.object({ name: z7.string() }).parse(request.body);
|
||||
const domain = repos8.getDomain(request.server.db, Number(id));
|
||||
const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
|
||||
return repos8.createSubdomain(
|
||||
@@ -1729,16 +2025,18 @@ async function subdomainRoutes(app2) {
|
||||
});
|
||||
app2.patch("/subdomains/:id", async (request) => {
|
||||
const { id } = request.params;
|
||||
const body = z8.object({ name: z8.string() }).parse(request.body);
|
||||
const body = updateSubdomainSchema.parse(request.body);
|
||||
const sub = repos8.getSubdomain(request.server.db, Number(id));
|
||||
const domain = repos8.getDomain(request.server.db, sub.domain_id);
|
||||
const fqdn = `${body.name}.${domain.zone_name}`;
|
||||
return repos8.updateSubdomain(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
body.name,
|
||||
fqdn
|
||||
);
|
||||
const patch = {};
|
||||
if (body.name !== void 0) {
|
||||
patch.name = body.name;
|
||||
patch.fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
|
||||
}
|
||||
if (body.enabled !== void 0) {
|
||||
patch.enabled = body.enabled;
|
||||
}
|
||||
return repos8.updateSubdomain(request.server.db, Number(id), patch);
|
||||
});
|
||||
app2.delete("/subdomains/:id", async (request) => {
|
||||
const { id } = request.params;
|
||||
@@ -1896,6 +2194,7 @@ async function syncRoutes(app2) {
|
||||
}
|
||||
|
||||
// src/app.ts
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
async function buildApp(opts = {}) {
|
||||
const config2 = opts.config ?? loadConfig();
|
||||
const app2 = Fastify({
|
||||
@@ -1943,20 +2242,23 @@ async function buildApp(opts = {}) {
|
||||
}
|
||||
if (!opts.memory) {
|
||||
await app2.register(import("@fastify/schedule"));
|
||||
app2.scheduler.addCronJob(
|
||||
{
|
||||
cronExpression: config2.certCheckCron,
|
||||
name: "certificate-check"
|
||||
},
|
||||
const certTask = new AsyncTask(
|
||||
"certificate-check",
|
||||
async () => {
|
||||
try {
|
||||
const n = await runAllChecks(app2.db);
|
||||
app2.log.info({ checked: n }, "certificate check completed");
|
||||
} catch (err) {
|
||||
app2.log.warn({ err }, "certificate check failed");
|
||||
}
|
||||
const n = await runAllChecks(app2.db);
|
||||
app2.log.info({ checked: n }, "certificate check completed");
|
||||
},
|
||||
(err) => {
|
||||
app2.log.warn({ err }, "certificate check failed");
|
||||
}
|
||||
);
|
||||
app2.scheduler.addCronJob(
|
||||
new CronJob(
|
||||
{ cronExpression: config2.certCheckCron },
|
||||
certTask,
|
||||
{ preventOverrun: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
return app2;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { repos } from "@cfdm/db";
|
||||
import {
|
||||
CERT_ERROR,
|
||||
CERT_MONITOR_REQUIRED,
|
||||
CERT_MONITOR_SKIPPED,
|
||||
} from "@cfdm/shared";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import * as certificateService from "../src/services/certificate-service.js";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const config = loadConfig();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: config.adminUsername, password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
describe("certificates", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("auto mode does not monitor DNS-only domain", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"dns-only.example.com",
|
||||
"cf-zone-dns",
|
||||
);
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: null,
|
||||
error: "connection refused",
|
||||
});
|
||||
|
||||
const checkRes = await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/certificates/check",
|
||||
headers,
|
||||
});
|
||||
expect(checkRes.statusCode).toBe(200);
|
||||
|
||||
const certs = repos.listCertificates(testApp.db);
|
||||
expect(certs.find((c) => c.hostname === domain.zone_name)).toBeUndefined();
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("monitors host with enabled service binding", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"app.example.com",
|
||||
"cf-zone-app",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Web", "web");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.insertBinding(testApp.db, domain.id, service.id, "api", null);
|
||||
|
||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/certificates/check",
|
||||
headers,
|
||||
});
|
||||
|
||||
const certs = repos.listCertificates(testApp.db);
|
||||
expect(certs.some((c) => c.hostname === "api.app.example.com")).toBe(true);
|
||||
expect(certs.some((c) => c.hostname === "app.example.com")).toBe(false);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("does not monitor host when service is disabled", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"off.example.com",
|
||||
"cf-zone-off",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Off", "off");
|
||||
repos.insertBinding(testApp.db, domain.id, service.id, "@", null);
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
error: null,
|
||||
});
|
||||
|
||||
await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/certificates/check",
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
repos.listCertificates(testApp.db).some((c) => c.hostname === domain.zone_name),
|
||||
).toBe(false);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("required apex is monitored without bindings", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"required.example.com",
|
||||
"cf-zone-req",
|
||||
);
|
||||
repos.updateDomain(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
"active",
|
||||
CERT_MONITOR_REQUIRED,
|
||||
);
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
error: null,
|
||||
});
|
||||
|
||||
await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/certificates/check",
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
repos.listCertificates(testApp.db).some(
|
||||
(c) => c.hostname === domain.zone_name,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("skipped apex removes stale certificate on check", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"skipped.example.com",
|
||||
"cf-zone-skip",
|
||||
);
|
||||
repos.upsertCertificateCheck(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
domain.zone_name,
|
||||
null,
|
||||
CERT_ERROR,
|
||||
"stale",
|
||||
);
|
||||
repos.updateDomain(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
"active",
|
||||
CERT_MONITOR_SKIPPED,
|
||||
);
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: null,
|
||||
error: "should not be called",
|
||||
});
|
||||
|
||||
await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/certificates/check",
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(repos.listCertificates(testApp.db)).toHaveLength(0);
|
||||
expect(certificateService.checkHostname).not.toHaveBeenCalled();
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("TLS failure on monitored host is stored as error", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"broken.example.com",
|
||||
"cf-zone-broken",
|
||||
);
|
||||
repos.updateDomain(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
"active",
|
||||
CERT_MONITOR_REQUIRED,
|
||||
);
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: null,
|
||||
error: "certificate has expired",
|
||||
});
|
||||
|
||||
await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/certificates/check",
|
||||
headers,
|
||||
});
|
||||
|
||||
const cert = repos.listCertificates(testApp.db)[0];
|
||||
expect(cert?.status).toBe(CERT_ERROR);
|
||||
expect(cert?.last_error).toBeTruthy();
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { SYNC_CONFLICT, SYNC_ERROR, SYNC_SYNCED } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { pullSync } from "../src/services/sync-service.js";
|
||||
|
||||
const ZONE = "rkns.top";
|
||||
const CF_ZONE_ID = "zone-rkns";
|
||||
|
||||
function mockCf(remote: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}>): CloudflareClient {
|
||||
return {
|
||||
listDnsRecords: async () =>
|
||||
remote.map((record) => ({
|
||||
...record,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
})),
|
||||
} as unknown as CloudflareClient;
|
||||
}
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("pullSync", () => {
|
||||
it("reconciles short local names with Cloudflare FQDNs", async () => {
|
||||
const db = setupDb();
|
||||
const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID);
|
||||
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"de",
|
||||
"193.233.134.103",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_CONFLICT,
|
||||
"local",
|
||||
"cf-de",
|
||||
);
|
||||
|
||||
const cf = mockCf([
|
||||
{
|
||||
id: "cf-de",
|
||||
type: "A",
|
||||
name: "de.rkns.top",
|
||||
content: "193.233.134.103",
|
||||
},
|
||||
]);
|
||||
|
||||
const changes = await pullSync(db, cf, domain);
|
||||
expect(changes).toBeGreaterThan(0);
|
||||
|
||||
const records = repos.listDnsByDomain(db, domain.id);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].name).toBe("de.rkns.top");
|
||||
expect(records[0].sync_status).toBe(SYNC_SYNCED);
|
||||
});
|
||||
|
||||
it("marks local record as conflict when Cloudflare has another type", async () => {
|
||||
const db = setupDb();
|
||||
const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID);
|
||||
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"mhome",
|
||||
"185.244.181.61",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_ERROR,
|
||||
"local",
|
||||
null,
|
||||
);
|
||||
|
||||
const cf = mockCf([
|
||||
{
|
||||
id: "cf-mhome",
|
||||
type: "CNAME",
|
||||
name: "mhome.rkns.top",
|
||||
content: "mmsk.rkns.top",
|
||||
},
|
||||
]);
|
||||
|
||||
await pullSync(db, cf, domain);
|
||||
|
||||
const records = repos.listDnsByDomain(db, domain.id);
|
||||
const localA = records.find((r) => r.record_type === "A");
|
||||
expect(localA?.sync_status).toBe(SYNC_CONFLICT);
|
||||
expect(localA?.last_error).toBe("type mismatch with cloudflare");
|
||||
|
||||
const localCname = records.find((r) => r.record_type === "CNAME");
|
||||
expect(localCname?.sync_status).toBe(SYNC_SYNCED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repos } from "@cfdm/db";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const config = loadConfig();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: config.adminUsername, password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
describe("services reorder", () => {
|
||||
it("PATCH /services/reorder persists order within group and ungrouped", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
|
||||
const group = repos.createServiceGroup(app.db, "VPN", "vpn", null, null);
|
||||
const alpha = repos.createService(app.db, "Alpha", "alpha");
|
||||
const beta = repos.createService(app.db, "Beta", "beta");
|
||||
const gamma = repos.createService(app.db, "Gamma", "gamma");
|
||||
|
||||
repos.setServiceGroup(app.db, alpha.id, group.id);
|
||||
repos.setServiceGroup(app.db, beta.id, group.id);
|
||||
|
||||
const reorderRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/v1/services/reorder",
|
||||
headers,
|
||||
payload: {
|
||||
group_id: group.id,
|
||||
service_ids: [beta.id, alpha.id],
|
||||
},
|
||||
});
|
||||
expect(reorderRes.statusCode).toBe(200);
|
||||
|
||||
const grouped = repos.listServicesByGroup(app.db, group.id);
|
||||
expect(grouped.map((s) => s.id)).toEqual([beta.id, alpha.id]);
|
||||
|
||||
const ungroupedReorderRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/v1/services/reorder",
|
||||
headers,
|
||||
payload: {
|
||||
group_id: null,
|
||||
service_ids: [gamma.id],
|
||||
},
|
||||
});
|
||||
expect(ungroupedReorderRes.statusCode).toBe(200);
|
||||
|
||||
const ungrouped = repos.listUngroupedServices(app.db);
|
||||
expect(ungrouped[0]?.id).toBe(gamma.id);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repos } from "@cfdm/db";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const config = loadConfig();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: config.adminUsername, password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
describe("subdomains", () => {
|
||||
it("PATCH /subdomains/:id toggles enabled and renames", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
app.db,
|
||||
null,
|
||||
"example.com",
|
||||
"cf-zone-1",
|
||||
);
|
||||
const created = repos.createSubdomain(
|
||||
app.db,
|
||||
domain.id,
|
||||
"www",
|
||||
"www.example.com",
|
||||
);
|
||||
expect(created.enabled).toBe(true);
|
||||
|
||||
const disableRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/subdomains/${created.id}`,
|
||||
headers,
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(disableRes.statusCode).toBe(200);
|
||||
expect(disableRes.json().enabled).toBe(false);
|
||||
|
||||
const renameRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/subdomains/${created.id}`,
|
||||
headers,
|
||||
payload: { name: "api" },
|
||||
});
|
||||
expect(renameRes.statusCode).toBe(200);
|
||||
const renamed = renameRes.json();
|
||||
expect(renamed.name).toBe("api");
|
||||
expect(renamed.fqdn).toBe("api.example.com");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user