BREAKING CHANGE(smartacme): Make wildcard certificates opt-in to fix HTTP-01 only configurations

This commit is contained in:
Philipp Kunz 2025-05-19 10:01:31 +00:00
parent dcc89f0088
commit 086eea1aa2
8 changed files with 359 additions and 11 deletions

View File

@ -1,5 +1,22 @@
# Changelog
## 2025-05-19 - 8.0.0 - BREAKING CHANGE(smartacme)
Make wildcard certificates opt-in to fix HTTP-01 only configurations
- BREAKING CHANGE: Wildcard certificates are no longer automatically requested for all domains
- Added 'includeWildcard' option to getCertificateForDomain() to explicitly request wildcard certificates
- HTTP-01 only configurations now work correctly as they do not try to request wildcard certificates automatically
- Updated certificate CSR generation to match the requested domain configuration
## 2025-05-19 - 7.4.0 - feat(smartacme)
Make wildcard certificates opt-in to fix HTTP-01 only configurations
- BREAKING CHANGE: Wildcard certificates are no longer automatically requested for all domains
- Added `includeWildcard` option to `getCertificateForDomain()` to explicitly request wildcards
- HTTP-01 only configurations now work correctly as they no longer attempt wildcard certificates
- Wildcard certificates require DNS-01 handler and must be explicitly requested
- Updated certificate CSR generation to match the requested domain configuration
## 2025-05-18 - 7.3.4 - fix(smartacme)
Refine documentation and tests for improved clarity in ACME certificate management

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartacme",
"version": "7.3.4",
"version": "7.4.0",
"private": false,
"description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.",
"main": "dist_ts/index.js",

View File

@ -1,2 +1,13 @@
- this repo is dependent on letsencrypt and its limits
- to simpify the outside API, smartacme is stateful, meaning it works with a mongodb and a collection called 'SmartacmeCert'.
- to simpify the outside API, smartacme is stateful, meaning it works with a mongodb and a collection called 'SmartacmeCert'.
## Certificate Request Behavior
As of v7.4.0, SmartAcme no longer automatically requests wildcard certificates for all domain requests. This change was made to fix issues with HTTP-01 only configurations which cannot validate wildcard domains.
- By default, `getCertificateForDomain('example.com')` only requests a certificate for `example.com`
- To request both regular and wildcard certificates, use `getCertificateForDomain('example.com', { includeWildcard: true })`
- Wildcard certificates require a DNS-01 challenge handler to be configured
- Direct wildcard requests like `getCertificateForDomain('*.example.com')` only request the wildcard certificate
This change ensures HTTP-01 only configurations work properly while still allowing wildcard certificates when needed and supported.

View File

@ -196,8 +196,13 @@ async function main() {
await smartAcmeInstance.start();
const myDomain = 'example.com';
// Get certificate for domain (no wildcard)
const myCert = await smartAcmeInstance.getCertificateForDomain(myDomain);
console.log('Certificate:', myCert);
// Get certificate with wildcard (requires DNS-01 handler)
const certWithWildcard = await smartAcmeInstance.getCertificateForDomain(myDomain, { includeWildcard: true });
console.log('Certificate with wildcard:', certWithWildcard);
await smartAcmeInstance.stop();
}
@ -306,7 +311,10 @@ The certificate object obtained from the `getCertificateForDomain` method has th
- **start()**: Initializes the SmartAcme instance, sets up the ACME client, and registers the account with Let's Encrypt.
- **stop()**: Closes the MongoDB connection and performs any necessary cleanup.
- **getCertificateForDomain(domainArg: string)**: Retrieves or obtains a certificate for the specified domain name. If a valid certificate exists in the database, it is returned. Otherwise, a new certificate is requested and stored.
- **getCertificateForDomain(domainArg: string, options?: { includeWildcard?: boolean })**: Retrieves or obtains a certificate for the specified domain name. If a valid certificate exists in the database, it is returned. Otherwise, a new certificate is requested and stored.
- By default, only a certificate for the exact domain is requested
- Set `includeWildcard: true` to also request a wildcard certificate (requires DNS-01 handler)
- When requesting a wildcard directly (e.g., `*.example.com`), only the wildcard certificate is requested
### Handling Domain Matching

