Files
cloudflare-domain-manager/apps/api/src/services/certificate-service.ts
T
DenozordecandCursor 278a6b64b4
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m59s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s
fix(certificates): не мониторить SSL без health-check с TLS verify
Биндинги с выключенным health-check (imsk/mmsk) исключаются из авто-мониторинга; sticky footer в редактировании сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 18:09:51 +07:00

294 lines
7.5 KiB
TypeScript

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, 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;
}
function pruneStaleCertificates(db: Db): void {
const targets = resolveCertificateTargets(db);
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
}
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
pruneStaleCertificates(db);
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",
);
}
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);
}
type HealthTlsFlags = {
health_check_enabled: boolean;
health_check_verify_tls: boolean;
};
/**
* Auto SSL monitoring follows effective health-check with TLS verify.
* No health → no SSL. Health without verify_tls (self-signed) → no SSL.
* Binding inherits group health when its own health is off.
*/
function hasSslHealthGate(
own: HealthTlsFlags,
group: (HealthTlsFlags & { enabled: boolean }) | null,
): boolean {
if (own.health_check_enabled) {
return own.health_check_verify_tls;
}
if (group?.enabled && group.health_check_enabled) {
return group.health_check_verify_tls;
}
return false;
}
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;
if (
!hasSslHealthGate(
{
health_check_enabled: binding.health_check_enabled,
health_check_verify_tls: binding.health_check_verify_tls,
},
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;
if (!group.health_check_enabled || !group.health_check_verify_tls) 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)) {
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)) {
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,
});
}
}
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]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
}