From 8891d6239fe7fa8fca47da990ff43d575fefb949 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 29 Aug 2025 15:35:57 -0700 Subject: [PATCH] Handle wildcard certs --- server/lib/remoteCertificates/certificates.ts | 2 + server/lib/traefikConfig.test.ts | 235 ++++++++++++++++++ server/lib/traefikConfig.ts | 228 ++++++++++++++--- 3 files changed, 428 insertions(+), 37 deletions(-) create mode 100644 server/lib/traefikConfig.test.ts diff --git a/server/lib/remoteCertificates/certificates.ts b/server/lib/remoteCertificates/certificates.ts index db6fa6ad..dde0f592 100644 --- a/server/lib/remoteCertificates/certificates.ts +++ b/server/lib/remoteCertificates/certificates.ts @@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set) Array<{ id: number; domain: string; + wildcard: boolean; certFile: string | null; keyFile: string | null; expiresAt: Date | null; @@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set): Prom Array<{ id: number; domain: string; + wildcard: boolean; certFile: string | null; keyFile: string | null; expiresAt: Date | null; diff --git a/server/lib/traefikConfig.test.ts b/server/lib/traefikConfig.test.ts new file mode 100644 index 00000000..55d19647 --- /dev/null +++ b/server/lib/traefikConfig.test.ts @@ -0,0 +1,235 @@ +import { assertEquals } from "@test/assert"; +import { isDomainCoveredByWildcard } from "./traefikConfig"; + +function runTests() { + console.log('Running wildcard domain coverage tests...'); + + // Test case 1: Basic wildcard certificate at example.com + const basicWildcardCerts = new Map([ + ['example.com', { exists: true, wildcard: true }] + ]); + + // Should match first-level subdomains + assertEquals( + isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match level1.example.com' + ); + + assertEquals( + isDomainCoveredByWildcard('api.example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match api.example.com' + ); + + assertEquals( + isDomainCoveredByWildcard('www.example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match www.example.com' + ); + + // Should match the root domain (exact match) + assertEquals( + isDomainCoveredByWildcard('example.com', basicWildcardCerts), + true, + 'Wildcard cert at example.com should match example.com itself' + ); + + // Should NOT match second-level subdomains + assertEquals( + isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match level2.level1.example.com' + ); + + assertEquals( + isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com' + ); + + // Should NOT match different domains + assertEquals( + isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match test.otherdomain.com' + ); + + assertEquals( + isDomainCoveredByWildcard('notexample.com', basicWildcardCerts), + false, + 'Wildcard cert at example.com should NOT match notexample.com' + ); + + // Test case 2: Multiple wildcard certificates + const multipleWildcardCerts = new Map([ + ['example.com', { exists: true, wildcard: true }], + ['test.org', { exists: true, wildcard: true }], + ['api.service.net', { exists: true, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts), + true, + 'Should match subdomain of first wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts), + true, + 'Should match subdomain of second wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts), + true, + 'Should match subdomain of third wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts), + false, + 'Should NOT match multi-level subdomain of third wildcard cert' + ); + + // Test exact domain matches for multiple certs + assertEquals( + isDomainCoveredByWildcard('example.com', multipleWildcardCerts), + true, + 'Should match exact domain of first wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('test.org', multipleWildcardCerts), + true, + 'Should match exact domain of second wildcard cert' + ); + + assertEquals( + isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts), + true, + 'Should match exact domain of third wildcard cert' + ); + + // Test case 3: Non-wildcard certificates (should not match anything) + const nonWildcardCerts = new Map([ + ['example.com', { exists: true, wildcard: false }], + ['specific.domain.com', { exists: true, wildcard: false }] + ]); + + assertEquals( + isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts), + false, + 'Non-wildcard cert should not match subdomains' + ); + + assertEquals( + isDomainCoveredByWildcard('example.com', nonWildcardCerts), + false, + 'Non-wildcard cert should not match even exact domain via this function' + ); + + // Test case 4: Non-existent certificates (should not match) + const nonExistentCerts = new Map([ + ['example.com', { exists: false, wildcard: true }], + ['missing.com', { exists: false, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('sub.example.com', nonExistentCerts), + false, + 'Non-existent wildcard cert should not match' + ); + + // Test case 5: Edge cases with special domain names + const specialDomainCerts = new Map([ + ['localhost', { exists: true, wildcard: true }], + ['127-0-0-1.nip.io', { exists: true, wildcard: true }], + ['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain + ]); + + assertEquals( + isDomainCoveredByWildcard('app.localhost', specialDomainCerts), + true, + 'Should match subdomain of localhost wildcard' + ); + + assertEquals( + isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts), + true, + 'Should match subdomain of nip.io wildcard' + ); + + assertEquals( + isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts), + true, + 'Should match subdomain of IDN wildcard' + ); + + // Test case 6: Empty input and edge cases + const emptyCerts = new Map(); + + assertEquals( + isDomainCoveredByWildcard('any.domain.com', emptyCerts), + false, + 'Empty certificate map should not match any domain' + ); + + // Test case 7: Domains with single character components + const singleCharCerts = new Map([ + ['a.com', { exists: true, wildcard: true }], + ['x.y.z', { exists: true, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('b.a.com', singleCharCerts), + true, + 'Should match single character subdomain' + ); + + assertEquals( + isDomainCoveredByWildcard('w.x.y.z', singleCharCerts), + true, + 'Should match single character subdomain of multi-part domain' + ); + + assertEquals( + isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts), + false, + 'Should NOT match multi-level subdomain of single char domain' + ); + + // Test case 8: Domains with numbers and hyphens + const numericCerts = new Map([ + ['api-v2.service-1.com', { exists: true, wildcard: true }], + ['123.456.net', { exists: true, wildcard: true }] + ]); + + assertEquals( + isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts), + true, + 'Should match subdomain with hyphens and numbers' + ); + + assertEquals( + isDomainCoveredByWildcard('test.123.456.net', numericCerts), + true, + 'Should match subdomain with numeric components' + ); + + assertEquals( + isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts), + false, + 'Should NOT match multi-level subdomain with hyphens and numbers' + ); + + console.log('All wildcard domain coverage tests passed!'); +} + +// Run all tests +try { + runTests(); +} catch (error) { + console.error('Test failed:', error); + process.exit(1); +} diff --git a/server/lib/traefikConfig.ts b/server/lib/traefikConfig.ts index 03656506..d2bb2b9e 100644 --- a/server/lib/traefikConfig.ts +++ b/server/lib/traefikConfig.ts @@ -29,6 +29,7 @@ export class TraefikConfigManager { exists: boolean; lastModified: Date | null; expiresAt: Date | null; + wildcard: boolean; } >(); @@ -115,6 +116,7 @@ export class TraefikConfigManager { exists: boolean; lastModified: Date | null; expiresAt: Date | null; + wildcard: boolean; } > > { @@ -136,13 +138,16 @@ export class TraefikConfigManager { const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); const lastUpdatePath = path.join(domainDir, ".last_update"); + const wildcardPath = path.join(domainDir, ".wildcard"); const certExists = await this.fileExists(certPath); const keyExists = await this.fileExists(keyPath); const lastUpdateExists = await this.fileExists(lastUpdatePath); + const wildcardExists = await this.fileExists(wildcardPath); let lastModified: Date | null = null; const expiresAt: Date | null = null; + let wildcard = false; if (lastUpdateExists) { try { @@ -161,10 +166,26 @@ export class TraefikConfigManager { } } + // Check if this is a wildcard certificate + if (wildcardExists) { + try { + const wildcardContent = fs + .readFileSync(wildcardPath, "utf8") + .trim(); + wildcard = wildcardContent === "true"; + } catch (error) { + logger.warn( + `Could not read wildcard file for ${domain}:`, + error + ); + } + } + state.set(domain, { exists: certExists && keyExists, lastModified, - expiresAt + expiresAt, + wildcard }); } } catch (error) { @@ -192,19 +213,36 @@ export class TraefikConfigManager { return true; } - // Fetch if domains have changed + // Filter out domains covered by wildcard certificates + const domainsNeedingCerts = new Set(); + for (const domain of currentDomains) { + if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + domainsNeedingCerts.add(domain); + } + } + + // Fetch if domains needing certificates have changed + const lastDomainsNeedingCerts = new Set(); + for (const domain of this.lastKnownDomains) { + if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + lastDomainsNeedingCerts.add(domain); + } + } + if ( - this.lastKnownDomains.size !== currentDomains.size || - !Array.from(this.lastKnownDomains).every((domain) => - currentDomains.has(domain) + domainsNeedingCerts.size !== lastDomainsNeedingCerts.size || + !Array.from(domainsNeedingCerts).every((domain) => + lastDomainsNeedingCerts.has(domain) ) ) { - logger.info("Fetching certificates due to domain changes"); + logger.info( + "Fetching certificates due to domain changes (after wildcard filtering)" + ); return true; } // Check if any local certificates are missing or appear to be outdated - for (const domain of currentDomains) { + for (const domain of domainsNeedingCerts) { const localState = this.lastLocalCertificateState.get(domain); if (!localState || !localState.exists) { logger.info( @@ -273,6 +311,7 @@ export class TraefikConfigManager { let validCertificates: Array<{ id: number; domain: string; + wildcard: boolean; certFile: string | null; keyFile: string | null; expiresAt: Date | null; @@ -280,23 +319,50 @@ export class TraefikConfigManager { }> = []; if (this.shouldFetchCertificates(domains)) { - // Get valid certificates for active domains - if (config.isManagedMode()) { - validCertificates = - await getValidCertificatesForDomainsHybrid(domains); - } else { - validCertificates = - await getValidCertificatesForDomains(domains); + // Filter out domains that are already covered by wildcard certificates + const domainsToFetch = new Set(); + for (const domain of domains) { + if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + domainsToFetch.add(domain); + } else { + logger.debug( + `Domain ${domain} is covered by existing wildcard certificate, skipping fetch` + ); + } } - this.lastCertificateFetch = new Date(); - this.lastKnownDomains = new Set(domains); - logger.info( - `Fetched ${validCertificates.length} certificates from remote` - ); + if (domainsToFetch.size > 0) { + // Get valid certificates for domains not covered by wildcards + if (config.isManagedMode()) { + validCertificates = + await getValidCertificatesForDomainsHybrid( + domainsToFetch + ); + } else { + validCertificates = + await getValidCertificatesForDomains( + domainsToFetch + ); + } + this.lastCertificateFetch = new Date(); + this.lastKnownDomains = new Set(domains); - // Download and decrypt new certificates - await this.processValidCertificates(validCertificates); + logger.info( + `Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)` + ); + + // Download and decrypt new certificates + await this.processValidCertificates(validCertificates); + } else { + logger.info( + "All domains are covered by existing wildcard certificates, no fetch needed" + ); + this.lastCertificateFetch = new Date(); + this.lastKnownDomains = new Set(domains); + } + + // Always ensure all existing certificates (including wildcards) are in the config + await this.updateDynamicConfigFromLocalCerts(domains); } else { const timeSinceLastFetch = this.lastCertificateFetch ? Math.round( @@ -544,7 +610,11 @@ export class TraefikConfigManager { // Clear existing certificates and rebuild from local state dynamicConfig.tls.certificates = []; + // Keep track of certificates we've already added to avoid duplicates + const addedCertPaths = new Set(); + for (const domain of domains) { + // First, try to find an exact match certificate const localState = this.lastLocalCertificateState.get(domain); if (localState && localState.exists) { const domainDir = path.join( @@ -554,11 +624,47 @@ export class TraefikConfigManager { const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); - const certEntry = { - certFile: certPath, - keyFile: keyPath - }; - dynamicConfig.tls.certificates.push(certEntry); + if (!addedCertPaths.has(certPath)) { + const certEntry = { + certFile: certPath, + keyFile: keyPath + }; + dynamicConfig.tls.certificates.push(certEntry); + addedCertPaths.add(certPath); + } + continue; + } + + // If no exact match, check for wildcard certificates that cover this domain + for (const [certDomain, certState] of this.lastLocalCertificateState) { + if (certState.exists && certState.wildcard) { + // Check if this wildcard certificate covers the domain + if (domain.endsWith("." + certDomain)) { + // Verify it's only one level deep (wildcard only covers one level) + const prefix = domain.substring( + 0, + domain.length - ("." + certDomain).length + ); + if (!prefix.includes(".")) { + const domainDir = path.join( + config.getRawConfig().traefik.certificates_path, + certDomain + ); + const certPath = path.join(domainDir, "cert.pem"); + const keyPath = path.join(domainDir, "key.pem"); + + if (!addedCertPaths.has(certPath)) { + const certEntry = { + certFile: certPath, + keyFile: keyPath + }; + dynamicConfig.tls.certificates.push(certEntry); + addedCertPaths.add(certPath); + } + break; // Found a wildcard that covers this domain + } + } + } } } @@ -577,6 +683,7 @@ export class TraefikConfigManager { validCertificates: Array<{ id: number; domain: string; + wildcard: boolean; certFile: string | null; keyFile: string | null; expiresAt: Date | null; @@ -651,15 +758,24 @@ export class TraefikConfigManager { "utf8" ); + // Check if this is a wildcard certificate and store it + const wildcardPath = path.join(domainDir, ".wildcard"); + fs.writeFileSync( + wildcardPath, + cert.wildcard ? "true" : "false", + "utf8" + ); + logger.info( - `Certificate updated for domain: ${cert.domain}` + `Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}` ); // Update local state tracking this.lastLocalCertificateState.set(cert.domain, { exists: true, lastModified: new Date(), - expiresAt: cert.expiresAt + expiresAt: cert.expiresAt, + wildcard: cert.wildcard }); } @@ -810,14 +926,8 @@ export class TraefikConfigManager { this.lastLocalCertificateState.delete(dirName); // Remove from dynamic config - const certFilePath = path.join( - domainDir, - "cert.pem" - ); - const keyFilePath = path.join( - domainDir, - "key.pem" - ); + const certFilePath = path.join(domainDir, "cert.pem"); + const keyFilePath = path.join(domainDir, "key.pem"); const before = dynamicConfig.tls.certificates.length; dynamicConfig.tls.certificates = dynamicConfig.tls.certificates.filter( @@ -894,14 +1004,58 @@ export class TraefikConfigManager { monitorInterval: number; lastCertificateFetch: Date | null; localCertificateCount: number; + wildcardCertificates: string[]; + domainsCoveredByWildcards: string[]; } { + const wildcardCertificates: string[] = []; + const domainsCoveredByWildcards: string[] = []; + + // Find wildcard certificates + for (const [domain, state] of this.lastLocalCertificateState) { + if (state.exists && state.wildcard) { + wildcardCertificates.push(domain); + } + } + + // Find domains covered by wildcards + for (const domain of this.activeDomains) { + if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) { + domainsCoveredByWildcards.push(domain); + } + } + return { isRunning: this.isRunning, activeDomains: Array.from(this.activeDomains), monitorInterval: config.getRawConfig().traefik.monitor_interval || 5000, lastCertificateFetch: this.lastCertificateFetch, - localCertificateCount: this.lastLocalCertificateState.size + localCertificateCount: this.lastLocalCertificateState.size, + wildcardCertificates, + domainsCoveredByWildcards }; } } + +/** + * Check if a domain is covered by existing wildcard certificates + */ +export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map): boolean { + for (const [certDomain, state] of lastLocalCertificateState) { + if (state.exists && state.wildcard) { + // If stored as example.com but is wildcard, check subdomains + if (domain.endsWith("." + certDomain)) { + // Check that it's only one level deep (wildcard only covers one level) + const prefix = domain.substring( + 0, + domain.length - ("." + certDomain).length + ); + // If prefix contains a dot, it's more than one level deep + if (!prefix.includes(".")) { + return true; + } + } + } + } + return false; +}