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:
@@ -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}
|
||||
/>
|
||||
|
||||
20
src/lib/cert-domain-match.ts
Normal file
20
src/lib/cert-domain-match.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
62
tests/unit/cert-domain-match.test.ts
Normal file
62
tests/unit/cert-domain-match.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user