fix(certificates): убрать stale SSL-хосты и вернуть Save в FormSheet
Группы с health-check без verify_tls исключаются из мониторинга сертификатов; list/summary чистят stale. FormSheet — sticky footer со скроллом тела. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -21,10 +21,19 @@ export interface CertificateTarget {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneStaleCertificates(db: Db): void {
|
||||||
|
const targets = resolveCertificateTargets(db);
|
||||||
|
repos.deleteCertificatesNotIn(
|
||||||
|
db,
|
||||||
|
targets.map((t) => t.hostname),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listCertificates(
|
export function listCertificates(
|
||||||
db: Db,
|
db: Db,
|
||||||
status?: string,
|
status?: string,
|
||||||
): Certificate[] {
|
): Certificate[] {
|
||||||
|
pruneStaleCertificates(db);
|
||||||
return repos.listCertificates(db, status);
|
return repos.listCertificates(db, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +142,13 @@ 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. */
|
||||||
|
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(
|
export function buildServiceCertificateFqdns(
|
||||||
db: Db,
|
db: Db,
|
||||||
): Map<string, CertificateTarget> {
|
): Map<string, CertificateTarget> {
|
||||||
@@ -144,6 +160,7 @@ 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;
|
||||||
|
|
||||||
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;
|
||||||
@@ -159,6 +176,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;
|
||||||
|
|
||||||
const parsed = parseFqdn(group.domain, knownZones);
|
const parsed = parseFqdn(group.domain, knownZones);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
@@ -244,5 +262,6 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function statusSummary(db: Db): Array<[string, number]> {
|
export function statusSummary(db: Db): Array<[string, number]> {
|
||||||
|
pruneStaleCertificates(db);
|
||||||
return repos.countCertificatesByStatus(db);
|
return repos.countCertificatesByStatus(db);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,4 +248,216 @@ describe("certificates", () => {
|
|||||||
|
|
||||||
await testApp.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,18 +40,29 @@ export function FormSheet<T extends FieldValues>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className={cn(className)}>
|
<SheetContent
|
||||||
<SheetHeader>
|
className={cn('flex flex-col gap-0 overflow-hidden', className)}
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
{description && <SheetDescription>{description}</SheetDescription>}
|
{description && <SheetDescription>{description}</SheetDescription>}
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className={cn('flex flex-1 flex-col gap-4 px-4', contentClassName)}
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
{children}
|
<div
|
||||||
{footer && <SheetFooter>{footer}</SheetFooter>}
|
className={cn(
|
||||||
|
'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4',
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<SheetFooter className="shrink-0 border-t">{footer}</SheetFooter>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function HealthCheckConfigFields({
|
|||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Проверять сертификат"
|
title="Проверять сертификат"
|
||||||
description="HTTPS (:443). Выключите для self-signed или IP-pin без валидной цепочки."
|
description="HTTPS (:443). Выключите для self-signed или IP-pin — хосты группы также не попадут на страницу «Сертификаты»."
|
||||||
labelFor={`${idPrefix}-verify-tls`}
|
labelFor={`${idPrefix}-verify-tls`}
|
||||||
compact
|
compact
|
||||||
last
|
last
|
||||||
|
|||||||
Reference in New Issue
Block a user