From 278a6b64b489ab002401d0ee461454f484c74f19 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 20 Jul 2026 18:09:51 +0700 Subject: [PATCH] =?UTF-8?q?fix(certificates):=20=D0=BD=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=82=D1=8C=20SSL=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20health-check=20=D1=81=20TLS=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Биндинги с выключенным health-check (imsk/mmsk) исключаются из авто-мониторинга; sticky footer в редактировании сервиса. Co-authored-by: Cursor --- apps/api/src/services/certificate-service.ts | 38 +++++++-- apps/api/test/certificates.test.ts | 82 ++++++++++++++++++- .../web/src/components/service-edit-sheet.tsx | 10 +-- apps/web/src/routes/_auth/certificates.tsx | 4 +- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/apps/api/src/services/certificate-service.ts b/apps/api/src/services/certificate-service.ts index 9970201..cf5b93b 100644 --- a/apps/api/src/services/certificate-service.ts +++ b/apps/api/src/services/certificate-service.ts @@ -142,11 +142,27 @@ 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, +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 { - return Boolean(group?.health_check_enabled && !group.health_check_verify_tls); + 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( @@ -160,7 +176,17 @@ export function buildServiceCertificateFqdns( ? repos.getServiceGroup(db, service.service_group_id) : null; if (!shouldMonitorService(service, group)) continue; - if (skipsSslDueToHealthTls(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; @@ -176,7 +202,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; + if (!group.health_check_enabled || !group.health_check_verify_tls) continue; const parsed = parseFqdn(group.domain, knownZones); if (!parsed) continue; diff --git a/apps/api/test/certificates.test.ts b/apps/api/test/certificates.test.ts index 2239673..0a6fb43 100644 --- a/apps/api/test/certificates.test.ts +++ b/apps/api/test/certificates.test.ts @@ -73,7 +73,17 @@ describe("certificates", () => { ); const service = repos.createService(testApp.db, "Web", "web"); repos.setServiceEnabled(testApp.db, service.id, true); - repos.insertBinding(testApp.db, domain.id, service.id, "api", null); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "api", + null, + ); + repos.updateBindingLbConfig(testApp.db, binding.id, { + health_check_enabled: true, + health_check_verify_tls: true, + }); const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ @@ -94,6 +104,76 @@ describe("certificates", () => { await testApp.close(); }); + it("does not monitor binding when health-check is off", 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-imsk", + ); + const service = repos.createService(testApp.db, "Cname", "cname"); + repos.setServiceEnabled(testApp.db, service.id, true); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "imsk", + null, + ); + repos.setBindingCnameTarget(testApp.db, binding.id, "ihome.rkns.example.com"); + repos.updateBindingLbConfig(testApp.db, binding.id, { + health_check_enabled: false, + }); + + repos.upsertCertificateCheck( + testApp.db, + domain.id, + null, + "imsk.rkns.example.com", + null, + CERT_ERROR, + "stale", + ); + + 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 === "imsk.rkns.example.com", + ), + ).toBe(false); + expect(certificateService.checkHostname).not.toHaveBeenCalled(); + + const listRes = await testApp.inject({ + method: "GET", + url: "/api/v1/certificates", + headers, + }); + expect(listRes.statusCode).toBe(200); + expect( + (listRes.json() as { hostname: string }[]).some( + (c) => c.hostname === "imsk.rkns.example.com", + ), + ).toBe(false); + + await testApp.close(); + }); + it("does not monitor host when service is disabled", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index 51c2833..f66f89c 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -381,8 +381,8 @@ export function ServiceEditSheet({ return ( - - + + {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN @@ -390,7 +390,7 @@ export function ServiceEditSheet({ -
+
- - - + {!isCreate ? ( ({ ...tab }))} activeTab={activeTab}