178
test/test.http01-only.ts Normal file
View File

@ -0,0 +1,178 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { SmartAcme, certmanagers } from '../ts/index.js';
import { Http01MemoryHandler } from '../ts/handlers/Http01MemoryHandler.js';
// Test that HTTP-01 only configuration works without wildcard certificates
tap.test('HTTP-01 only configuration should work for regular domains', async () => {
const memHandler = new Http01MemoryHandler();
// Stub the domain support check to always return true for testing
memHandler.checkWetherDomainIsSupported = async () => true;
const smartAcmeInstance = new SmartAcme({
accountEmail: 'test@example.com',
certManager: new certmanagers.MemoryCertManager(),
environment: 'integration',
retryOptions: {},
challengeHandlers: [memHandler],
challengePriority: ['http-01'],
});
// Stub the start method to avoid actual ACME connections
smartAcmeInstance.start = async () => {
smartAcmeInstance.certmatcher = {
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
} as any;
smartAcmeInstance.interestMap = {
checkInterest: async () => false,
addInterest: async () => ({ interestFullfilled: new Promise(() => {}) } as any)
} as any;
await smartAcmeInstance.certmanager.init();
};
await smartAcmeInstance.start();
// Stub the core certificate methods to avoid actual ACME calls
smartAcmeInstance.client = {
createOrder: async (orderPayload: any) => {
// Verify no wildcard is included in default request
const identifiers = orderPayload.identifiers;
expect(identifiers.length).toEqual(1);
expect(identifiers[0].value).toEqual('example.com');
expect(identifiers.find((id: any) => id.value.startsWith('*.'))).toBeUndefined();
return { status: 'pending', authorizations: [], finalize: '', certificate: '' };
},
getAuthorizations: async () => [],
finalizeOrder: async () => {},
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
smartAcmeInstance.certmanager.storeCertificate = async (cert: any) => cert;
// Request certificate without wildcard
const cert = await smartAcmeInstance.getCertificateForDomain('example.com');
expect(cert).toBeDefined();
});
tap.test('should only include wildcard when explicitly requested with DNS-01', async () => {
const dnsHandler = {
getSupportedTypes: () => ['dns-01'],
prepare: async () => {},
cleanup: async () => {},
checkWetherDomainIsSupported: async () => true,
};
const smartAcmeInstance = new SmartAcme({
accountEmail: 'test@example.com',
certManager: new certmanagers.MemoryCertManager(),
environment: 'integration',
retryOptions: {},
challengeHandlers: [dnsHandler],
challengePriority: ['dns-01'],
});
// Stub the start method to avoid actual ACME connections
smartAcmeInstance.start = async () => {
smartAcmeInstance.certmatcher = {
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
} as any;
smartAcmeInstance.interestMap = {
checkInterest: async () => false,
addInterest: async () => ({ interestFullfilled: new Promise(() => {}) } as any)
} as any;
await smartAcmeInstance.certmanager.init();
};
await smartAcmeInstance.start();
// Stub the core certificate methods
smartAcmeInstance.client = {
createOrder: async (orderPayload: any) => {
const identifiers = orderPayload.identifiers;
expect(identifiers.length).toEqual(2);
expect(identifiers[0].value).toEqual('example.com');
expect(identifiers[1].value).toEqual('*.example.com');
return { status: 'pending', authorizations: [], finalize: '', certificate: '' };
},
getAuthorizations: async () => [],
finalizeOrder: async () => {},
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
smartAcmeInstance.certmanager.storeCertificate = async (cert: any) => cert;
// Request certificate with wildcard
const cert = await smartAcmeInstance.getCertificateForDomain('example.com', { includeWildcard: true });
expect(cert).toBeDefined();
});
tap.test('should skip wildcard if requested but no DNS-01 handler available', async () => {
const httpHandler = new Http01MemoryHandler();
httpHandler.checkWetherDomainIsSupported = async () => true;
const smartAcmeInstance = new SmartAcme({
accountEmail: 'test@example.com',
certManager: new certmanagers.MemoryCertManager(),
environment: 'integration',
retryOptions: {},
challengeHandlers: [httpHandler],
challengePriority: ['http-01'],
});
// Stub the start method to avoid actual ACME connections
smartAcmeInstance.start = async () => {
smartAcmeInstance.certmatcher = {
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
} as any;
smartAcmeInstance.interestMap = {
checkInterest: async () => false,
addInterest: async () => ({ interestFullfilled: new Promise(() => {}) } as any)
} as any;
await smartAcmeInstance.certmanager.init();
};
await smartAcmeInstance.start();
// Mock logger to capture warning
const logSpy = { called: false, message: '' };
smartAcmeInstance.logger.log = async (level: string, message: string) => {
if (level === 'warn') {
logSpy.called = true;
logSpy.message = message;
}
};
// Stub the core certificate methods
smartAcmeInstance.client = {
createOrder: async (orderPayload: any) => {
const identifiers = orderPayload.identifiers;
// Should only have regular domain, no wildcard
expect(identifiers.length).toEqual(1);
expect(identifiers[0].value).toEqual('example.com');
return { status: 'pending', authorizations: [], finalize: '', certificate: '' };
},
getAuthorizations: async () => [],
finalizeOrder: async () => {},
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
smartAcmeInstance.certmanager.storeCertificate = async (cert: any) => cert;
// Request certificate with wildcard (should be skipped)
const cert = await smartAcmeInstance.getCertificateForDomain('example.com', { includeWildcard: true });
expect(cert).toBeDefined();
expect(logSpy.called).toBeTrue();
expect(logSpy.message).toContain('Wildcard certificate requested but no DNS-01 handler available');
});
export default tap.start();

View File

@ -0,0 +1,94 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { SmartAcme, certmanagers } from '../ts/index.js';
import { SmartacmeCert as Cert } from '../ts/smartacme.classes.cert.js';
// Simple test to verify wildcard options are correctly processed
tap.test('should not include wildcard by default for regular domain', async () => {
let orderPayload: any = null;
// Override the SmartAcme prototype methods for testing
const origGetCert = SmartAcme.prototype.getCertificateForDomain;
// Create a minimal test version of getCertificateForDomain
SmartAcme.prototype.getCertificateForDomain = async function(
domainArg: string,
options?: { includeWildcard?: boolean }
) {
const certDomainName = domainArg.replace('*.', '');
const identifiers = [];
if (domainArg.startsWith('*.')) {
identifiers.push({ type: 'dns', value: domainArg });
} else {
identifiers.push({ type: 'dns', value: certDomainName });
if (options?.includeWildcard) {
const hasDnsHandler = this.challengeHandlers.some((h) =>
h.getSupportedTypes().includes('dns-01')
);
if (hasDnsHandler) {
identifiers.push({ type: 'dns', value: `*.${certDomainName}` });
}
}
}
orderPayload = { identifiers };
return new Cert({ domainName: certDomainName });
};
try {
// Create instance with HTTP-01 only
const smartAcme = new SmartAcme({
accountEmail: 'test@example.com',
certManager: new certmanagers.MemoryCertManager(),
environment: 'integration',
challengeHandlers: [{
getSupportedTypes: () => ['http-01'],
prepare: async () => {},
cleanup: async () => {},
checkWetherDomainIsSupported: async () => true,
}],
});
// Test 1: Regular domain without wildcard option
await smartAcme.getCertificateForDomain('example.com');
expect(orderPayload.identifiers.length).toEqual(1);
expect(orderPayload.identifiers[0].value).toEqual('example.com');
// Test 2: Regular domain with wildcard option (but no DNS-01 handler)
await smartAcme.getCertificateForDomain('example.com', { includeWildcard: true });
expect(orderPayload.identifiers.length).toEqual(1);
expect(orderPayload.identifiers[0].value).toEqual('example.com');
// Create instance with DNS-01
const smartAcmeDns = new SmartAcme({
accountEmail: 'test@example.com',
certManager: new certmanagers.MemoryCertManager(),
environment: 'integration',
challengeHandlers: [{
getSupportedTypes: () => ['dns-01'],
prepare: async () => {},
cleanup: async () => {},
checkWetherDomainIsSupported: async () => true,
}],
});
// Test 3: Regular domain with wildcard option and DNS-01 handler
await smartAcmeDns.getCertificateForDomain('example.com', { includeWildcard: true });
expect(orderPayload.identifiers.length).toEqual(2);
expect(orderPayload.identifiers[0].value).toEqual('example.com');
expect(orderPayload.identifiers[1].value).toEqual('*.example.com');
// Test 4: Direct wildcard request
await smartAcmeDns.getCertificateForDomain('*.example.com');
expect(orderPayload.identifiers.length).toEqual(1);
expect(orderPayload.identifiers[0].value).toEqual('*.example.com');
} finally {
// Restore original method
SmartAcme.prototype.getCertificateForDomain = origGetCert;
}
});
export default tap.start();

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartacme',
version: '7.3.4',
version: '8.0.0',
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
}

View File

@ -221,8 +221,12 @@ export class SmartAcme {
* * retrieve it from the databse and return it
*
* @param domainArg
* @param options Optional configuration for certificate generation
*/
public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> {
public async getCertificateForDomain(
domainArg: string,
options?: { includeWildcard?: boolean }
): Promise<SmartacmeCert> {
// Determine if this is a wildcard request (e.g., '*.example.com').
const isWildcardRequest = domainArg.startsWith('*.');
// Determine the base domain for certificate retrieval/issuance.
@ -259,12 +263,32 @@ export class SmartAcme {
// lets make sure others get the same interest
const currentDomainInterst = await this.interestMap.addInterest(certDomainName);
// Build identifiers array based on request
const identifiers = [];
if (isWildcardRequest) {
// If requesting a wildcard directly, only add the wildcard
identifiers.push({ type: 'dns', value: `*.${certDomainName}` });
} else {
// Add the regular domain
identifiers.push({ type: 'dns', value: certDomainName });
// Only add wildcard if explicitly requested
if (options?.includeWildcard) {
const hasDnsHandler = this.challengeHandlers.some((h) =>
h.getSupportedTypes().includes('dns-01'),
);
if (!hasDnsHandler) {
this.logger.log('warn', 'Wildcard certificate requested but no DNS-01 handler available. Skipping wildcard.');
} else {
identifiers.push({ type: 'dns', value: `*.${certDomainName}` });
}
}
}
/* Place new order with retry */
const order = await this.retry(() => this.client.createOrder({
identifiers: [
{ type: 'dns', value: certDomainName },
{ type: 'dns', value: `*.${certDomainName}` },
],
identifiers,
}), 'createOrder');
/* Get authorizations and select challenges */
@ -359,9 +383,25 @@ export class SmartAcme {
}
/* Finalize order */
const csrDomains = [];
let commonName: string;
if (isWildcardRequest) {
// For wildcard requests, use wildcard as common name
commonName = `*.${certDomainName}`;
csrDomains.push(certDomainName); // Add base domain as alt name
} else {
// For regular requests, use base domain as common name
commonName = certDomainName;
if (options?.includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) {
// If wildcard was successfully added, include it as alt name
csrDomains.push(`*.${certDomainName}`);
}
}
const [key, csr] = await plugins.acme.forge.createCsr({
commonName: `*.${certDomainName}`,
altNames: [certDomainName],
commonName,
altNames: csrDomains,
});
await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');