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) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-18 12:10:17 +02:00
parent ef62ef232f
commit 92fa1cb9d8
4 changed files with 171 additions and 2 deletions

View File

@@ -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<number, string[]>();
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<number, IssuedClientCertificate[]>>((map, cert) => {
@@ -174,11 +214,11 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
return (
<CertificatesClient
acmeHosts={acmeHosts}
acmeHosts={filteredAcmeHosts}
importedCerts={importedCerts}
managedCerts={managedCerts}
caCertificates={caCertificateViews}
acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }}
acmePagination={{ total: adjustedAcmeTotal, page, perPage: PER_PAGE }}
mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts}
/>

View File

@@ -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);
}

View File

@@ -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 });
}
});
});

View File

@@ -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);
});
});