Биндинги с выключенным health-check (imsk/mmsk) исключаются из авто-мониторинга; sticky footer в редактировании сервиса. Co-authored-by: Cursor <cursoragent@cursor.com>
294 lines
7.5 KiB
TypeScript
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);
|
|
}
|