Handle wildcard certs

This commit is contained in:
Owen
2025-08-29 15:35:57 -07:00
parent b156b5ff2d
commit 8891d6239f
3 changed files with 428 additions and 37 deletions

View File

@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
Array<{ Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
Array<{ Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;

View File

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

View File

@@ -29,6 +29,7 @@ export class TraefikConfigManager {
exists: boolean; exists: boolean;
lastModified: Date | null; lastModified: Date | null;
expiresAt: Date | null; expiresAt: Date | null;
wildcard: boolean;
} }
>(); >();
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
exists: boolean; exists: boolean;
lastModified: Date | null; lastModified: Date | null;
expiresAt: Date | null; expiresAt: Date | null;
wildcard: boolean;
} }
> >
> { > {
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
const certPath = path.join(domainDir, "cert.pem"); const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem"); const keyPath = path.join(domainDir, "key.pem");
const lastUpdatePath = path.join(domainDir, ".last_update"); const lastUpdatePath = path.join(domainDir, ".last_update");
const wildcardPath = path.join(domainDir, ".wildcard");
const certExists = await this.fileExists(certPath); const certExists = await this.fileExists(certPath);
const keyExists = await this.fileExists(keyPath); const keyExists = await this.fileExists(keyPath);
const lastUpdateExists = await this.fileExists(lastUpdatePath); const lastUpdateExists = await this.fileExists(lastUpdatePath);
const wildcardExists = await this.fileExists(wildcardPath);
let lastModified: Date | null = null; let lastModified: Date | null = null;
const expiresAt: Date | null = null; const expiresAt: Date | null = null;
let wildcard = false;
if (lastUpdateExists) { if (lastUpdateExists) {
try { 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, { state.set(domain, {
exists: certExists && keyExists, exists: certExists && keyExists,
lastModified, lastModified,
expiresAt expiresAt,
wildcard
}); });
} }
} catch (error) { } catch (error) {
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
return true; return true;
} }
// Fetch if domains have changed // Filter out domains covered by wildcard certificates
const domainsNeedingCerts = new Set<string>();
for (const domain of currentDomains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
domainsNeedingCerts.add(domain);
}
}
// Fetch if domains needing certificates have changed
const lastDomainsNeedingCerts = new Set<string>();
for (const domain of this.lastKnownDomains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
lastDomainsNeedingCerts.add(domain);
}
}
if ( if (
this.lastKnownDomains.size !== currentDomains.size || domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
!Array.from(this.lastKnownDomains).every((domain) => !Array.from(domainsNeedingCerts).every((domain) =>
currentDomains.has(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; return true;
} }
// Check if any local certificates are missing or appear to be outdated // 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); const localState = this.lastLocalCertificateState.get(domain);
if (!localState || !localState.exists) { if (!localState || !localState.exists) {
logger.info( logger.info(
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
let validCertificates: Array<{ let validCertificates: Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
}> = []; }> = [];
if (this.shouldFetchCertificates(domains)) { if (this.shouldFetchCertificates(domains)) {
// Get valid certificates for active domains // Filter out domains that are already covered by wildcard certificates
if (config.isManagedMode()) { const domainsToFetch = new Set<string>();
validCertificates = for (const domain of domains) {
await getValidCertificatesForDomainsHybrid(domains); if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
} else { domainsToFetch.add(domain);
validCertificates = } else {
await getValidCertificatesForDomains(domains); logger.debug(
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
);
}
} }
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
logger.info( if (domainsToFetch.size > 0) {
`Fetched ${validCertificates.length} certificates from remote` // 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 logger.info(
await this.processValidCertificates(validCertificates); `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 { } else {
const timeSinceLastFetch = this.lastCertificateFetch const timeSinceLastFetch = this.lastCertificateFetch
? Math.round( ? Math.round(
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
// Clear existing certificates and rebuild from local state // Clear existing certificates and rebuild from local state
dynamicConfig.tls.certificates = []; dynamicConfig.tls.certificates = [];
// Keep track of certificates we've already added to avoid duplicates
const addedCertPaths = new Set<string>();
for (const domain of domains) { for (const domain of domains) {
// First, try to find an exact match certificate
const localState = this.lastLocalCertificateState.get(domain); const localState = this.lastLocalCertificateState.get(domain);
if (localState && localState.exists) { if (localState && localState.exists) {
const domainDir = path.join( const domainDir = path.join(
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
const certPath = path.join(domainDir, "cert.pem"); const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem"); const keyPath = path.join(domainDir, "key.pem");
const certEntry = { if (!addedCertPaths.has(certPath)) {
certFile: certPath, const certEntry = {
keyFile: keyPath certFile: certPath,
}; keyFile: keyPath
dynamicConfig.tls.certificates.push(certEntry); };
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<{ validCertificates: Array<{
id: number; id: number;
domain: string; domain: string;
wildcard: boolean;
certFile: string | null; certFile: string | null;
keyFile: string | null; keyFile: string | null;
expiresAt: Date | null; expiresAt: Date | null;
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
"utf8" "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( logger.info(
`Certificate updated for domain: ${cert.domain}` `Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
); );
// Update local state tracking // Update local state tracking
this.lastLocalCertificateState.set(cert.domain, { this.lastLocalCertificateState.set(cert.domain, {
exists: true, exists: true,
lastModified: new Date(), lastModified: new Date(),
expiresAt: cert.expiresAt expiresAt: cert.expiresAt,
wildcard: cert.wildcard
}); });
} }
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
this.lastLocalCertificateState.delete(dirName); this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config // Remove from dynamic config
const certFilePath = path.join( const certFilePath = path.join(domainDir, "cert.pem");
domainDir, const keyFilePath = path.join(domainDir, "key.pem");
"cert.pem"
);
const keyFilePath = path.join(
domainDir,
"key.pem"
);
const before = dynamicConfig.tls.certificates.length; const before = dynamicConfig.tls.certificates.length;
dynamicConfig.tls.certificates = dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter( dynamicConfig.tls.certificates.filter(
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
monitorInterval: number; monitorInterval: number;
lastCertificateFetch: Date | null; lastCertificateFetch: Date | null;
localCertificateCount: number; 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 { return {
isRunning: this.isRunning, isRunning: this.isRunning,
activeDomains: Array.from(this.activeDomains), activeDomains: Array.from(this.activeDomains),
monitorInterval: monitorInterval:
config.getRawConfig().traefik.monitor_interval || 5000, config.getRawConfig().traefik.monitor_interval || 5000,
lastCertificateFetch: this.lastCertificateFetch, 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<string, { exists: boolean; wildcard: boolean }>): 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;
}