feat(handlers): Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot
This commit is contained in:
parent
9f66a0487f
commit
fc420eb615
@ -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
|
||||
|
||||
|
25
readme.md
25
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<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
|
||||
|
||||
|
44
readme.plan.md
Normal file
44
readme.plan.md
Normal 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.
|
58
test/test.handlers-http01-memory.ts
Normal file
58
test/test.handlers-http01-memory.ts
Normal 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();
|
@ -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}`;
|
||||
|
@ -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.'
|
||||
}
|
||||
|
@ -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 <webroot>/.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;
|
||||
}
|
||||
|
||||
|
67
ts/handlers/Http01MemoryHandler.ts
Normal file
67
ts/handlers/Http01MemoryHandler.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export type { IChallengeHandler } from './IChallengeHandler.js';
|
||||
// Removed legacy handler adapter
|
||||
export { Dns01Handler } from './Dns01Handler.js';
|
||||
export { Http01Handler } from './Http01Handler.js';
|
||||
export { Http01Webroot } from './Http01Handler.js';
|
||||
export { Http01MemoryHandler } from './Http01MemoryHandler.js';
|
Loading…
x
Reference in New Issue
Block a user