Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
1698abef16 | |||
a0f6a14b63 | |||
876d876661 | |||
ae212c53d5 | |||
b9866c2ced | |||
c863c7295d | |||
b8bb4af184 | |||
6fedf0505e | |||
f814038a6a | |||
9dc8c1d8a3 | |||
758c6c6b5d | |||
6363ec4be6 |
49
changelog.md
49
changelog.md
@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-05-01 - 7.2.3 - fix(docs)
|
||||||
|
Improve certificate manager documentation with detailed examples and custom implementation guide
|
||||||
|
|
||||||
|
- Added usage examples for MemoryCertManager and MongoCertManager
|
||||||
|
- Provided a custom ICertManager implementation guide
|
||||||
|
- Enhanced overall documentation clarity for certificate storage configuration
|
||||||
|
|
||||||
|
## 2025-05-01 - 7.2.2 - fix(readme)
|
||||||
|
Update readme documentation: switch installation instructions to pnpm and clarify usage with MongoCertManager and updated SmartAcme options
|
||||||
|
|
||||||
|
- Replaced npm/yarn commands with pnpm commands for installation and testing.
|
||||||
|
- Added guidance to ensure the project is set up for TypeScript and ECMAScript Modules.
|
||||||
|
- Updated usage examples to include initialization of MongoCertManager instead of legacy mongoDescriptor.
|
||||||
|
- Revised challenge handlers examples to reference the current API signatures.
|
||||||
|
|
||||||
|
## 2025-05-01 - 7.2.1 - fix(smartacme)
|
||||||
|
Centralize interest map coordination and remove redundant interestMap from cert managers
|
||||||
|
|
||||||
|
- Removed interestMap property and related logic from MemoryCertManager and MongoCertManager
|
||||||
|
- Refactored SmartAcme to instantiate its own interestMap for coordinating certificate requests
|
||||||
|
- Updated getCertificateForDomain to use the new interestMap for checking and adding certificate interests
|
||||||
|
|
||||||
|
## 2025-05-01 - 7.2.0 - feat(core)
|
||||||
|
Refactor SmartAcme core to centralize interest coordination and update dependencies
|
||||||
|
|
||||||
|
- Moved interest coordination mechanism out of ICertManager implementations and into SmartAcme core
|
||||||
|
- Updated certificate managers (MemoryCertManager and MongoCertManager) to remove redundant interestMap handling
|
||||||
|
- Upgraded @push.rocks/tapbundle from 6.0.1 to 6.0.3 in package.json
|
||||||
|
- Revised readme.plan.md to reflect the new interest coordination approach
|
||||||
|
|
||||||
|
## 2025-04-30 - 7.1.0 - feat(certmanagers/integration)
|
||||||
|
Add optional wipe methods to certificate managers and update integration tests, plus bump tapbundle dependency
|
||||||
|
|
||||||
|
- Introduce wipe() in ICertManager to support integration testing by clearing stored certificates
|
||||||
|
- Implement wipe() in MemoryCertManager and MongoCertManager for resetting internal state
|
||||||
|
- Refactor SmartAcme constructor to consider wiping certificates in integration mode (commented out for now)
|
||||||
|
- Update integration test assertions and add console logging for domain certificate retrieval
|
||||||
|
- Upgrade @push.rocks/tapbundle from ^6.0.0 to ^6.0.1
|
||||||
|
|
||||||
|
## 2025-04-30 - 7.0.0 - BREAKING CHANGE(SmartAcme (Cert Management))
|
||||||
|
Refactor certificate management and challenge handling API to use a unified certManager interface, remove legacy storage, and update challenge workflows.
|
||||||
|
|
||||||
|
- Introduce ICertManager interface with MemoryCertManager and MongoCertManager implementations.
|
||||||
|
- Remove the legacy SmartacmeCertManager and update SmartAcme to require a certManager option instead of mongoDescriptor.
|
||||||
|
- Adjust certificate renewal logic to delete and store certificates through the new certManager API.
|
||||||
|
- Refine DNS-01 challenge handling by removing in-handler DNS propagation waiting and relying on external checks.
|
||||||
|
- Increase retry settings for robustness during challenge verification and certificate issuance.
|
||||||
|
- Update integration and unit tests to use the new certManager configuration.
|
||||||
|
|
||||||
## 2025-04-30 - 6.2.0 - feat(handlers)
|
## 2025-04-30 - 6.2.0 - feat(handlers)
|
||||||
Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot
|
Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartacme",
|
"name": "@push.rocks/smartacme",
|
||||||
"version": "6.2.0",
|
"version": "7.2.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"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.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@ -59,7 +59,7 @@
|
|||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^1.0.96",
|
"@git.zone/tstest": "^1.0.96",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/qenv": "^6.1.0",
|
||||||
"@push.rocks/tapbundle": "^6.0.0",
|
"@push.rocks/tapbundle": "^6.0.3",
|
||||||
"@types/node": "^22.15.3"
|
"@types/node": "^22.15.3"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -64,8 +64,8 @@ importers:
|
|||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
'@push.rocks/tapbundle':
|
'@push.rocks/tapbundle':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.3
|
||||||
version: 6.0.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
|
version: 6.0.3(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.15.3
|
specifier: ^22.15.3
|
||||||
version: 22.15.3
|
version: 22.15.3
|
||||||
@ -821,8 +821,8 @@ packages:
|
|||||||
'@push.rocks/smartexpect@1.6.1':
|
'@push.rocks/smartexpect@1.6.1':
|
||||||
resolution: {integrity: sha512-NFQXEPkGiMNxyvFwKyzDWe3ADYdf8KNvIcV7TGNZZT3uPQtk65te4Q+a1cWErjP/61yE9XdYiQA66QQp+TV9IQ==}
|
resolution: {integrity: sha512-NFQXEPkGiMNxyvFwKyzDWe3ADYdf8KNvIcV7TGNZZT3uPQtk65te4Q+a1cWErjP/61yE9XdYiQA66QQp+TV9IQ==}
|
||||||
|
|
||||||
'@push.rocks/smartexpect@2.2.2':
|
'@push.rocks/smartexpect@2.4.2':
|
||||||
resolution: {integrity: sha512-s2zJlLc6Wub7P/jgKSM51kW2UjslxQwx2BXoyJVO95OgiOwarde0AuxPR0lfRA/FvHdBfTmJf4upiWtcjYMB/Q==}
|
resolution: {integrity: sha512-L+aS1n5rWhf/yOh5R3zPgwycYtDr5FfrDWgasy6ShhN6Zbn/z/AOPbWcF/OpeTmx0XabWB2h5d4xBcCKLl47cQ==}
|
||||||
|
|
||||||
'@push.rocks/smartfeed@1.0.11':
|
'@push.rocks/smartfeed@1.0.11':
|
||||||
resolution: {integrity: sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==}
|
resolution: {integrity: sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==}
|
||||||
@ -959,8 +959,8 @@ packages:
|
|||||||
'@push.rocks/tapbundle@5.6.3':
|
'@push.rocks/tapbundle@5.6.3':
|
||||||
resolution: {integrity: sha512-hFzsf59rg1K70i45llj7PCyyCZp7JW19XRR+Q1gge1T0pBN8Wi53aYqP/2qtxdMiNVe2s3ESp6VJZv3sLOMYPQ==}
|
resolution: {integrity: sha512-hFzsf59rg1K70i45llj7PCyyCZp7JW19XRR+Q1gge1T0pBN8Wi53aYqP/2qtxdMiNVe2s3ESp6VJZv3sLOMYPQ==}
|
||||||
|
|
||||||
'@push.rocks/tapbundle@6.0.0':
|
'@push.rocks/tapbundle@6.0.3':
|
||||||
resolution: {integrity: sha512-ARIs189TysvI8EsPAC7LH6O0WbBYI9E7XxdihwmM6LRgLvzAbp1agfO6lOjpKrAYWKjT3KdlUEihilxOBrgTYQ==}
|
resolution: {integrity: sha512-SuP14V6TPdtd1y1CYTvwTKJdpHa7EzY55NfaaEMxW4oRKvHgJiOiPEiR/IrtL9tSiDMSfrx12waTMgZheYaBug==}
|
||||||
|
|
||||||
'@push.rocks/taskbuffer@3.1.7':
|
'@push.rocks/taskbuffer@3.1.7':
|
||||||
resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
|
resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
|
||||||
@ -5830,7 +5830,7 @@ snapshots:
|
|||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|
||||||
'@push.rocks/smartexpect@2.2.2':
|
'@push.rocks/smartexpect@2.4.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
@ -6240,7 +6240,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
'@push.rocks/tapbundle@6.0.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)':
|
'@push.rocks/tapbundle@6.0.3(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@open-wc/testing': 4.0.0
|
'@open-wc/testing': 4.0.0
|
||||||
'@push.rocks/consolecolor': 2.0.2
|
'@push.rocks/consolecolor': 2.0.2
|
||||||
@ -6248,7 +6248,7 @@ snapshots:
|
|||||||
'@push.rocks/smartcrypto': 2.0.4
|
'@push.rocks/smartcrypto': 2.0.4
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.12
|
||||||
'@push.rocks/smartexpect': 2.2.2
|
'@push.rocks/smartexpect': 2.4.2
|
||||||
'@push.rocks/smartfile': 11.2.0
|
'@push.rocks/smartfile': 11.2.0
|
||||||
'@push.rocks/smartjson': 5.0.20
|
'@push.rocks/smartjson': 5.0.20
|
||||||
'@push.rocks/smartmongo': 2.0.12(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
|
'@push.rocks/smartmongo': 2.0.12(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
|
||||||
|
173
readme.md
173
readme.md
@ -4,19 +4,26 @@ A TypeScript-based ACME client with an easy yet powerful interface for LetsEncry
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
To install `@push.rocks/smartacme`, you can use npm or yarn. Run one of the following commands in your project directory:
|
Using pnpm as the package manager:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @push.rocks/smartacme --save
|
pnpm add @push.rocks/smartacme
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
Ensure your project is set up to use TypeScript and ECMAScript Modules (ESM).
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Tests are written using `@push.rocks/tapbundle` and can be run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn add @push.rocks/smartacme
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure your project is set up to use TypeScript and supports ECMAScript Modules (ESM).
|
To run a specific test file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tsx test/<test-file>.ts
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -42,28 +49,31 @@ Ensure your project includes the necessary TypeScript configuration and dependen
|
|||||||
Start by importing the `SmartAcme` class and any built-in handlers you plan to use. For example, to use DNS-01 via Cloudflare:
|
Start by importing the `SmartAcme` class and any built-in handlers you plan to use. For example, to use DNS-01 via Cloudflare:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartAcme } from '@push.rocks/smartacme';
|
import { SmartAcme, MongoCertManager } from '@push.rocks/smartacme';
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js';
|
import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js';
|
||||||
|
|
||||||
// Create a Cloudflare account client with your API token
|
// Create a Cloudflare account client with your API token
|
||||||
const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
|
const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
|
||||||
|
|
||||||
// Instantiate SmartAcme with one or more ACME challenge handlers
|
// Initialize a certificate manager (e.g., MongoDB)
|
||||||
const smartAcmeInstance = new SmartAcme({
|
const certManager = new MongoCertManager({
|
||||||
accountEmail: 'youremail@example.com',
|
|
||||||
mongoDescriptor: {
|
|
||||||
mongoDbUrl: 'mongodb://yourmongoURL',
|
mongoDbUrl: 'mongodb://yourmongoURL',
|
||||||
mongoDbName: 'yourDbName',
|
mongoDbName: 'yourDbName',
|
||||||
mongoDbPass: 'yourDbPassword',
|
mongoDbPass: 'yourDbPassword',
|
||||||
},
|
});
|
||||||
|
|
||||||
|
// Instantiate SmartAcme with the certManager and challenge handlers
|
||||||
|
const smartAcmeInstance = new SmartAcme({
|
||||||
|
accountEmail: 'youremail@example.com',
|
||||||
|
certManager,
|
||||||
environment: 'integration', // 'production' to request real certificates
|
environment: 'integration', // 'production' to request real certificates
|
||||||
retryOptions: {}, // optional retry/backoff settings
|
retryOptions: {}, // optional retry/backoff settings
|
||||||
challengeHandlers: [
|
challengeHandlers: [ // pluggable ACME challenge handlers
|
||||||
new Dns01Handler(cfAccount),
|
new Dns01Handler(cfAccount),
|
||||||
// you can add more handlers, e.g. Http01Webroot
|
// add more handlers as needed (e.g., Http01Webroot, Http01MemoryHandler)
|
||||||
],
|
],
|
||||||
challengePriority: ['dns-01'], // optional ordering of challenge types
|
challengePriority: ['dns-01'], // optional challenge ordering
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -91,14 +101,64 @@ SmartAcme uses pluggable ACME challenge handlers (see built-in handlers below) t
|
|||||||
|
|
||||||
### Managing Certificates
|
### Managing Certificates
|
||||||
|
|
||||||
The library automatically handles fetching, renewing, and storing your certificates in a MongoDB database specified in your configuration. Ensure your MongoDB instance is accessible and properly configured for use with SmartAcme.
|
The library automatically handles fetching, renewing, and storing your certificates in a MongoDB database specified via a certificate manager. Ensure your MongoDB instance is accessible and properly configured for use with SmartAcme.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const mongoDescriptor = {
|
import { MongoCertManager } from '@push.rocks/smartacme';
|
||||||
|
|
||||||
|
const certManager = new MongoCertManager({
|
||||||
mongoDbUrl: 'mongodb://yourmongoURL',
|
mongoDbUrl: 'mongodb://yourmongoURL',
|
||||||
mongoDbName: 'yourDbName',
|
mongoDbName: 'yourDbName',
|
||||||
mongoDbPass: 'yourDbPassword',
|
mongoDbPass: 'yourDbPassword',
|
||||||
};
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
SmartAcme uses the `ICertManager` interface for certificate storage. Two built-in implementations are available:
|
||||||
|
|
||||||
|
- **MemoryCertManager**
|
||||||
|
- In-memory storage, suitable for testing or ephemeral use.
|
||||||
|
- Import example:
|
||||||
|
```typescript
|
||||||
|
import { MemoryCertManager } from '@push.rocks/smartacme';
|
||||||
|
const certManager = new MemoryCertManager();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **MongoCertManager**
|
||||||
|
- Persistent storage in MongoDB (collection: `SmartacmeCert`).
|
||||||
|
- Import example:
|
||||||
|
```typescript
|
||||||
|
import { MongoCertManager } from '@push.rocks/smartacme';
|
||||||
|
const certManager = new MongoCertManager({
|
||||||
|
mongoDbUrl: 'mongodb://yourmongoURL',
|
||||||
|
mongoDbName: 'yourDbName',
|
||||||
|
mongoDbPass: 'yourDbPassword',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Custom Certificate Managers
|
||||||
|
|
||||||
|
To implement a custom certificate manager, implement the `ICertManager` interface and pass it to `SmartAcme`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { ICertManager, Cert as SmartacmeCert } from '@push.rocks/smartacme';
|
||||||
|
import { SmartAcme } from '@push.rocks/smartacme';
|
||||||
|
|
||||||
|
class MyCustomCertManager implements ICertManager {
|
||||||
|
async init(): Promise<void> { /* setup storage */ }
|
||||||
|
async get(domainName: string): Promise<SmartacmeCert | null> { /* lookup cert */ }
|
||||||
|
async put(cert: SmartacmeCert): Promise<SmartacmeCert> { /* store cert */ }
|
||||||
|
async delete(domainName: string): Promise<void> { /* remove cert */ }
|
||||||
|
async close?(): Promise<void> { /* optional cleanup */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use your custom manager:
|
||||||
|
const customManager = new MyCustomCertManager();
|
||||||
|
const smartAcme = new SmartAcme({
|
||||||
|
accountEmail: 'youremail@example.com',
|
||||||
|
certManager: customManager,
|
||||||
|
environment: 'integration',
|
||||||
|
challengeHandlers: [], // add your handlers
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environmental Considerations
|
### Environmental Considerations
|
||||||
@ -110,23 +170,27 @@ When creating an instance of `SmartAcme`, you can specify an `environment` optio
|
|||||||
Below is a complete example demonstrating how to use `@push.rocks/smartacme` to obtain and manage an ACME certificate with Let's Encrypt using a DNS-01 handler:
|
Below is a complete example demonstrating how to use `@push.rocks/smartacme` to obtain and manage an ACME certificate with Let's Encrypt using a DNS-01 handler:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartAcme } from '@push.rocks/smartacme';
|
import { SmartAcme, MongoCertManager } from '@push.rocks/smartacme';
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
|
import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js';
|
||||||
|
|
||||||
const qenv = new Qenv('./', './.nogit/');
|
const qenv = new Qenv('./', './.nogit/');
|
||||||
const cloudflareAccount = new cloudflare.CloudflareAccount(qenv.getEnvVarOnDemand('CF_TOKEN'));
|
const cloudflareAccount = new cloudflare.CloudflareAccount(qenv.getEnvVarOnDemand('CF_TOKEN'));
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const smartAcmeInstance = new SmartAcme({
|
// Initialize MongoDB certificate manager
|
||||||
accountEmail: 'youremail@example.com',
|
const certManager = new MongoCertManager({
|
||||||
mongoDescriptor: {
|
|
||||||
mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
|
mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
|
||||||
mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
|
mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
|
||||||
mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
|
mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
|
||||||
},
|
});
|
||||||
|
|
||||||
|
const smartAcmeInstance = new SmartAcme({
|
||||||
|
accountEmail: 'youremail@example.com',
|
||||||
|
certManager,
|
||||||
environment: 'integration',
|
environment: 'integration',
|
||||||
challengeHandlers: [ new Dns01Handler(cloudflareAccount) ],
|
challengeHandlers: [new Dns01Handler(cloudflareAccount)],
|
||||||
});
|
});
|
||||||
|
|
||||||
await smartAcmeInstance.start();
|
await smartAcmeInstance.start();
|
||||||
@ -138,8 +202,8 @@ async function main() {
|
|||||||
await smartAcmeInstance.stop();
|
await smartAcmeInstance.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error);
|
main().catch(console.error);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Built-in Challenge Handlers
|
## Built-in Challenge Handlers
|
||||||
|
|
||||||
@ -222,7 +286,7 @@ async function main() {
|
|||||||
challengePriority: ['my-01'],
|
challengePriority: ['my-01'],
|
||||||
});
|
});
|
||||||
|
|
||||||
In this example, `Qenv` is used to manage environment variables, and `cloudflare` library is used to handle DNS challenges required by Let's Encrypt ACME protocol. The `setChallenge` and `removeChallenge` methods are essential for automating the DNS challenge process, which is a key part of domain validation.
|
In this example, `Qenv` is used to manage environment variables, and the Cloudflare library is used to handle DNS challenges through the built-in `Dns01Handler` plugin.
|
||||||
|
|
||||||
## Additional Details
|
## Additional Details
|
||||||
|
|
||||||
@ -243,8 +307,6 @@ 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.
|
- **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.
|
- **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)**: 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.
|
||||||
- **setChallenge(dnsChallenge: any)**: Automates the process of setting DNS challenge records.
|
|
||||||
- **removeChallenge(dnsChallenge: any)**: Automates the process of removing DNS challenge records.
|
|
||||||
|
|
||||||
### Handling Domain Matching
|
### Handling Domain Matching
|
||||||
|
|
||||||
@ -260,60 +322,13 @@ console.log('Certificate Domain Name:', certDomainName); // Output: example.com
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
Automated tests can be added to ensure that the setup and functions work as expected. Using a testing framework such as `tap` and mock services for DNS providers (e.g., Cloudflare), you can simulate the process of obtaining and managing certificates without the need for actual domain ownership.
|
Sample tests are provided in the `test` directory. They demonstrate core functionality using the `MemoryCertManager` and built-in challenge handlers. To run all tests, use:
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
pnpm test
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
|
||||||
import * as smartacme from '@push.rocks/smartacme';
|
|
||||||
|
|
||||||
const testQenv = new Qenv('./', './.nogit/');
|
|
||||||
const testCloudflare = new cloudflare.CloudflareAccount(testQenv.getEnvVarOnDemand('CF_TOKEN'));
|
|
||||||
|
|
||||||
let smartAcmeInstance: smartacme.SmartAcme;
|
|
||||||
|
|
||||||
tap.test('should create a valid instance of SmartAcme', async () => {
|
|
||||||
smartAcmeInstance = new smartacme.SmartAcme({
|
|
||||||
accountEmail: 'domains@lossless.org',
|
|
||||||
accountPrivateKey: null,
|
|
||||||
mongoDescriptor: {
|
|
||||||
mongoDbName: testQenv.getEnvVarRequired('MONGODB_DATABASE'),
|
|
||||||
mongoDbPass: testQenv.getEnvVarRequired('MONGODB_PASSWORD'),
|
|
||||||
mongoDbUrl: testQenv.getEnvVarRequired('MONGODB_URL'),
|
|
||||||
},
|
|
||||||
setChallenge: async (dnsChallenge) => {
|
|
||||||
await testCloudflare.convenience.acmeSetDnsChallenge(dnsChallenge);
|
|
||||||
},
|
|
||||||
removeChallenge: async (dnsChallenge) => {
|
|
||||||
await testCloudflare.convenience.acmeRemoveDnsChallenge(dnsChallenge);
|
|
||||||
},
|
|
||||||
environment: 'integration',
|
|
||||||
});
|
|
||||||
await smartAcmeInstance.init();
|
|
||||||
expect(smartAcmeInstance).toBeInstanceOf(smartacme.SmartAcme);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should get a domain certificate', async () => {
|
|
||||||
const certificate = await smartAcmeInstance.getCertificateForDomain('example.com');
|
|
||||||
console.log('Certificate:', certificate);
|
|
||||||
expect(certificate).toHaveProperty('domainName', 'example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('certmatcher should correctly match domains', async () => {
|
|
||||||
const certMatcher = new smartacme.SmartacmeCertMatcher();
|
|
||||||
const matchedCert = certMatcher.getCertificateDomainNameByDomainName('subdomain.example.com');
|
|
||||||
expect(matchedCert).toBe('example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should stop correctly', async () => {
|
|
||||||
await smartAcmeInstance.stop();
|
|
||||||
expect(smartAcmeInstance).toHaveProperty('client', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.start();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
This comprehensive guide ensures you can set up, manage, and test ACME certificates efficiently and effectively using `@push.rocks/smartacme`.
|
This comprehensive guide ensures you can set up, manage, and test ACME certificates efficiently and effectively using `@push.rocks/smartacme`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
@ -1,44 +1,16 @@
|
|||||||
# Plan: Diskless HTTP-01 Handler and Renaming Existing Handler
|
# Plan: Move interestMap from certmanager to smartacme core
|
||||||
|
|
||||||
This plan outlines steps to rename the existing filesystem-based HTTP-01 handler to `Http01Webroot`
|
## Goal
|
||||||
and introduce a new diskless (in-memory) HTTP-01 handler for integration with arbitrary HTTP servers
|
- Pull the interest coordination mechanism out of the ICertManager implementations and into the SmartAcme class.
|
||||||
(e.g., Express).
|
|
||||||
|
|
||||||
## 1. Rename existing handler to Http01Webroot
|
## Steps
|
||||||
- In `ts/handlers/Http01Handler.ts`:
|
1. Remove `interestMap` from `ICertManager` interface (`ts/interfaces/certmanager.ts`) and its import of `InterestMap`.
|
||||||
- Rename `Http01HandlerOptions` to `Http01WebrootOptions`.
|
2. Strip out `interestMap` property, initialization, and usage from `MemoryCertManager` and `MongoCertManager` (`ts/certmanagers/*.ts`).
|
||||||
- Rename class `Http01Handler` to `Http01Webroot`.
|
3. In `Smartacme` class (`ts/smartacme.classes.smartacme.ts`):
|
||||||
- Remove the legacy alias; rename the handler directly.
|
- Add a private `interestMap: plugins.lik.InterestMap<string, SmartacmeCert>` property.
|
||||||
- In `ts/handlers/index.ts`:
|
- Initialize it in the constructor: `this.interestMap = new plugins.lik.InterestMap((domain) => domain);`.
|
||||||
- Export `Http01Webroot` under its new name.
|
- Update `getCertificateForDomain()` and any other consumers to reference `this.interestMap` instead of `this.certmanager.interestMap`.
|
||||||
- Remove any `Http01Handler` export.
|
4. Remove any tests or code that reference the old `interestMap` on `ICertManager` (if any).
|
||||||
- Update existing tests (e.g., `test.handlers-http01.ts`) to import `Http01Webroot` instead of `Http01Handler`.
|
5. Run CI (`pnpm build` and `pnpm test`) and fix any regressions.
|
||||||
|
|
||||||
## 2. Add new diskless (in-memory) HTTP-01 handler
|
Please review and confirm before we begin the refactor.
|
||||||
- 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.
|
|
@ -7,15 +7,11 @@ tap.test('Dns01Handler prepare and cleanup calls Cloudflare and DNS functions',
|
|||||||
// fake Cloudflare API
|
// fake Cloudflare API
|
||||||
const fakeCF: any = {
|
const fakeCF: any = {
|
||||||
convenience: {
|
convenience: {
|
||||||
acmeSetDnsChallenge: async (ch: any) => {
|
acmeSetDnsChallenge: async (_ch: any) => {
|
||||||
setCalled = true;
|
setCalled = true;
|
||||||
expect(ch).toHaveProperty('hostName');
|
|
||||||
expect(ch).toHaveProperty('challenge');
|
|
||||||
},
|
},
|
||||||
acmeRemoveDnsChallenge: async (ch: any) => {
|
acmeRemoveDnsChallenge: async (_ch: any) => {
|
||||||
removeCalled = true;
|
removeCalled = true;
|
||||||
expect(ch).toHaveProperty('hostName');
|
|
||||||
expect(ch).toHaveProperty('challenge');
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import { Qenv } from '@push.rocks/qenv';
|
import { Qenv } from '@push.rocks/qenv';
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
import { SmartAcme } from '../ts/index.js';
|
import { SmartAcme, MongoCertManager } from '../ts/index.js';
|
||||||
import { Dns01Handler } from '../ts/handlers/Dns01Handler.js';
|
import { Dns01Handler } from '../ts/handlers/Dns01Handler.js';
|
||||||
|
|
||||||
// Load environment variables for credentials (stored under .nogit/)
|
// Load environment variables for credentials (stored under .nogit/)
|
||||||
@ -14,12 +14,13 @@ const mongoDbName = (await testQenv.getEnvVarOnDemand('MONGODB_DATABASE'))!;
|
|||||||
const mongoDbPass = (await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'))!;
|
const mongoDbPass = (await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'))!;
|
||||||
const mongoDbUrl = (await testQenv.getEnvVarOnDemand('MONGODB_URL'))!;
|
const mongoDbUrl = (await testQenv.getEnvVarOnDemand('MONGODB_URL'))!;
|
||||||
|
|
||||||
|
|
||||||
let smartAcmeInstance: SmartAcme;
|
let smartAcmeInstance: SmartAcme;
|
||||||
|
|
||||||
tap.test('create SmartAcme instance with DNS-01 handler and start', async () => {
|
tap.test('create SmartAcme instance with DNS-01 handler and start', async () => {
|
||||||
smartAcmeInstance = new SmartAcme({
|
smartAcmeInstance = new SmartAcme({
|
||||||
accountEmail: 'domains@lossless.org',
|
accountEmail: 'domains@lossless.org',
|
||||||
mongoDescriptor: { mongoDbName, mongoDbPass, mongoDbUrl },
|
certManager: new MongoCertManager({ mongoDbName, mongoDbPass, mongoDbUrl }),
|
||||||
environment: 'integration',
|
environment: 'integration',
|
||||||
retryOptions: {},
|
retryOptions: {},
|
||||||
challengeHandlers: [new Dns01Handler(cfAccount)],
|
challengeHandlers: [new Dns01Handler(cfAccount)],
|
||||||
@ -29,6 +30,10 @@ tap.test('create SmartAcme instance with DNS-01 handler and start', async () =>
|
|||||||
expect(smartAcmeInstance).toBeInstanceOf(SmartAcme);
|
expect(smartAcmeInstance).toBeInstanceOf(SmartAcme);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should wipe the certmanager for this test', async () => {
|
||||||
|
await smartAcmeInstance.certmanager.wipe();
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('get a domain certificate via DNS-01 challenge', async () => {
|
tap.test('get a domain certificate via DNS-01 challenge', async () => {
|
||||||
// Replace 'bleu.de' with your test domain if different
|
// Replace 'bleu.de' with your test domain if different
|
||||||
const domain = 'bleu.de';
|
const domain = 'bleu.de';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import { SmartAcme } from '../ts/index.js';
|
import { SmartAcme, MemoryCertManager } from '../ts/index.js';
|
||||||
import type { IChallengeHandler } from '../ts/handlers/IChallengeHandler.js';
|
import type { IChallengeHandler } from '../ts/handlers/IChallengeHandler.js';
|
||||||
|
|
||||||
// Dummy handler for testing
|
// Dummy handler for testing
|
||||||
@ -12,7 +12,7 @@ class DummyHandler implements IChallengeHandler<any> {
|
|||||||
tap.test('constructor throws without challengeHandlers', async () => {
|
tap.test('constructor throws without challengeHandlers', async () => {
|
||||||
expect(() => new SmartAcme({
|
expect(() => new SmartAcme({
|
||||||
accountEmail: 'test@example.com',
|
accountEmail: 'test@example.com',
|
||||||
mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' },
|
certManager: new MemoryCertManager(),
|
||||||
environment: 'integration',
|
environment: 'integration',
|
||||||
retryOptions: {},
|
retryOptions: {},
|
||||||
} as any)).toThrow();
|
} as any)).toThrow();
|
||||||
@ -21,7 +21,7 @@ tap.test('constructor throws without challengeHandlers', async () => {
|
|||||||
tap.test('constructor accepts valid challengeHandlers', async () => {
|
tap.test('constructor accepts valid challengeHandlers', async () => {
|
||||||
const sa = new SmartAcme({
|
const sa = new SmartAcme({
|
||||||
accountEmail: 'test@example.com',
|
accountEmail: 'test@example.com',
|
||||||
mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' },
|
certManager: new MemoryCertManager(),
|
||||||
environment: 'integration',
|
environment: 'integration',
|
||||||
retryOptions: {},
|
retryOptions: {},
|
||||||
challengeHandlers: [new DummyHandler()],
|
challengeHandlers: [new DummyHandler()],
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartacme',
|
name: '@push.rocks/smartacme',
|
||||||
version: '6.2.0',
|
version: '7.2.3',
|
||||||
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.'
|
||||||
}
|
}
|
||||||
|
2
ts/certmanagers/index.ts
Normal file
2
ts/certmanagers/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './memory.js';
|
||||||
|
export * from './mongo.js';
|
38
ts/certmanagers/memory.ts
Normal file
38
ts/certmanagers/memory.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import * as plugins from '../smartacme.plugins.js';
|
||||||
|
import type { ICertManager } from '../interfaces/certmanager.js';
|
||||||
|
import { SmartacmeCert } from '../smartacme.classes.cert.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory certificate manager for mongoless mode.
|
||||||
|
* Stores certificates in memory only and does not connect to MongoDB.
|
||||||
|
*/
|
||||||
|
export class MemoryCertManager implements ICertManager {
|
||||||
|
private certs: Map<string, SmartacmeCert> = new Map();
|
||||||
|
|
||||||
|
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
// no-op for in-memory store
|
||||||
|
}
|
||||||
|
|
||||||
|
public async retrieveCertificate(domainName: string): Promise<SmartacmeCert | null> {
|
||||||
|
return this.certs.get(domainName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async storeCertificate(cert: SmartacmeCert): Promise<void> {
|
||||||
|
this.certs.set(cert.domainName, cert);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteCertificate(domainName: string): Promise<void> {
|
||||||
|
this.certs.delete(domainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Wipe all certificates from the in-memory store (for testing)
|
||||||
|
*/
|
||||||
|
public async wipe(): Promise<void> {
|
||||||
|
this.certs.clear();
|
||||||
|
}
|
||||||
|
}
|
52
ts/certmanagers/mongo.ts
Normal file
52
ts/certmanagers/mongo.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import * as plugins from '../smartacme.plugins.js';
|
||||||
|
import type { ICertManager } from '../interfaces/certmanager.js';
|
||||||
|
import { SmartacmeCert } from '../smartacme.classes.cert.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MongoDB-backed certificate manager using EasyStore from smartdata.
|
||||||
|
*/
|
||||||
|
export class MongoCertManager implements ICertManager {
|
||||||
|
private db: plugins.smartdata.SmartdataDb;
|
||||||
|
private store: plugins.smartdata.EasyStore<Record<string, any>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mongoDescriptor MongoDB connection settings
|
||||||
|
*/
|
||||||
|
constructor(mongoDescriptor: plugins.smartdata.IMongoDescriptor) {
|
||||||
|
this.db = new plugins.smartdata.SmartdataDb(mongoDescriptor);
|
||||||
|
// Use a single EasyStore document to hold all certs keyed by domainName
|
||||||
|
this.store = new plugins.smartdata.EasyStore<Record<string, any>>(
|
||||||
|
'smartacme-certs',
|
||||||
|
this.db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
await this.db.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async retrieveCertificate(domainName: string): Promise<SmartacmeCert | null> {
|
||||||
|
const data = await this.store.readKey(domainName);
|
||||||
|
return data ? new SmartacmeCert(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async storeCertificate(cert: SmartacmeCert): Promise<void> {
|
||||||
|
// write plain object for persistence
|
||||||
|
await this.store.writeKey(cert.domainName, { ...cert });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteCertificate(domainName: string): Promise<void> {
|
||||||
|
await this.store.deleteKey(domainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
await this.db.close();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Wipe all certificates from the persistent store (for integration testing)
|
||||||
|
*/
|
||||||
|
public async wipe(): Promise<void> {
|
||||||
|
// clear all keys in the easy store
|
||||||
|
await this.store.wipe();
|
||||||
|
}
|
||||||
|
}
|
@ -23,14 +23,6 @@ export class Dns01Handler implements IChallengeHandler<plugins.tsclass.network.I
|
|||||||
public async prepare(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> {
|
public async prepare(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> {
|
||||||
// set DNS TXT record
|
// set DNS TXT record
|
||||||
await this.cf.convenience.acmeSetDnsChallenge(ch);
|
await this.cf.convenience.acmeSetDnsChallenge(ch);
|
||||||
// wait for DNS propagation
|
|
||||||
await this.smartdns.checkUntilAvailable(
|
|
||||||
ch.hostName,
|
|
||||||
'TXT',
|
|
||||||
ch.challenge,
|
|
||||||
100,
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async cleanup(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> {
|
public async cleanup(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> {
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
export * from './smartacme.classes.smartacme.js';
|
export * from './smartacme.classes.smartacme.js';
|
||||||
export { SmartacmeCert as Cert } from './smartacme.classes.cert.js';
|
export { SmartacmeCert as Cert } from './smartacme.classes.cert.js';
|
||||||
|
export type { ICertManager } from './interfaces/certmanager.js';
|
||||||
|
export { MemoryCertManager, MongoCertManager } from './certmanagers/index.js';
|
||||||
|
36
ts/interfaces/certmanager.ts
Normal file
36
ts/interfaces/certmanager.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { SmartacmeCert } from '../smartacme.classes.cert.js';
|
||||||
|
|
||||||
|
// (ICertRecord removed; use SmartacmeCert directly)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for certificate storage managers.
|
||||||
|
* Users can implement this to provide custom persistence (in-memory,
|
||||||
|
* file-based, Redis, etc.).
|
||||||
|
*/
|
||||||
|
export interface ICertManager {
|
||||||
|
/**
|
||||||
|
* Initialize the store (e.g., connect to database).
|
||||||
|
*/
|
||||||
|
init(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Retrieve a certificate record by domain name.
|
||||||
|
* Returns null if none found.
|
||||||
|
*/
|
||||||
|
retrieveCertificate(domainName: string): Promise<SmartacmeCert | null>;
|
||||||
|
/**
|
||||||
|
* Store a certificate record. Fulfills any pending interests.
|
||||||
|
*/
|
||||||
|
storeCertificate(cert: SmartacmeCert): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Delete a certificate record by domain name.
|
||||||
|
*/
|
||||||
|
deleteCertificate(domainName: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Close the store (e.g., disconnect database).
|
||||||
|
*/
|
||||||
|
close(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Optional: wipe all stored certificates (e.g., for integration testing)
|
||||||
|
*/
|
||||||
|
wipe(): Promise<void>;
|
||||||
|
}
|
@ -1,64 +1,39 @@
|
|||||||
import * as plugins from './smartacme.plugins.js';
|
import * as plugins from './smartacme.plugins.js';
|
||||||
|
|
||||||
import * as interfaces from './interfaces/index.js';
|
/**
|
||||||
|
* Plain certificate record.
|
||||||
import { SmartacmeCertManager } from './smartacme.classes.certmanager.js';
|
*/
|
||||||
|
export class SmartacmeCert {
|
||||||
import { Collection, svDb, unI } from '@push.rocks/smartdata';
|
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => {
|
|
||||||
return SmartacmeCertManager.activeDB;
|
|
||||||
})
|
|
||||||
export class SmartacmeCert
|
|
||||||
extends plugins.smartdata.SmartDataDbDoc<SmartacmeCert, plugins.tsclass.network.ICert>
|
|
||||||
implements plugins.tsclass.network.ICert
|
|
||||||
{
|
|
||||||
@unI()
|
|
||||||
public id: string;
|
public id: string;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public domainName: string;
|
public domainName: string;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public created: number;
|
public created: number;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public privateKey: string;
|
public privateKey: string;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public publicKey: string;
|
public publicKey: string;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public csr: string;
|
public csr: string;
|
||||||
|
|
||||||
@svDb()
|
|
||||||
public validUntil: number;
|
public validUntil: number;
|
||||||
|
|
||||||
|
constructor(data: Partial<SmartacmeCert> = {}) {
|
||||||
|
this.id = data.id || '';
|
||||||
|
this.domainName = data.domainName || '';
|
||||||
|
this.created = data.created || Date.now();
|
||||||
|
this.privateKey = data.privateKey || '';
|
||||||
|
this.publicKey = data.publicKey || '';
|
||||||
|
this.csr = data.csr || '';
|
||||||
|
this.validUntil = data.validUntil || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if certificate is still valid.
|
||||||
|
*/
|
||||||
public isStillValid(): boolean {
|
public isStillValid(): boolean {
|
||||||
return this.validUntil >= Date.now();
|
return this.validUntil >= Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if certificate needs renewal (e.g., expires in <10 days).
|
||||||
|
*/
|
||||||
public shouldBeRenewed(): boolean {
|
public shouldBeRenewed(): boolean {
|
||||||
const shouldBeValidAtLeastUntil =
|
const threshold = Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 10 });
|
||||||
Date.now() +
|
return this.validUntil < threshold;
|
||||||
plugins.smarttime.getMilliSecondsFromUnits({
|
|
||||||
days: 10,
|
|
||||||
});
|
|
||||||
return !(this.validUntil >= shouldBeValidAtLeastUntil);
|
|
||||||
}
|
|
||||||
|
|
||||||
public update(certDataArg: plugins.tsclass.network.ICert) {
|
|
||||||
Object.keys(certDataArg).forEach((key) => {
|
|
||||||
this[key] = certDataArg[key];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(optionsArg: plugins.tsclass.network.ICert) {
|
|
||||||
super();
|
|
||||||
if (optionsArg) {
|
|
||||||
Object.keys(optionsArg).forEach((key) => {
|
|
||||||
this[key] = optionsArg[key];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,77 +0,0 @@
|
|||||||
import * as plugins from './smartacme.plugins.js';
|
|
||||||
import { SmartacmeCert } from './smartacme.classes.cert.js';
|
|
||||||
import { SmartAcme } from './smartacme.classes.smartacme.js';
|
|
||||||
|
|
||||||
import * as interfaces from './interfaces/index.js';
|
|
||||||
|
|
||||||
export class SmartacmeCertManager {
|
|
||||||
// =========
|
|
||||||
// STATIC
|
|
||||||
// =========
|
|
||||||
public static activeDB: plugins.smartdata.SmartdataDb;
|
|
||||||
|
|
||||||
// =========
|
|
||||||
// INSTANCE
|
|
||||||
// =========
|
|
||||||
private mongoDescriptor: plugins.smartdata.IMongoDescriptor;
|
|
||||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
|
||||||
|
|
||||||
public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
smartAcmeArg: SmartAcme,
|
|
||||||
optionsArg: {
|
|
||||||
mongoDescriptor: plugins.smartdata.IMongoDescriptor;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
this.mongoDescriptor = optionsArg.mongoDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
// Smartdata DB
|
|
||||||
this.smartdataDb = new plugins.smartdata.SmartdataDb(this.mongoDescriptor);
|
|
||||||
await this.smartdataDb.init();
|
|
||||||
SmartacmeCertManager.activeDB = this.smartdataDb;
|
|
||||||
|
|
||||||
// Pending Map
|
|
||||||
this.interestMap = new plugins.lik.InterestMap((certName) => certName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieves a certificate
|
|
||||||
* @returns the Cert class or null
|
|
||||||
* @param certDomainNameArg the domain Name to retrieve the vcertificate for
|
|
||||||
*/
|
|
||||||
public async retrieveCertificate(certDomainNameArg: string): Promise<SmartacmeCert> {
|
|
||||||
const existingCertificate: SmartacmeCert = await SmartacmeCert.getInstance<SmartacmeCert>({
|
|
||||||
domainName: certDomainNameArg,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingCertificate) {
|
|
||||||
return existingCertificate;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* stores the certificate
|
|
||||||
* @param optionsArg
|
|
||||||
*/
|
|
||||||
public async storeCertificate(optionsArg: plugins.tsclass.network.ICert) {
|
|
||||||
const cert = new SmartacmeCert(optionsArg);
|
|
||||||
await cert.save();
|
|
||||||
const interest = this.interestMap.findInterest(cert.domainName);
|
|
||||||
if (interest) {
|
|
||||||
interest.fullfillInterest(cert);
|
|
||||||
interest.markLost();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteCertificate(certDomainNameArg: string) {
|
|
||||||
const cert: SmartacmeCert = await SmartacmeCert.getInstance<SmartacmeCert>({
|
|
||||||
domainName: certDomainNameArg,
|
|
||||||
});
|
|
||||||
await cert.delete();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
import * as plugins from './smartacme.plugins.js';
|
import * as plugins from './smartacme.plugins.js';
|
||||||
import { SmartacmeCert } from './smartacme.classes.cert.js';
|
import type { ICertManager } from './interfaces/certmanager.js';
|
||||||
import { SmartacmeCertManager } from './smartacme.classes.certmanager.js';
|
|
||||||
import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
|
import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
|
||||||
import { commitinfo } from './00_commitinfo_data.js';
|
import { commitinfo } from './00_commitinfo_data.js';
|
||||||
|
import { SmartacmeCert } from './smartacme.classes.cert.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the options for the class @see SmartAcme
|
* the options for the class @see SmartAcme
|
||||||
@ -10,7 +10,10 @@ import { commitinfo } from './00_commitinfo_data.js';
|
|||||||
export interface ISmartAcmeOptions {
|
export interface ISmartAcmeOptions {
|
||||||
accountPrivateKey?: string;
|
accountPrivateKey?: string;
|
||||||
accountEmail: string;
|
accountEmail: string;
|
||||||
mongoDescriptor: plugins.smartdata.IMongoDescriptor;
|
/**
|
||||||
|
* Certificate storage manager (e.g., Mongo or in-memory).
|
||||||
|
*/
|
||||||
|
certManager: ICertManager;
|
||||||
// Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
|
// Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
|
||||||
environment: 'production' | 'integration';
|
environment: 'production' | 'integration';
|
||||||
/**
|
/**
|
||||||
@ -59,8 +62,8 @@ export class SmartAcme {
|
|||||||
private privateKey: string;
|
private privateKey: string;
|
||||||
|
|
||||||
|
|
||||||
// certmanager
|
// certificate manager for persistence (implements ICertManager)
|
||||||
private certmanager: SmartacmeCertManager;
|
public certmanager: ICertManager;
|
||||||
private certmatcher: SmartacmeCertMatcher;
|
private certmatcher: SmartacmeCertMatcher;
|
||||||
// retry/backoff configuration (resolved with defaults)
|
// retry/backoff configuration (resolved with defaults)
|
||||||
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
|
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
|
||||||
@ -70,6 +73,8 @@ export class SmartAcme {
|
|||||||
private challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
|
private challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
|
||||||
// priority order of challenge types
|
// priority order of challenge types
|
||||||
private challengePriority: string[];
|
private challengePriority: string[];
|
||||||
|
// Map for coordinating concurrent certificate requests
|
||||||
|
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
|
||||||
|
|
||||||
constructor(optionsArg: ISmartAcmeOptions) {
|
constructor(optionsArg: ISmartAcmeOptions) {
|
||||||
this.options = optionsArg;
|
this.options = optionsArg;
|
||||||
@ -78,10 +83,10 @@ export class SmartAcme {
|
|||||||
this.logger.enableConsole();
|
this.logger.enableConsole();
|
||||||
// initialize retry/backoff options
|
// initialize retry/backoff options
|
||||||
this.retryOptions = {
|
this.retryOptions = {
|
||||||
retries: optionsArg.retryOptions?.retries ?? 3,
|
retries: optionsArg.retryOptions?.retries ?? 10,
|
||||||
factor: optionsArg.retryOptions?.factor ?? 2,
|
factor: optionsArg.retryOptions?.factor ?? 4,
|
||||||
minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
|
minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
|
||||||
maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000,
|
maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 60000,
|
||||||
};
|
};
|
||||||
// initialize challenge handlers (must provide at least one)
|
// initialize challenge handlers (must provide at least one)
|
||||||
if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) {
|
if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) {
|
||||||
@ -95,6 +100,8 @@ export class SmartAcme {
|
|||||||
optionsArg.challengePriority && optionsArg.challengePriority.length > 0
|
optionsArg.challengePriority && optionsArg.challengePriority.length > 0
|
||||||
? optionsArg.challengePriority
|
? optionsArg.challengePriority
|
||||||
: this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
|
: this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
|
||||||
|
// initialize interest coordination
|
||||||
|
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,10 +114,11 @@ export class SmartAcme {
|
|||||||
this.privateKey =
|
this.privateKey =
|
||||||
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
|
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
|
||||||
|
|
||||||
// CertMangaer
|
// Initialize certificate manager
|
||||||
this.certmanager = new SmartacmeCertManager(this, {
|
if (!this.options.certManager) {
|
||||||
mongoDescriptor: this.options.mongoDescriptor,
|
throw new Error('You must provide a certManager via options.certManager');
|
||||||
});
|
}
|
||||||
|
this.certmanager = this.options.certManager;
|
||||||
await this.certmanager.init();
|
await this.certmanager.init();
|
||||||
|
|
||||||
// CertMatcher
|
// CertMatcher
|
||||||
@ -138,8 +146,13 @@ export class SmartAcme {
|
|||||||
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
|
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the SmartAcme instance and closes certificate store connections.
|
||||||
|
*/
|
||||||
public async stop() {
|
public async stop() {
|
||||||
await this.certmanager.smartdataDb.close();
|
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
|
||||||
|
await (this.certmanager as any).close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/** Retry helper with exponential backoff */
|
/** Retry helper with exponential backoff */
|
||||||
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
|
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
|
||||||
@ -210,22 +223,41 @@ export class SmartAcme {
|
|||||||
public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> {
|
public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> {
|
||||||
const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg);
|
const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg);
|
||||||
const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
|
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 (
|
if (
|
||||||
!retrievedCertificate &&
|
!retrievedCertificate &&
|
||||||
(await this.certmanager.interestMap.checkInterest(certDomainName))
|
(await this.interestMap.checkInterest(certDomainName))
|
||||||
) {
|
) {
|
||||||
const existingCertificateInterest = this.certmanager.interestMap.findInterest(certDomainName);
|
const existingCertificateInterest = this.interestMap.findInterest(certDomainName);
|
||||||
const certificate = existingCertificateInterest.interestFullfilled;
|
const certificate = existingCertificateInterest.interestFullfilled;
|
||||||
return certificate;
|
return certificate;
|
||||||
} else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
|
} else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
|
||||||
return retrievedCertificate;
|
return retrievedCertificate;
|
||||||
} else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
|
} else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
|
||||||
await retrievedCertificate.delete();
|
// Remove old certificate via certManager
|
||||||
|
await this.certmanager.deleteCertificate(certDomainName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets make sure others get the same interest
|
// lets make sure others get the same interest
|
||||||
const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName);
|
const currentDomainInterst = await this.interestMap.addInterest(certDomainName);
|
||||||
|
|
||||||
/* Place new order with retry */
|
/* Place new order with retry */
|
||||||
const order = await this.retry(() => this.client.createOrder({
|
const order = await this.retry(() => this.client.createOrder({
|
||||||
@ -277,15 +309,45 @@ export class SmartAcme {
|
|||||||
}
|
}
|
||||||
this.pendingChallenges.push(input);
|
this.pendingChallenges.push(input);
|
||||||
try {
|
try {
|
||||||
|
// Prepare the challenge (set DNS record, write file, etc.)
|
||||||
await this.retry(() => handler.prepare(input), `${type}.prepare`);
|
await this.retry(() => handler.prepare(input), `${type}.prepare`);
|
||||||
if (handler.verify) {
|
// For DNS-01, wait for propagation before verification
|
||||||
await this.retry(() => handler.verify!(input), `${type}.verify`);
|
if (type === 'dns-01') {
|
||||||
} else {
|
const dnsInput = input as { hostName: string; challenge: string };
|
||||||
await this.retry(() => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`);
|
// Wait for authoritative DNS propagation before ACME verify
|
||||||
|
await this.retry(
|
||||||
|
() => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000),
|
||||||
|
`${type}.propagation`,
|
||||||
|
);
|
||||||
|
// Extra cool-down to ensure ACME server sees the new TXT record
|
||||||
|
this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
|
||||||
|
await plugins.smartdelay.delayFor(60000);
|
||||||
|
}
|
||||||
|
// Official ACME verification (ensures challenge is publicly reachable)
|
||||||
|
await this.retry(
|
||||||
|
() => this.client.verifyChallenge(authz, selectedChallengeArg),
|
||||||
|
`${type}.verifyChallenge`,
|
||||||
|
);
|
||||||
|
// Notify ACME server to complete the challenge
|
||||||
|
await this.retry(
|
||||||
|
() => this.client.completeChallenge(selectedChallengeArg),
|
||||||
|
`${type}.completeChallenge`,
|
||||||
|
);
|
||||||
|
// Wait for valid status (warnings on staging timeouts)
|
||||||
|
try {
|
||||||
|
await this.retry(
|
||||||
|
() => this.client.waitForValidStatus(selectedChallengeArg),
|
||||||
|
`${type}.waitForValidStatus`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await this.logger.log(
|
||||||
|
'warn',
|
||||||
|
`Challenge ${type} did not reach valid status in time, proceeding to finalize`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.retry(() => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`);
|
|
||||||
await this.retry(() => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`);
|
|
||||||
} finally {
|
} finally {
|
||||||
|
// Always cleanup resource
|
||||||
try {
|
try {
|
||||||
await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
|
await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -307,19 +369,17 @@ export class SmartAcme {
|
|||||||
|
|
||||||
/* Done */
|
/* Done */
|
||||||
|
|
||||||
await this.certmanager.storeCertificate({
|
// Store the new certificate record
|
||||||
|
const certRecord = new SmartacmeCert({
|
||||||
id: plugins.smartunique.shortId(),
|
id: plugins.smartunique.shortId(),
|
||||||
domainName: certDomainName,
|
domainName: certDomainName,
|
||||||
privateKey: key.toString(),
|
privateKey: key.toString(),
|
||||||
publicKey: cert.toString(),
|
publicKey: cert.toString(),
|
||||||
csr: csr.toString(),
|
csr: csr.toString(),
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
validUntil:
|
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }),
|
||||||
Date.now() +
|
|
||||||
plugins.smarttime.getMilliSecondsFromUnits({
|
|
||||||
days: 90,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
await this.certmanager.storeCertificate(certRecord);
|
||||||
|
|
||||||
const newCertificate = await this.certmanager.retrieveCertificate(certDomainName);
|
const newCertificate = await this.certmanager.retrieveCertificate(certDomainName);
|
||||||
currentDomainInterst.fullfillInterest(newCertificate);
|
currentDomainInterst.fullfillInterest(newCertificate);
|
||||||
@ -327,7 +387,4 @@ export class SmartAcme {
|
|||||||
return newCertificate;
|
return newCertificate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAllCertificates(): Promise<SmartacmeCert[]> {
|
|
||||||
return SmartacmeCert.getInstances({});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user