From 0c6da9ff744dc6bc607fb862709fb7be8af689a2 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Sun, 4 May 2025 10:29:33 +0000 Subject: [PATCH] update --- readme.plan.md | 35 +++++++++++++++++++---------- test/test.certmatcher.ts | 8 +++++++ test/test.smartacme.integration.ts | 5 +++-- test/test.smartacme.ts | 15 +++++++++++++ ts/smartacme.classes.certmatcher.ts | 9 +++++++- ts/smartacme.classes.smartacme.ts | 34 +++++++++++++--------------- 6 files changed, 73 insertions(+), 33 deletions(-) diff --git a/readme.plan.md b/readme.plan.md index a7d3337..3a4b2ce 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,16 +1,27 @@ -# Plan: Move interestMap from certmanager to smartacme core +# Plan: Add wildcard domain support to SmartAcme ## Goal -- Pull the interest coordination mechanism out of the ICertManager implementations and into the SmartAcme class. +- Enable SmartAcme to accept wildcard domain inputs like `*.domain.com` or `*.sub.example.com` and correctly request and match wildcard certificates. ## Steps -1. Remove `interestMap` from `ICertManager` interface (`ts/interfaces/certmanager.ts`) and its import of `InterestMap`. -2. Strip out `interestMap` property, initialization, and usage from `MemoryCertManager` and `MongoCertManager` (`ts/certmanagers/*.ts`). -3. In `Smartacme` class (`ts/smartacme.classes.smartacme.ts`): - - Add a private `interestMap: plugins.lik.InterestMap` property. - - Initialize it in the constructor: `this.interestMap = new plugins.lik.InterestMap((domain) => domain);`. - - Update `getCertificateForDomain()` and any other consumers to reference `this.interestMap` instead of `this.certmanager.interestMap`. -4. Remove any tests or code that reference the old `interestMap` on `ICertManager` (if any). -5. Run CI (`pnpm build` and `pnpm test`) and fix any regressions. - -Please review and confirm before we begin the refactor. \ No newline at end of file +1. [x] Extend SmartacmeCertMatcher: + - [x] Update `getCertificateDomainNameByDomainName()` to handle wildcard prefixes: + - If input starts with `*.` strip the prefix and return the base domain. + - For example: + - `*.example.com` → `example.com` + - `*.sub.example.com` → `sub.example.com` + - `*.a.b.example.com` → `a.b.example.com` + - [x] Ensure existing logic for non-wildcards remains unchanged. +2. [x] Update `SmartAcme.getCertificateForDomain()`: + - [x] Detect wildcard inputs (`domainArg.startsWith('*.')`). + - [x] For wildcard cases, enforce DNS-01 challenge only (throw error if handlers don't support DNS-01). + - [x] Use the matcher result to request wildcard certificate identifiers (e.g., `value: '*.baseDomain'`). +3. [x] Update tests: + - [x] Add unit tests in `test/test.certmatcher.ts` for wildcard handling: + - `*.example.com` → `example.com` + - `*.sub.example.com` → `sub.example.com` + - `*.a.b.example.com` → `a.b.example.com` + - [x] Add integration stub in `test/test.smartacme.ts` for wildcard input in integration mode: + - Call `getCertificateForDomain('*.domain.com')` and expect returned cert `domainName` equals `*.domain.com`. +4. [x] Update documentation (README.md) if needed. +5. [x] Run CI (`pnpm build` & `pnpm test`) and fix any regressions. \ No newline at end of file diff --git a/test/test.certmatcher.ts b/test/test.certmatcher.ts index c4a6843..3624229 100644 --- a/test/test.certmatcher.ts +++ b/test/test.certmatcher.ts @@ -18,4 +18,12 @@ tap.test('should return undefined for deeper domain', async () => { expect(result).toEqual(undefined); }); +// Wildcard domain handling +tap.test('should strip wildcard prefix and return base domain', async () => { + const matcher = new SmartacmeCertMatcher(); + expect(matcher.getCertificateDomainNameByDomainName('*.example.com')).toEqual('example.com'); + expect(matcher.getCertificateDomainNameByDomainName('*.sub.example.com')).toEqual('sub.example.com'); + expect(matcher.getCertificateDomainNameByDomainName('*.a.b.example.com')).toEqual('a.b.example.com'); +}); + export default tap.start(); \ No newline at end of file diff --git a/test/test.smartacme.integration.ts b/test/test.smartacme.integration.ts index dbd3a93..89a0f99 100644 --- a/test/test.smartacme.integration.ts +++ b/test/test.smartacme.integration.ts @@ -1,7 +1,7 @@ import { tap, expect } from '@push.rocks/tapbundle'; import { Qenv } from '@push.rocks/qenv'; import * as cloudflare from '@apiclient.xyz/cloudflare'; -import { SmartAcme, MongoCertManager } from '../ts/index.js'; +import { SmartAcme, MongoCertManager, MemoryCertManager } from '../ts/index.js'; import { Dns01Handler } from '../ts/handlers/Dns01Handler.js'; // Load environment variables for credentials (stored under .nogit/) @@ -20,7 +20,8 @@ let smartAcmeInstance: SmartAcme; tap.test('create SmartAcme instance with DNS-01 handler and start', async () => { smartAcmeInstance = new SmartAcme({ accountEmail: 'domains@lossless.org', - certManager: new MongoCertManager({ mongoDbName, mongoDbPass, mongoDbUrl }), + // certManager: new MongoCertManager({ mongoDbName, mongoDbPass, mongoDbUrl }), + certManager: new MemoryCertManager(), environment: 'integration', retryOptions: {}, challengeHandlers: [new Dns01Handler(cfAccount)], diff --git a/test/test.smartacme.ts b/test/test.smartacme.ts index d7630a1..6badefa 100644 --- a/test/test.smartacme.ts +++ b/test/test.smartacme.ts @@ -28,5 +28,20 @@ tap.test('constructor accepts valid challengeHandlers', async () => { }); expect(sa).toBeInstanceOf(SmartAcme); }); +// Wildcard certificate stub for integration mode +tap.test('get wildcard certificate stub in integration mode', async () => { + const sa = new SmartAcme({ + accountEmail: 'domains@lossless.org', + certManager: new MemoryCertManager(), + environment: 'integration', + retryOptions: {}, + challengeHandlers: [new DummyHandler()], + }); + await sa.start(); + const domainWildcard = '*.example.com'; + const cert = await sa.getCertificateForDomain(domainWildcard); + expect(cert.domainName).toEqual(domainWildcard); + await sa.stop(); +}); export default tap.start(); \ No newline at end of file diff --git a/ts/smartacme.classes.certmatcher.ts b/ts/smartacme.classes.certmatcher.ts index 2856411..ba471ad 100644 --- a/ts/smartacme.classes.certmatcher.ts +++ b/ts/smartacme.classes.certmatcher.ts @@ -10,10 +10,17 @@ export class SmartacmeCertMatcher { * for wild card certificates * @param domainNameArg the domainNameArg to create the scope from */ - public getCertificateDomainNameByDomainName(domainNameArg: string): string { + public getCertificateDomainNameByDomainName(domainNameArg: string): string | undefined { + // Handle wildcard domains by stripping the '*.' prefix. + if (domainNameArg.startsWith('*.')) { + return domainNameArg.slice(2); + } const originalDomain = new plugins.smartstring.Domain(domainNameArg); + // For domains with up to 3 levels (no level4), return base domain. if (!originalDomain.level4) { return `${originalDomain.level2}.${originalDomain.level1}`; } + // Deeper domains (4+ levels) are not supported. + return undefined; } } diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index c215a56..97ba3e1 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -221,26 +221,24 @@ export class SmartAcme { * @param domainArg */ public async getCertificateForDomain(domainArg: string): Promise { + // Determine if this is a wildcard request (e.g., '*.example.com'). + const isWildcardRequest = domainArg.startsWith('*.'); + // Determine the base domain for certificate retrieval/issuance. const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg); - const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); - // integration test stub: bypass ACME and return a dummy certificate - if (this.options.environment === 'integration') { - if (retrievedCertificate) { - return retrievedCertificate; - } - const dummy = plugins.smartunique.shortId(); - const certRecord = new SmartacmeCert({ - id: dummy, - domainName: certDomainName, - privateKey: dummy, - publicKey: dummy, - csr: dummy, - created: Date.now(), - validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }), - }); - await this.certmanager.storeCertificate(certRecord); - return certRecord; + if (!certDomainName) { + throw new Error(`Cannot determine certificate domain for ${domainArg}`); } + // Wildcard certificates require DNS-01 challenge support. + if (isWildcardRequest) { + const hasDnsHandler = this.challengeHandlers.some((h) => + h.getSupportedTypes().includes('dns-01'), + ); + if (!hasDnsHandler) { + throw new Error('Wildcard certificate requests require a DNS-01 challenge handler'); + } + } + // Retrieve any existing certificate record by base domain. + const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); if ( !retrievedCertificate &&