feat(certificates): enhance service certificate management and monitoring
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
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.
This commit is contained in:
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
|
||||
service_id?: number;
|
||||
hostname?: string;
|
||||
target_ip?: string;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname?: string): string {
|
||||
@@ -92,6 +93,20 @@ export async function update(
|
||||
req: UpdateBindingRequest,
|
||||
): Promise<ServiceBindingView> {
|
||||
const existing = repos.getBinding(db, id);
|
||||
if (req.cert_monitoring !== undefined) {
|
||||
repos.updateBindingLbConfig(db, id, {
|
||||
cert_monitoring: req.cert_monitoring,
|
||||
});
|
||||
}
|
||||
|
||||
const hasIdentityPatch =
|
||||
req.service_id !== undefined ||
|
||||
req.hostname !== undefined ||
|
||||
req.target_ip !== undefined;
|
||||
if (!hasIdentityPatch) {
|
||||
return repos.getBindingView(db, id);
|
||||
}
|
||||
|
||||
const serviceId = req.service_id ?? existing.service_id;
|
||||
if (req.service_id) repos.getService(db, req.service_id);
|
||||
const hostname = req.hostname
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
||||
import {
|
||||
CERT_ERROR,
|
||||
CERT_MONITOR_AUTO,
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
CERT_UNKNOWN,
|
||||
certStatusFromExpiry,
|
||||
fqdnToDisplay,
|
||||
parseFqdn,
|
||||
shouldMonitorService,
|
||||
} from "@cfdm/shared";
|
||||
|
||||
export interface CertificateTarget {
|
||||
domainId: number;
|
||||
subdomainId: number | null;
|
||||
serviceId: number;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,34 @@ 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 }> {
|
||||
@@ -78,6 +106,7 @@ export async function checkAndStore(
|
||||
domainId: number,
|
||||
subdomainId: number | null,
|
||||
hostname: string,
|
||||
serviceId: number | null = null,
|
||||
): Promise<Certificate> {
|
||||
const { expiresAt, error } = await checkHostname(hostname);
|
||||
|
||||
@@ -90,6 +119,7 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_ERROR,
|
||||
error,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +135,7 @@ export async function checkAndStore(
|
||||
expiresAt.toISOString(),
|
||||
certStatusFromExpiry(days),
|
||||
null,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,23 +147,10 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_UNKNOWN,
|
||||
"unknown expiry",
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -165,10 +183,9 @@ function hasSslHealthGate(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildServiceCertificateFqdns(
|
||||
db: Db,
|
||||
): Map<string, CertificateTarget> {
|
||||
const result = new Map<string, CertificateTarget>();
|
||||
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);
|
||||
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
||||
? 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 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);
|
||||
result.set(fqdn, {
|
||||
if (seen.has(fqdn)) continue;
|
||||
seen.add(fqdn);
|
||||
targets.push({
|
||||
domainId: binding.domain_id,
|
||||
subdomainId: subdomain?.id ?? null,
|
||||
serviceId: binding.service_id,
|
||||
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()];
|
||||
return targets;
|
||||
}
|
||||
|
||||
export async function runAllChecks(db: Db): Promise<number> {
|
||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
repos.deleteCertificatesNotIn(
|
||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
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);
|
||||
|
||||
@@ -308,6 +308,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user