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

- 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:
Denozordec
2026-08-20 00:32:43 +07:00
parent 69119a08a4
commit a458465153
20 changed files with 760 additions and 274 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { changeIpSchema } from "@cfdm/shared";
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
import * as bindingService from "../services/binding-service.js";
import * as changeIp from "../services/change-ip-service.js";
import { recordAudit } from "../lib/audit.js";
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
service_id: z.number().optional(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(),
});
app.get("/service-bindings", async (request) => {
+18
View File
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-service.js";
import * as certificateService from "../services/certificate-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
@@ -77,6 +78,23 @@ export async function serviceRoutes(app: FastifyInstance) {
};
});
app.get("/services/:id/certificates", async (request) => {
const { id } = request.params as { id: string };
return certificateService.listServiceCertificates(
request.server.db,
Number(id),
);
});
app.post("/services/:id/certificates/check", async (request) => {
const { id } = request.params as { id: string };
const checked = await certificateService.runServiceChecks(
request.server.db,
Number(id),
);
return { checked };
});
app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id));
+15
View File
@@ -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
+81 -101
View File
@@ -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),
};
});
+186 -20
View File
@@ -208,7 +208,7 @@ describe("certificates", () => {
await testApp.close();
});
it("required apex is monitored without bindings", async () => {
it("required binding is monitored without TLS health gate", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -221,10 +221,18 @@ describe("certificates", () => {
"required.example.com",
"cf-zone-req",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
const service = repos.createService(testApp.db, "Req", "req");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
health_check_enabled: false,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
@@ -247,7 +255,7 @@ describe("certificates", () => {
await testApp.close();
});
it("skipped apex removes stale certificate on check", async () => {
it("skipped binding removes stale certificate on check", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -260,6 +268,20 @@ describe("certificates", () => {
"skipped.example.com",
"cf-zone-skip",
);
const service = repos.createService(testApp.db, "Skip", "skip");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_SKIPPED,
health_check_enabled: true,
health_check_verify_tls: true,
});
repos.upsertCertificateCheck(
testApp.db,
domain.id,
@@ -268,12 +290,8 @@ describe("certificates", () => {
null,
CERT_ERROR,
"stale",
service.id,
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_SKIPPED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
@@ -305,9 +323,16 @@ describe("certificates", () => {
"broken.example.com",
"cf-zone-broken",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
const service = repos.createService(testApp.db, "Broken", "broken");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
@@ -424,7 +449,7 @@ describe("certificates", () => {
});
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false);
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
await testApp.close();
@@ -443,12 +468,7 @@ describe("certificates", () => {
"force.example.com",
"cf-zone-force",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.createServiceGroup(
const group = repos.createServiceGroup(
testApp.db,
"Proxy",
"vpn",
@@ -459,6 +479,19 @@ describe("certificates", () => {
health_check_verify_tls: false,
},
);
const service = repos.createService(testApp.db, "Force", "force");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.setServiceGroup(testApp.db, service.id, group.id);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
@@ -540,4 +573,137 @@ describe("certificates", () => {
await testApp.close();
});
it("GET /services/:id/certificates lists binding FQDNs", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"svc.example.com",
"cf-zone-svc",
);
const service = repos.createService(testApp.db, "Api", "api");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.insertBinding(testApp.db, domain.id, service.id, "www", null);
const res = await testApp.inject({
method: "GET",
url: `/api/v1/services/${service.id}/certificates`,
headers,
});
expect(res.statusCode).toBe(200);
const rows = res.json() as Array<{
hostname: string;
cert_monitoring: string;
status: string;
}>;
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("www.svc.example.com");
expect(rows[0]?.cert_monitoring).toBe("auto");
expect(rows[0]?.status).toBe("unknown");
await testApp.close();
});
it("PATCH /service-bindings/:id updates cert_monitoring", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"patch.example.com",
"cf-zone-patch",
);
const service = repos.createService(testApp.db, "Patch", "patch");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"api",
null,
);
const res = await testApp.inject({
method: "PATCH",
url: `/api/v1/service-bindings/${binding.id}`,
headers,
payload: { cert_monitoring: CERT_MONITOR_REQUIRED },
});
expect(res.statusCode).toBe(200);
expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe(
CERT_MONITOR_REQUIRED,
);
expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe(
CERT_MONITOR_REQUIRED,
);
await testApp.close();
});
it("POST /services/:id/certificates/check only checks that service", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"check.example.com",
"cf-zone-check",
);
const service = repos.createService(testApp.db, "One", "one");
const other = repos.createService(testApp.db, "Two", "two");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.setServiceEnabled(testApp.db, other.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"one",
null,
);
const otherBinding = repos.insertBinding(
testApp.db,
domain.id,
other.id,
"two",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.updateBindingLbConfig(testApp.db, otherBinding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
error: null,
});
const res = await testApp.inject({
method: "POST",
url: `/api/v1/services/${service.id}/certificates/check`,
headers,
});
expect(res.statusCode).toBe(200);
expect((res.json() as { checked: number }).checked).toBe(1);
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false);
await testApp.close();
});
});