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);
|
return repos.findSubdomainByDomainAndName(db, domainId, hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Health-check without TLS verify implies invalid certs — skip SSL monitoring. */
|
type HealthTlsFlags = {
|
||||||
function skipsSslDueToHealthTls(
|
health_check_enabled: boolean;
|
||||||
group: { health_check_enabled: boolean; health_check_verify_tls: boolean } | null,
|
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 {
|
): 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(
|
export function buildServiceCertificateFqdns(
|
||||||
@@ -160,7 +176,17 @@ export function buildServiceCertificateFqdns(
|
|||||||
? repos.getServiceGroup(db, service.service_group_id)
|
? repos.getServiceGroup(db, service.service_group_id)
|
||||||
: null;
|
: null;
|
||||||
if (!shouldMonitorService(service, group)) continue;
|
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);
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
if (subdomain && !subdomain.enabled) continue;
|
||||||
@@ -176,7 +202,7 @@ export function buildServiceCertificateFqdns(
|
|||||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
||||||
for (const group of repos.listServiceGroups(db)) {
|
for (const group of repos.listServiceGroups(db)) {
|
||||||
if (!group.enabled || !group.domain?.trim()) continue;
|
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);
|
const parsed = parseFqdn(group.domain, knownZones);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|||||||
@@ -73,7 +73,17 @@ describe("certificates", () => {
|
|||||||
);
|
);
|
||||||
const service = repos.createService(testApp.db, "Web", "web");
|
const service = repos.createService(testApp.db, "Web", "web");
|
||||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
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);
|
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
@@ -94,6 +104,76 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
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 () => {
|
it("does not monitor host when service is disabled", async () => {
|
||||||
const testApp = await buildApp({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
|||||||
@@ -381,8 +381,8 @@ export function ServiceEditSheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
|
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-xl">
|
||||||
<SheetHeader className="border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN
|
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN
|
||||||
@@ -390,7 +390,7 @@ export function ServiceEditSheet({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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
|
<CountedLineTabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'general', label: 'Основное' },
|
{ id: 'general', label: 'Основное' },
|
||||||
@@ -642,9 +642,7 @@ export function ServiceEditSheet({
|
|||||||
</CountedLineTabs>
|
</CountedLineTabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||||
|
|
||||||
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
|
|
||||||
{!isCreate ? (
|
{!isCreate ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function CertificatesPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
|
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||||
actions={primaryAction}
|
actions={primaryAction}
|
||||||
/>
|
/>
|
||||||
<CertKpiCards
|
<CertKpiCards
|
||||||
@@ -86,7 +86,7 @@ function CertificatesPage() {
|
|||||||
/>
|
/>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
|
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||||
hideHeader
|
hideHeader
|
||||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|||||||
Reference in New Issue
Block a user