diff --git a/changelog.md b/changelog.md index a2c847f..b4f36d0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2025-04-30 - 6.2.0 - feat(handlers) +Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot + +- Renamed Http01Handler to Http01Webroot in both implementation and documentation +- Introduced Http01MemoryHandler for diskless HTTP-01 challenges +- Updated tests and README examples to reflect handler name changes and new feature + ## 2025-04-30 - 6.1.3 - fix(Dns01Handler) Update dependency versions and refine Dns01Handler implementation diff --git a/readme.md b/readme.md index 21ed160..c511300 100644 --- a/readme.md +++ b/readme.md @@ -61,7 +61,7 @@ const smartAcmeInstance = new SmartAcme({ retryOptions: {}, // optional retry/backoff settings challengeHandlers: [ new Dns01Handler(cfAccount), - // you can add more handlers, e.g. Http01Handler + // you can add more handlers, e.g. Http01Webroot ], challengePriority: ['dns-01'], // optional ordering of challenge types }); @@ -143,7 +143,7 @@ async function main() { ## Built-in Challenge Handlers - This module includes two out-of-the-box ACME challenge handlers: + This module includes three out-of-the-box ACME challenge handlers: - **Dns01Handler** - Uses a Cloudflare account (from `@apiclient.xyz/cloudflare`) and Smartdns client to set and remove DNS TXT records, then wait for propagation. @@ -158,18 +158,31 @@ async function main() { const dnsHandler = new Dns01Handler(cfAccount); ``` - - **Http01Handler** + - **Http01Webroot** - Writes ACME HTTP-01 challenge files under a file-system webroot (`/.well-known/acme-challenge/`), and removes them on cleanup. - Import path: ```typescript - import { Http01Handler } from '@push.rocks/smartacme/ts/handlers/Http01Handler.js'; + import { Http01Webroot } from '@push.rocks/smartacme/ts/handlers/Http01Handler.js'; ``` - Example: ```typescript - const httpHandler = new Http01Handler({ webroot: '/var/www/html' }); + const httpHandler = new Http01Webroot({ webroot: '/var/www/html' }); ``` - Both handlers implement the `IChallengeHandler` interface and can be combined in the `challengeHandlers` array. + - **Http01MemoryHandler** + - In-memory HTTP-01 challenge handler that stores and serves ACME tokens without disk I/O. + - Import path: + ```typescript + import { Http01MemoryHandler } from '@push.rocks/smartacme/ts/handlers/Http01MemoryHandler.js'; + ``` + - Example (Express integration): + ```typescript + import { Http01MemoryHandler } from '@push.rocks/smartacme/ts/handlers/Http01MemoryHandler.js'; + const memoryHandler = new Http01MemoryHandler(); + app.use((req, res, next) => memoryHandler.handleRequest(req, res, next)); + ``` + + All handlers implement the `IChallengeHandler` interface and can be combined in the `challengeHandlers` array. ## Creating Custom Handlers diff --git a/readme.plan.md b/readme.plan.md new file mode 100644 index 0000000..767554f --- /dev/null +++ b/readme.plan.md @@ -0,0 +1,44 @@ +# Plan: Diskless HTTP-01 Handler and Renaming Existing Handler + +This plan outlines steps to rename the existing filesystem-based HTTP-01 handler to `Http01Webroot` +and introduce a new diskless (in-memory) HTTP-01 handler for integration with arbitrary HTTP servers +(e.g., Express). + +## 1. Rename existing handler to Http01Webroot +- In `ts/handlers/Http01Handler.ts`: + - Rename `Http01HandlerOptions` to `Http01WebrootOptions`. + - Rename class `Http01Handler` to `Http01Webroot`. + - Remove the legacy alias; rename the handler directly. + - In `ts/handlers/index.ts`: + - Export `Http01Webroot` under its new name. + - Remove any `Http01Handler` export. + - Update existing tests (e.g., `test.handlers-http01.ts`) to import `Http01Webroot` instead of `Http01Handler`. + +## 2. Add new diskless (in-memory) HTTP-01 handler +- Create `ts/handlers/Http01MemoryHandler.ts`: + - Implement `IChallengeHandler<{ token: string; keyAuthorization: string; webPath: string }>`, storing challenges in a private `Map`. + - `prepare()`: add token→keyAuthorization mapping. + - `verify()`: no-op. + - `cleanup()`: remove mapping. + - Add `handleRequest(req, res, next?)` method: + - Parse `/.well-known/acme-challenge/:token` from `req.url`. + - If token exists, respond with the key authorization and status 200. + - If missing and `next` provided, call `next()`, otherwise respond 404. +- Export `Http01MemoryHandler` in `ts/handlers/index.ts`. + +## 3. Write tests for Http01MemoryHandler +- Create `test/test.handlers-http01-memory.ts`: + - Use `tap` and `expect` to: + 1. `prepare()` a challenge. + 2. Invoke `handleRequest()` with a fake `req`/`res` to confirm 200 and correct body. + 3. `cleanup()` the challenge. + 4. Confirm `handleRequest()` now yields 404. + +## 4. Update documentation +- Add examples in `readme.md` showing how to use both `Http01Webroot` and the new `Http01MemoryHandler`: + - Sample code for Express integration using `handleRequest`. + +## 5. Build and test +- Run `pnpm build` and `pnpm test`, ensuring existing tests are updated for `Http01Webroot` and new tests pass. + +Please review and let me know if this plan makes sense before proceeding with implementation. \ No newline at end of file diff --git a/test/test.handlers-http01-memory.ts b/test/test.handlers-http01-memory.ts new file mode 100644 index 0000000..94885d0 --- /dev/null +++ b/test/test.handlers-http01-memory.ts @@ -0,0 +1,58 @@ +import { tap, expect } from '@push.rocks/tapbundle'; +import { Http01MemoryHandler } from '../ts/handlers/Http01MemoryHandler.js'; + +tap.test('Http01MemoryHandler serves in-memory challenges and cleans up', async () => { + const handler = new Http01MemoryHandler(); + const token = 'testtoken'; + const keyAuth = 'keyAuthValue'; + const webPath = `/.well-known/acme-challenge/${token}`; + const challenge = { type: 'http-01', token, keyAuthorization: keyAuth, webPath }; + + // Prepare challenge (store in memory) + await handler.prepare(challenge); + + // Serve existing challenge without next() + const req1: any = { url: webPath }; + const res1: any = { + statusCode: 0, + headers: {} as Record, + body: '', + setHeader(name: string, value: string) { this.headers[name] = value; }, + end(body?: string) { this.body = body || ''; }, + }; + handler.handleRequest(req1, res1); + expect(res1.statusCode).toEqual(200); + expect(res1.body).toEqual(keyAuth); + expect(res1.headers['content-type']).toEqual('text/plain'); + + // Cleanup challenge (remove from memory) + await handler.cleanup(challenge); + + // Serve after cleanup without next() should give 404 + const req2: any = { url: webPath }; + const res2: any = { + statusCode: 0, + headers: {} as Record, + body: '', + setHeader(name: string, value: string) { this.headers[name] = value; }, + end(body?: string) { this.body = body || ''; }, + }; + handler.handleRequest(req2, res2); + expect(res2.statusCode).toEqual(404); + + // Serve after cleanup with next() should call next + const req3: any = { url: webPath }; + let nextCalled = false; + const next = () => { nextCalled = true; }; + const res3: any = { + statusCode: 0, + headers: {} as Record, + body: '', + setHeader(name: string, value: string) { this.headers[name] = value; }, + end(body?: string) { this.body = body || ''; }, + }; + handler.handleRequest(req3, res3, next); + expect(nextCalled).toEqual(true); +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.handlers-http01.ts b/test/test.handlers-http01.ts index 997c63b..3ffed96 100644 --- a/test/test.handlers-http01.ts +++ b/test/test.handlers-http01.ts @@ -1,13 +1,13 @@ import { tap, expect } from '@push.rocks/tapbundle'; -import { Http01Handler } from '../ts/handlers/Http01Handler.js'; +import { Http01Webroot } from '../ts/handlers/Http01Handler.js'; import { promises as fs } from 'fs'; import * as path from 'path'; import os from 'os'; -tap.test('Http01Handler writes challenge file and removes it on cleanup', async () => { +tap.test('Http01Webroot writes challenge file and removes it on cleanup', async () => { // create temporary webroot directory const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'http01-')); - const handler = new Http01Handler({ webroot: tmpDir }); + const handler = new Http01Webroot({ webroot: tmpDir }); const token = 'testtoken'; const keyAuth = 'keyAuthValue'; const webPath = `/.well-known/acme-challenge/${token}`; diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 36552bd..f81a2c1 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: '6.1.3', + version: '6.2.0', description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.' } diff --git a/ts/handlers/Http01Handler.ts b/ts/handlers/Http01Handler.ts index 8d3240b..fad4ff0 100644 --- a/ts/handlers/Http01Handler.ts +++ b/ts/handlers/Http01Handler.ts @@ -6,14 +6,14 @@ import type { IChallengeHandler } from './IChallengeHandler.js'; * HTTP-01 ACME challenge handler using file-system webroot. * Writes and removes the challenge file under /.well-known/acme-challenge/. */ -export interface Http01HandlerOptions { +export interface Http01WebrootOptions { /** * Directory that serves HTTP requests for /.well-known/acme-challenge */ webroot: string; } -export class Http01Handler implements IChallengeHandler<{ +export class Http01Webroot implements IChallengeHandler<{ type: string; token: string; keyAuthorization: string; @@ -21,7 +21,7 @@ export class Http01Handler implements IChallengeHandler<{ }> { private webroot: string; - constructor(options: Http01HandlerOptions) { + constructor(options: Http01WebrootOptions) { this.webroot = options.webroot; } diff --git a/ts/handlers/Http01MemoryHandler.ts b/ts/handlers/Http01MemoryHandler.ts new file mode 100644 index 0000000..bae2ba8 --- /dev/null +++ b/ts/handlers/Http01MemoryHandler.ts @@ -0,0 +1,67 @@ +import type { IChallengeHandler } from './IChallengeHandler.js'; + +/** + * HTTP-01 ACME challenge handler using in-memory storage. + * Stores challenge tokens and key authorizations in memory + * and serves them via handleRequest for arbitrary HTTP servers. + */ +export interface Http01MemoryHandlerChallenge { + type: string; + token: string; + keyAuthorization: string; + webPath: string; +} + +export class Http01MemoryHandler implements IChallengeHandler { + private store: Map = new Map(); + + public getSupportedTypes(): string[] { + return ['http-01']; + } + + public async prepare(ch: Http01MemoryHandlerChallenge): Promise { + this.store.set(ch.token, ch.keyAuthorization); + } + + public async verify(_ch: Http01MemoryHandlerChallenge): Promise { + // No-op + return; + } + + public async cleanup(ch: Http01MemoryHandlerChallenge): Promise { + this.store.delete(ch.token); + } + + /** + * HTTP request handler for serving ACME HTTP-01 challenges. + * @param req HTTP request object (should have url property) + * @param res HTTP response object + * @param next Optional next() callback for Express-style fallthrough + */ + public handleRequest(req: any, res: any, next?: () => void): void { + const url = req.url || ''; + const prefix = '/.well-known/acme-challenge/'; + if (!url.startsWith(prefix)) { + if (next) { + return next(); + } + res.statusCode = 404; + return res.end(); + } + const token = url.slice(prefix.length); + const keyAuth = this.store.get(token); + if (keyAuth !== undefined) { + if (typeof res.status === 'function' && typeof res.send === 'function') { + return res.status(200).send(keyAuth); + } + res.statusCode = 200; + res.setHeader('content-type', 'text/plain'); + return res.end(keyAuth); + } + if (next) { + return next(); + } + res.statusCode = 404; + return res.end(); + } +} \ No newline at end of file diff --git a/ts/handlers/index.ts b/ts/handlers/index.ts index 69073e3..3085337 100644 --- a/ts/handlers/index.ts +++ b/ts/handlers/index.ts @@ -1,4 +1,5 @@ export type { IChallengeHandler } from './IChallengeHandler.js'; // Removed legacy handler adapter export { Dns01Handler } from './Dns01Handler.js'; -export { Http01Handler } from './Http01Handler.js'; \ No newline at end of file +export { Http01Webroot } from './Http01Handler.js'; +export { Http01MemoryHandler } from './Http01MemoryHandler.js'; \ No newline at end of file