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<{
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<string>): Prom
Array<{
id: number;
domain: string;
wildcard: boolean;
certFile: string | null;
keyFile: string | 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;
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<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 (
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<string>();
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<string>();
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<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;
}