fix(certificates): не мониторить SSL без health-check с TLS verify
Биндинги с выключенным health-check (imsk/mmsk) исключаются из авто-мониторинга; sticky footer в редактировании сервиса. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -381,8 +381,8 @@ export function ServiceEditSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader className="border-b pb-4">
|
||||
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-xl">
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN
|
||||
@@ -390,7 +390,7 @@ export function ServiceEditSheet({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'general', label: 'Основное' },
|
||||
@@ -642,9 +642,7 @@ export function ServiceEditSheet({
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
|
||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||
{!isCreate ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
|
||||
@@ -74,7 +74,7 @@ function CertificatesPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
|
||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||
actions={primaryAction}
|
||||
/>
|
||||
<CertKpiCards
|
||||
@@ -86,7 +86,7 @@ function CertificatesPage() {
|
||||
/>
|
||||
<ResourcePage
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
|
||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||
hideHeader
|
||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||
activeTab={activeTab}
|
||||
|
||||
Reference in New Issue
Block a user