From 92fa1cb9d8c7cff1b98123181f3c8ec49d4ac0d2 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:10:17 +0200 Subject: [PATCH] Fix duplicate certificate display for wildcard-covered subdomains When a wildcard cert (e.g. *.domain.de) existed and a proxy host was created for a subdomain (e.g. sub.domain.de) without explicitly linking it, the certificates page showed it as a separate ACME entry. Now hosts covered by an existing wildcard cert are attributed to that cert's "Used by" list instead. Closes #110 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(dashboard)/certificates/page.tsx | 44 ++++++++++++++++++- src/lib/cert-domain-match.ts | 20 +++++++++ tests/e2e/certificates.spec.ts | 47 ++++++++++++++++++++ tests/unit/cert-domain-match.test.ts | 62 +++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/lib/cert-domain-match.ts create mode 100644 tests/unit/cert-domain-match.test.ts diff --git a/app/(dashboard)/certificates/page.tsx b/app/(dashboard)/certificates/page.tsx index d9db6d6a..ba87725f 100644 --- a/app/(dashboard)/certificates/page.tsx +++ b/app/(dashboard)/certificates/page.tsx @@ -7,6 +7,7 @@ import CertificatesClient from './CertificatesClient'; import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates'; import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates'; import { listMtlsRoles, type MtlsRole } from '@/src/lib/models/mtls-roles'; +import { isDomainCoveredByCert } from '@/src/lib/cert-domain-match'; export type { CaCertificate }; export type { IssuedClientCertificate }; @@ -78,6 +79,7 @@ function getExpiryStatus(validToIso: string): CertExpiryStatus { return 'ok'; } + export default async function CertificatesPage({ searchParams }: PageProps) { await requireAdmin(); const { page: pageParam } = await searchParams; @@ -140,6 +142,44 @@ export default async function CertificatesPage({ searchParams }: PageProps) { usageMap.set(u.certId, hosts); } + // Build a map of cert ID -> its domain list (including wildcard entries) + const certDomainMap = new Map(); + for (const cert of certRows) { + const domainNames = JSON.parse(cert.domainNames) as string[]; + // For imported certs, also check PEM SANs which may include wildcards + if (cert.type === 'imported' && cert.certificatePem) { + const pemInfo = parsePemInfo(cert.certificatePem); + if (pemInfo?.sanDomains.length) { + certDomainMap.set(cert.id, pemInfo.sanDomains); + continue; + } + } + certDomainMap.set(cert.id, domainNames); + } + + // Filter out ACME hosts whose domains are fully covered by an existing certificate's wildcard, + // and attribute them to that certificate's usedBy list instead. + let adjustedAcmeTotal = acmeTotal; + const filteredAcmeHosts: AcmeHost[] = []; + for (const host of acmeHosts) { + let coveredByCertId: number | null = null; + for (const [certId, certDomains] of certDomainMap) { + if (host.domains.every(d => isDomainCoveredByCert(d, certDomains))) { + coveredByCertId = certId; + break; + } + } + if (coveredByCertId !== null) { + // Move this host to the cert's usedBy list + const hosts = usageMap.get(coveredByCertId) ?? []; + hosts.push({ id: host.id, name: host.name, domains: host.domains }); + usageMap.set(coveredByCertId, hosts); + adjustedAcmeTotal--; + } else { + filteredAcmeHosts.push(host); + } + } + const importedCerts: ImportedCertView[] = []; const managedCerts: ManagedCertView[] = []; const issuedByCa = issuedClientCerts.reduce>((map, cert) => { @@ -174,11 +214,11 @@ export default async function CertificatesPage({ searchParams }: PageProps) { return ( diff --git a/src/lib/cert-domain-match.ts b/src/lib/cert-domain-match.ts new file mode 100644 index 00000000..40b9df6f --- /dev/null +++ b/src/lib/cert-domain-match.ts @@ -0,0 +1,20 @@ +/** Check if a domain is covered by any wildcard in the set (e.g. *.example.com covers sub.example.com) */ +export function isDomainCoveredByWildcard(domain: string, wildcardDomains: string[]): boolean { + for (const wc of wildcardDomains) { + if (!wc.startsWith('*.')) continue; + const base = wc.slice(2); // "example.com" from "*.example.com" + // Exact base is not covered by wildcard alone (needs explicit entry) + if (domain === base) continue; + // Wildcard covers one level: sub.example.com but not sub.sub.example.com + if (domain.endsWith('.' + base) && !domain.slice(0, -(base.length + 1)).includes('.')) { + return true; + } + } + return false; +} + +/** Check if a domain is explicitly listed or covered by wildcard in a cert's domain list */ +export function isDomainCoveredByCert(domain: string, certDomains: string[]): boolean { + if (certDomains.includes(domain)) return true; + return isDomainCoveredByWildcard(domain, certDomains); +} diff --git a/tests/e2e/certificates.spec.ts b/tests/e2e/certificates.spec.ts index 1160df09..ebcc3ee6 100644 --- a/tests/e2e/certificates.spec.ts +++ b/tests/e2e/certificates.spec.ts @@ -22,4 +22,51 @@ test.describe('Certificates', () => { await page.goto('/certificates'); await expect(page).not.toHaveURL(/login/); }); + + test('wildcard cert covers subdomain — no duplicate in ACME tab', async ({ page }) => { + const BASE_URL = 'http://localhost:3000'; + const API = `${BASE_URL}/api/v1`; + const headers = { 'Content-Type': 'application/json', 'Origin': BASE_URL }; + const domain = `wc-test-${Date.now()}.example`; + + // 1. Create a managed certificate with wildcard + base domain + const certRes = await page.request.post(`${API}/certificates`, { + data: { + name: `Wildcard ${domain}`, + type: 'managed', + domainNames: [domain, `*.${domain}`], + autoRenew: true, + }, + headers, + }); + expect(certRes.status()).toBe(201); + const cert = await certRes.json(); + + // 2. Create a proxy host for a subdomain (no explicit certificateId → auto ACME) + const hostRes = await page.request.post(`${API}/proxy-hosts`, { + data: { + name: `Sub ${domain}`, + domains: [`sub.${domain}`], + upstreams: ['127.0.0.1:8080'], + }, + headers, + }); + expect(hostRes.status()).toBe(201); + const host = await hostRes.json(); + + try { + // 3. Visit certificates page — the subdomain host should NOT appear in the ACME tab + await page.goto('/certificates'); + await expect(page.getByRole('tab', { name: /acme/i })).toBeVisible(); + await page.getByRole('tab', { name: /acme/i }).click(); + + // The subdomain should not be listed as a separate ACME entry + const acmeTab = page.locator('[role="tabpanel"]'); + await expect(acmeTab.getByText(`sub.${domain}`)).not.toBeVisible({ timeout: 5_000 }); + } finally { + // Cleanup: delete the proxy host and certificate + await page.request.delete(`${API}/proxy-hosts/${host.id}`, { headers }); + await page.request.delete(`${API}/certificates/${cert.id}`, { headers }); + } + }); }); diff --git a/tests/unit/cert-domain-match.test.ts b/tests/unit/cert-domain-match.test.ts new file mode 100644 index 00000000..dfea1ab4 --- /dev/null +++ b/tests/unit/cert-domain-match.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { isDomainCoveredByWildcard, isDomainCoveredByCert } from '@/src/lib/cert-domain-match'; + +describe('isDomainCoveredByWildcard', () => { + it('wildcard *.example.com covers sub.example.com', () => { + expect(isDomainCoveredByWildcard('sub.example.com', ['*.example.com'])).toBe(true); + }); + + it('wildcard *.example.com does not cover example.com itself', () => { + expect(isDomainCoveredByWildcard('example.com', ['*.example.com'])).toBe(false); + }); + + it('wildcard *.example.com does not cover deep subdomain sub.sub.example.com', () => { + expect(isDomainCoveredByWildcard('sub.sub.example.com', ['*.example.com'])).toBe(false); + }); + + it('wildcard *.example.com does not cover unrelated.com', () => { + expect(isDomainCoveredByWildcard('unrelated.com', ['*.example.com'])).toBe(false); + }); + + it('returns false when no wildcards present', () => { + expect(isDomainCoveredByWildcard('sub.example.com', ['example.com', 'other.com'])).toBe(false); + }); + + it('wildcard *.domain.de covers app.domain.de', () => { + expect(isDomainCoveredByWildcard('app.domain.de', ['*.domain.de'])).toBe(true); + }); + + it('does not match partial suffix (notexample.com)', () => { + expect(isDomainCoveredByWildcard('notexample.com', ['*.example.com'])).toBe(false); + }); +}); + +describe('isDomainCoveredByCert', () => { + const certDomains = ['domain.de', '*.domain.de']; + + it('exact match: domain.de is covered', () => { + expect(isDomainCoveredByCert('domain.de', certDomains)).toBe(true); + }); + + it('wildcard match: sub.domain.de is covered', () => { + expect(isDomainCoveredByCert('sub.domain.de', certDomains)).toBe(true); + }); + + it('deep subdomain: a.b.domain.de is NOT covered', () => { + expect(isDomainCoveredByCert('a.b.domain.de', certDomains)).toBe(false); + }); + + it('unrelated domain is NOT covered', () => { + expect(isDomainCoveredByCert('other.com', certDomains)).toBe(false); + }); + + it('works with only wildcard (no explicit base)', () => { + expect(isDomainCoveredByCert('sub.example.com', ['*.example.com'])).toBe(true); + expect(isDomainCoveredByCert('example.com', ['*.example.com'])).toBe(false); + }); + + it('works with only explicit domain (no wildcard)', () => { + expect(isDomainCoveredByCert('example.com', ['example.com'])).toBe(true); + expect(isDomainCoveredByCert('sub.example.com', ['example.com'])).toBe(false); + }); +});