diff --git a/apps/api/src/services/certificate-service.ts b/apps/api/src/services/certificate-service.ts index b0dd669..9970201 100644 --- a/apps/api/src/services/certificate-service.ts +++ b/apps/api/src/services/certificate-service.ts @@ -21,10 +21,19 @@ export interface CertificateTarget { 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); } @@ -133,6 +142,13 @@ function bindingSubdomain( return repos.findSubdomainByDomainAndName(db, domainId, hostname); } +/** Health-check without TLS verify implies invalid certs — skip SSL monitoring. */ +function skipsSslDueToHealthTls( + group: { health_check_enabled: boolean; health_check_verify_tls: boolean } | null, +): boolean { + return Boolean(group?.health_check_enabled && !group.health_check_verify_tls); +} + export function buildServiceCertificateFqdns( db: Db, ): Map { @@ -144,6 +160,7 @@ export function buildServiceCertificateFqdns( ? repos.getServiceGroup(db, service.service_group_id) : null; if (!shouldMonitorService(service, group)) continue; + if (skipsSslDueToHealthTls(group)) continue; const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname); if (subdomain && !subdomain.enabled) continue; @@ -159,6 +176,7 @@ export function buildServiceCertificateFqdns( 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 (skipsSslDueToHealthTls(group)) continue; const parsed = parseFqdn(group.domain, knownZones); if (!parsed) continue; @@ -244,5 +262,6 @@ export async function runAllChecks(db: Db): Promise { } export function statusSummary(db: Db): Array<[string, number]> { + pruneStaleCertificates(db); return repos.countCertificatesByStatus(db); } diff --git a/apps/api/test/certificates.test.ts b/apps/api/test/certificates.test.ts index 96ff8e8..2239673 100644 --- a/apps/api/test/certificates.test.ts +++ b/apps/api/test/certificates.test.ts @@ -248,4 +248,216 @@ describe("certificates", () => { await testApp.close(); }); + + it("skips SSL monitoring when group health is on without TLS verify", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "rkns.example.com", + "cf-zone-rkns", + ); + const group = repos.createServiceGroup( + testApp.db, + "TG Proxy", + "vpn", + null, + "gt.rkns.example.com", + { + health_check_enabled: true, + health_check_type: "http", + health_check_port: 443, + health_check_verify_tls: false, + }, + ); + const service = repos.createService(testApp.db, "Node", "node"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.setServiceGroup(testApp.db, service.id, group.id); + repos.insertBinding(testApp.db, domain.id, service.id, "rutg", null); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + error: null, + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + const certs = repos.listCertificates(testApp.db); + expect(certs.some((c) => c.hostname === "gt.rkns.example.com")).toBe(false); + expect(certs.some((c) => c.hostname === "rutg.rkns.example.com")).toBe( + false, + ); + expect(certificateService.checkHostname).not.toHaveBeenCalled(); + + await testApp.close(); + }); + + it("monitors group hosts when health TLS verify is on", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "ok.example.com", + "cf-zone-ok", + ); + const group = repos.createServiceGroup( + testApp.db, + "LB", + "vpn", + null, + "lb.ok.example.com", + { + health_check_enabled: true, + health_check_type: "http", + health_check_port: 443, + health_check_verify_tls: true, + }, + ); + const service = repos.createService(testApp.db, "Edge", "edge"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.setServiceGroup(testApp.db, service.id, group.id); + repos.insertBinding(testApp.db, domain.id, service.id, "edge", null); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + error: null, + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + const certs = repos.listCertificates(testApp.db); + expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true); + expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true); + + await testApp.close(); + }); + + it("required mode still monitors when group skips TLS verify", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "force.example.com", + "cf-zone-force", + ); + repos.updateDomain(testApp.db, domain.id, { + group_id: null, + status: "active", + cert_monitoring: CERT_MONITOR_REQUIRED, + }); + repos.createServiceGroup( + testApp.db, + "Proxy", + "vpn", + null, + "force.example.com", + { + health_check_enabled: true, + health_check_verify_tls: false, + }, + ); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + error: null, + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + expect( + repos.listCertificates(testApp.db).some( + (c) => c.hostname === domain.zone_name, + ), + ).toBe(true); + + await testApp.close(); + }); + + it("GET /certificates prunes stale rows without running check", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "stale-list.example.com", + "cf-zone-stale-list", + ); + const group = repos.createServiceGroup( + testApp.db, + "Stale", + "vpn", + null, + "gt.stale-list.example.com", + { + health_check_enabled: true, + health_check_verify_tls: false, + }, + ); + const service = repos.createService(testApp.db, "S", "s"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.setServiceGroup(testApp.db, service.id, group.id); + repos.insertBinding(testApp.db, domain.id, service.id, "rutg", null); + + repos.upsertCertificateCheck( + testApp.db, + domain.id, + null, + "gt.stale-list.example.com", + null, + CERT_ERROR, + "stale group domain", + ); + repos.upsertCertificateCheck( + testApp.db, + domain.id, + null, + "rutg.stale-list.example.com", + null, + CERT_ERROR, + "stale binding", + ); + + expect(repos.listCertificates(testApp.db)).toHaveLength(2); + + const listRes = await testApp.inject({ + method: "GET", + url: "/api/v1/certificates", + headers, + }); + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toEqual([]); + + await testApp.close(); + }); }); diff --git a/apps/web/src/components/form-sheet.tsx b/apps/web/src/components/form-sheet.tsx index b9687ca..b1da841 100644 --- a/apps/web/src/components/form-sheet.tsx +++ b/apps/web/src/components/form-sheet.tsx @@ -40,18 +40,29 @@ export function FormSheet({ return ( - - + + {title} {description && {description}}
- {children} - {footer && {footer}} +
+ {children} +
+ {footer ? ( + {footer} + ) : null}
diff --git a/apps/web/src/components/health-check-config-fields.tsx b/apps/web/src/components/health-check-config-fields.tsx index f202cae..5e31047 100644 --- a/apps/web/src/components/health-check-config-fields.tsx +++ b/apps/web/src/components/health-check-config-fields.tsx @@ -224,7 +224,7 @@ export function HealthCheckConfigFields({