diff --git a/changelog.md b/changelog.md index e9e2fe3..8e5a04e 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index 9cd5c42..eb71da6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/readme.hints.md b/readme.hints.md index f63e1f0..6ad6704 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -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'. \ No newline at end of file + - 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. \ No newline at end of file diff --git a/readme.md b/readme.md index 2948761..2bd2247 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/test/test.http01-only.ts b/test/test.http01-only.ts new file mode 100644 index 0000000..3f54489 --- /dev/null +++ b/test/test.http01-only.ts @@ -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) => 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) => 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) => 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(); \ No newline at end of file diff --git a/test/test.wildcard-options.ts b/test/test.wildcard-options.ts new file mode 100644 index 0000000..d6f0fe5 --- /dev/null +++ b/test/test.wildcard-options.ts @@ -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(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 0647417..986a719 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index 62dd5c6..1b91c7d 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -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 { + public async getCertificateForDomain( + domainArg: string, + options?: { includeWildcard?: boolean } + ): Promise { // 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');