quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
CD / publish (push) Successful in 1m35s
- Added new endpoints for listing and checking service certificates, improving visibility into SSL status. - Integrated certificate monitoring options into service binding updates, allowing for flexible SSL management. - Updated the service detail grid to include SSL monitoring controls, enhancing user interaction with certificate settings. - Refactored related components and schemas to support the new certificate features, ensuring consistency across the application. - Improved test coverage for certificate functionalities, validating the new features and ensuring reliability.
274 lines
6.9 KiB
TypeScript
274 lines
6.9 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, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
|
import {
|
|
CERT_ERROR,
|
|
CERT_MONITOR_AUTO,
|
|
CERT_MONITOR_REQUIRED,
|
|
CERT_MONITOR_SKIPPED,
|
|
CERT_UNKNOWN,
|
|
certStatusFromExpiry,
|
|
fqdnToDisplay,
|
|
shouldMonitorService,
|
|
} from "@cfdm/shared";
|
|
|
|
export interface CertificateTarget {
|
|
domainId: number;
|
|
subdomainId: number | null;
|
|
serviceId: number;
|
|
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 function listServiceCertificates(
|
|
db: Db,
|
|
serviceId: number,
|
|
): ServiceCertificateRow[] {
|
|
repos.getService(db, serviceId);
|
|
const certsByHost = new Map(
|
|
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
|
|
);
|
|
return repos.listBindingsByService(db, serviceId).map((binding) => {
|
|
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
|
|
const cert = certsByHost.get(hostname);
|
|
return {
|
|
binding_id: binding.id,
|
|
domain_id: binding.domain_id,
|
|
service_id: binding.service_id,
|
|
hostname,
|
|
cert_monitoring:
|
|
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
|
|
"auto",
|
|
id: cert?.id ?? null,
|
|
status: cert?.status ?? "unknown",
|
|
expires_at: cert?.expires_at ?? null,
|
|
last_checked_at: cert?.last_checked_at ?? null,
|
|
last_error: cert?.last_error ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
serviceId: number | null = null,
|
|
): Promise<Certificate> {
|
|
const { expiresAt, error } = await checkHostname(hostname);
|
|
|
|
if (error) {
|
|
return repos.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
null,
|
|
CERT_ERROR,
|
|
error,
|
|
serviceId,
|
|
);
|
|
}
|
|
|
|
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,
|
|
serviceId,
|
|
);
|
|
}
|
|
|
|
return repos.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
null,
|
|
CERT_UNKNOWN,
|
|
"unknown expiry",
|
|
serviceId,
|
|
);
|
|
}
|
|
|
|
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 resolveCertificateTargets(db: Db): CertificateTarget[] {
|
|
const targets: CertificateTarget[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
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 mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
|
if (mode === CERT_MONITOR_SKIPPED) continue;
|
|
if (mode === CERT_MONITOR_AUTO) {
|
|
if (
|
|
!hasSslHealthGate(
|
|
{
|
|
health_check_enabled: binding.health_check_enabled,
|
|
health_check_verify_tls: binding.health_check_verify_tls,
|
|
},
|
|
group,
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
|
continue;
|
|
}
|
|
|
|
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
|
if (seen.has(fqdn)) continue;
|
|
seen.add(fqdn);
|
|
targets.push({
|
|
domainId: binding.domain_id,
|
|
subdomainId: subdomain?.id ?? null,
|
|
serviceId: binding.service_id,
|
|
hostname: fqdn,
|
|
});
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
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,
|
|
target.serviceId,
|
|
);
|
|
}
|
|
repos.deleteCertificatesNotIn(
|
|
db,
|
|
targets.map((t) => t.hostname),
|
|
);
|
|
return targets.length;
|
|
}
|
|
|
|
export async function runServiceChecks(
|
|
db: Db,
|
|
serviceId: number,
|
|
): Promise<number> {
|
|
repos.getService(db, serviceId);
|
|
const targets = resolveCertificateTargets(db).filter(
|
|
(target) => target.serviceId === serviceId,
|
|
);
|
|
for (const target of targets) {
|
|
await checkAndStore(
|
|
db,
|
|
target.domainId,
|
|
target.subdomainId,
|
|
target.hostname,
|
|
target.serviceId,
|
|
);
|
|
}
|
|
return targets.length;
|
|
}
|
|
|
|
export function statusSummary(db: Db): Array<[string, number]> {
|
|
pruneStaleCertificates(db);
|
|
return repos.countCertificatesByStatus(db);
|
|
}
|