feat(handlers): Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot

This commit is contained in:
Philipp Kunz 2025-04-30 14:55:03 +00:00
parent 9f66a0487f
commit fc420eb615
9 changed files with 204 additions and 14 deletions

View File

@ -1,5 +1,12 @@
# Changelog # 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) ## 2025-04-30 - 6.1.3 - fix(Dns01Handler)
Update dependency versions and refine Dns01Handler implementation Update dependency versions and refine Dns01Handler implementation

View File

@ -61,7 +61,7 @@ const smartAcmeInstance = new SmartAcme({
retryOptions: {}, // optional retry/backoff settings retryOptions: {}, // optional retry/backoff settings
challengeHandlers: [ challengeHandlers: [
new Dns01Handler(cfAccount), 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 challengePriority: ['dns-01'], // optional ordering of challenge types
}); });
@ -143,7 +143,7 @@ async function main() {
## Built-in Challenge Handlers ## 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** - **Dns01Handler**
- Uses a Cloudflare account (from `@apiclient.xyz/cloudflare`) and Smartdns client to set and remove DNS TXT records, then wait for propagation. - 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); 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. - Writes ACME HTTP-01 challenge files under a file-system webroot (`/.well-known/acme-challenge/`), and removes them on cleanup.
- Import path: - Import path:
```typescript ```typescript
import { Http01Handler } from '@push.rocks/smartacme/ts/handlers/Http01Handler.js'; import { Http01Webroot } from '@push.rocks/smartacme/ts/handlers/Http01Handler.js';
``` ```
- Example: - Example:
```typescript ```typescript
const httpHandler = new Http01Handler({ webroot: '/var/www/html' }); const httpHandler = new Http01Webroot({ webroot: '/var/www/html' });
``` ```
Both handlers implement the `IChallengeHandler<T>` 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<T>` interface and can be combined in the `challengeHandlers` array.
## Creating Custom Handlers ## Creating Custom Handlers

44
readme.plan.md Normal file
View File

@ -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<string, string>`.
- `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.

View File

@ -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<string, string>,
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<string, string>,
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<string, string>,
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();

View File

@ -1,13 +1,13 @@
import { tap, expect } from '@push.rocks/tapbundle'; 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 { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import os from 'os'; 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 // create temporary webroot directory
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'http01-')); 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 token = 'testtoken';
const keyAuth = 'keyAuthValue'; const keyAuth = 'keyAuthValue';
const webPath = `/.well-known/acme-challenge/${token}`; const webPath = `/.well-known/acme-challenge/${token}`;

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartacme', 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.' description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
} }

View File

@ -6,14 +6,14 @@ import type { IChallengeHandler } from './IChallengeHandler.js';
* HTTP-01 ACME challenge handler using file-system webroot. * HTTP-01 ACME challenge handler using file-system webroot.
* Writes and removes the challenge file under <webroot>/.well-known/acme-challenge/. * Writes and removes the challenge file under <webroot>/.well-known/acme-challenge/.
*/ */
export interface Http01HandlerOptions { export interface Http01WebrootOptions {
/** /**
* Directory that serves HTTP requests for /.well-known/acme-challenge * Directory that serves HTTP requests for /.well-known/acme-challenge
*/ */
webroot: string; webroot: string;
} }
export class Http01Handler implements IChallengeHandler<{ export class Http01Webroot implements IChallengeHandler<{
type: string; type: string;
token: string; token: string;
keyAuthorization: string; keyAuthorization: string;
@ -21,7 +21,7 @@ export class Http01Handler implements IChallengeHandler<{
}> { }> {
private webroot: string; private webroot: string;
constructor(options: Http01HandlerOptions) { constructor(options: Http01WebrootOptions) {
this.webroot = options.webroot; this.webroot = options.webroot;
} }

View File

@ -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<Http01MemoryHandlerChallenge> {
private store: Map<string, string> = new Map();
public getSupportedTypes(): string[] {
return ['http-01'];
}
public async prepare(ch: Http01MemoryHandlerChallenge): Promise<void> {
this.store.set(ch.token, ch.keyAuthorization);
}
public async verify(_ch: Http01MemoryHandlerChallenge): Promise<void> {
// No-op
return;
}
public async cleanup(ch: Http01MemoryHandlerChallenge): Promise<void> {
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();
}
}

View File

@ -1,4 +1,5 @@
export type { IChallengeHandler } from './IChallengeHandler.js'; export type { IChallengeHandler } from './IChallengeHandler.js';
// Removed legacy handler adapter // Removed legacy handler adapter
export { Dns01Handler } from './Dns01Handler.js'; export { Dns01Handler } from './Dns01Handler.js';
export { Http01Handler } from './Http01Handler.js'; export { Http01Webroot } from './Http01Handler.js';
export { Http01MemoryHandler } from './Http01MemoryHandler.js';