@geektr/acme-dns01
v0.3.0
Published
Minimal ACME client — DNS-01 only, zero bloat
Maintainers
Readme
@geektr/acme-dns01
Minimal ACME client — DNS-01 challenge only, zero bloat.
Based on publishlab/node-acme-client. Handles ACME v2 (RFC 8555) with DNS-01 challenges only.
- RFC 8555: https://datatracker.ietf.org/doc/html/rfc8555
- Boulder divergences: https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md
Why
- DNS-01 only — works behind firewalls, supports wildcard certificates, no open ports needed
- No HTTP-01, no TLS-ALPN-01 — simpler code, fewer dependencies, fewer failure modes
- TypeScript — dual ESM/CJS output, strict mode
- fetch — native HTTP transport, no axios
- Structured events —
EventTarget-based, typedCustomEventwith detail payloads (operation-level) - 3 runtime deps —
@peculiar/x509,debug,reflect-metadata
Install
npm install @geektr/acme-dns01Usage
import { Client, directory, crypto, DohResolver } from '@geektr/acme-dns01';
const client = new Client({
directoryUrl: directory.letsencrypt.staging,
accountKey: '<PEM encoded private key>',
// Optional: replace default DNS resolver (node:dns) with DoH
// resolver: new DohResolver(), // Cloudflare DoH (default endpoint)
});Directory URLs
directory.letsencrypt.staging; // https://acme-staging-v02.api.letsencrypt.org/directory
directory.letsencrypt.production; // https://acme-v02.api.letsencrypt.org/directory
directory.google.staging;
directory.google.production;
directory.zerossl.production;Account
const client = new Client({
directoryUrl: directory.letsencrypt.staging,
accountKey: accountPrivateKey,
// Optional: skip account creation, use existing account URL
accountUrl: 'https://acme-v02.api.letsencrypt.org/acme/acct/12345678',
// Optional: external account binding
externalAccountBinding: {
kid: 'YOUR-EAB-KID',
hmacKey: 'YOUR-EAB-HMAC-KEY',
},
});
// Check existing account URL
client.getAccountUrl();
// Create or update account
const account = await client.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:[email protected]'],
});Crypto
Key generation via Web Crypto API (globalThis.crypto) + @peculiar/x509.
import { crypto } from '@geektr/acme-dns01';
const rsaKey = await crypto.createPrivateRsaKey();
const ecKey = await crypto.createPrivateEcdsaKey('P-384');
const [key, csr] = await crypto.createCsr({
commonName: 'example.com',
altNames: ['example.com', '*.example.com'],
});
const info = crypto.readCertificateInfo(certPem);Auto mode
Single-call certificate ordering with DNS-01. Returns a lazy AcmeOperation — attach event listeners before await-ing.
const op = client.auto({
csr: certificateRequest,
email: '[email protected]',
termsOfServiceAgreed: true,
// Called before completing challenge — set your DNS TXT record here
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
const recordName = `_acme-challenge.${authz.identifier.value}`;
await yourDnsProvider.setTxt(recordName, keyAuthorization);
},
// Called after challenge completes — clean up the TXT record
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
const recordName = `_acme-challenge.${authz.identifier.value}`;
await yourDnsProvider.removeTxt(recordName);
},
});
// Attach listeners before awaiting — operation starts on first .then()
op.addEventListener('challenge:create', (e) => {
console.log(`Creating challenge for ${e.detail.domain}`);
});
const certificate = await op;Events
Two event layers, each dispatched on a separate EventTarget:
Transport events (on AcmeClient):
client.addEventListener('http:request', (e) => {
console.log(e.detail.method, e.detail.url);
});
client.addEventListener('transport:retry', (e) => {
console.log(`Retry ${e.detail.attempt}/${e.detail.max}`);
});Operation events (on AcmeOperation returned by auto()):
const op = client.auto({ /* ... */ });
op.addEventListener('account:check', (e) => { /* ... */ });
op.addEventListener('order:create', (e) => { console.log(e.detail.identifiers); });
op.addEventListener('challenge:create', (e) => { console.log(e.detail.domain); });
op.addEventListener('challenge:complete', (e) => { /* ... */ });
op.addEventListener('dns:txt-found', (e) => { console.log(e.detail.recordName, e.detail.count); });
op.addEventListener('status:poll', (e) => { console.log(e.detail.status); });Full event type maps are exported as AcmeAutoEventMap.
Transport-level logging (HTTP requests, retries) uses the debug package — see below.
Manual API
const account = await client.createAccount({ termsOfServiceAgreed: true });
const order = await client.createOrder({
identifiers: [
{ type: 'dns', value: 'example.com' },
{ type: 'dns', value: '*.example.com' },
],
});
const authorizations = await client.getAuthorizations(order);
// ... handle challenges manually ...
await client.finalizeOrder(order, csr);
const cert = await client.getCertificate(order, 'DST Root CA X3');Settings
import { settings, setUserAgent } from '@geektr/acme-dns01';
settings.retryMaxAttempts = 5; // max retries on 429/5xx
settings.retryDefaultDelay = 5; // base delay in seconds
settings.timeout = 30000; // per-request timeout in ms (default: 30s)
setUserAgent('my-agent/1.0');DNS Resolver
By default, challenge verification uses node:dns/promises. You can replace it with any object implementing the DnsResolver interface (e.g. DNS-over-HTTPS).
Recommended: pass via ClientOptions — all paths (verifyChallenge, auto()) pick it up automatically:
import { Client, DohResolver } from '@geektr/acme-dns01';
const client = new Client({
directoryUrl: directory.letsencrypt.staging,
accountKey: accountPrivateKey,
resolver: new DohResolver(), // Cloudflare DoH (one.one.one.one)
});
// auto() and verifyChallenge() now use the DoH resolver
const certificate = await client.auto({ /* ... */ });Custom endpoint:
const resolver = new DohResolver({ endpoint: 'https://dns.alidns.com/resolve' });Also available: pass resolver directly to verifyChallenge(authz, challenge, emit, prefix, resolver) — overrides ClientOptions.resolver for that call.
Cancellation
All client methods accept an AbortSignal via ClientOptions, allowing the entire ACME flow to be cancelled externally.
import { Client, directory } from '@geektr/acme-dns01';
const ac = new AbortController();
const client = new Client({
directoryUrl: directory.letsencrypt.staging,
accountKey: accountPrivateKey,
signal: ac.signal,
});
// Cancel after 10 seconds
setTimeout(() => ac.abort(), 10000);
// Also works with auto()
const certificate = await client.auto({
csr: certificateRequest,
signal: ac.signal,
// ...
});Debug
Transport-level logging uses the debug package:
DEBUG=acme-dns01:* node index.jsNamespaces: acme-dns01:http, acme-dns01:transport, acme-dns01:util.
License
MIT
