BREAKING CHANGE(acme): Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly
This commit is contained in:
236
ts/acme/acme.classes.http-client.ts
Normal file
236
ts/acme/acme.classes.http-client.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import * as https from 'node:https';
|
||||
import * as http from 'node:http';
|
||||
import { AcmeCrypto } from './acme.classes.crypto.js';
|
||||
import { AcmeError } from './acme.classes.error.js';
|
||||
import type { IAcmeDirectory, IAcmeHttpResponse } from './acme.interfaces.js';
|
||||
|
||||
export type TAcmeLogger = (level: string, message: string, data?: any) => void;
|
||||
|
||||
/**
|
||||
* JWS-signed HTTP transport for ACME protocol.
|
||||
* Handles nonce management, bad-nonce retries, and signed requests.
|
||||
*/
|
||||
export class AcmeHttpClient {
|
||||
private directoryUrl: string;
|
||||
private accountKeyPem: string;
|
||||
private directory: IAcmeDirectory | null = null;
|
||||
private nonce: string | null = null;
|
||||
public kid: string | null = null;
|
||||
private logger?: TAcmeLogger;
|
||||
|
||||
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
||||
this.directoryUrl = directoryUrl;
|
||||
this.accountKeyPem = accountKeyPem;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private log(level: string, message: string, data?: any): void {
|
||||
if (this.logger) {
|
||||
this.logger(level, message, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET the ACME directory (cached after first call)
|
||||
*/
|
||||
async getDirectory(): Promise<IAcmeDirectory> {
|
||||
if (this.directory) return this.directory;
|
||||
const response = await this.httpRequest(this.directoryUrl, 'GET');
|
||||
if (response.status !== 200) {
|
||||
throw new AcmeError({
|
||||
status: response.status,
|
||||
type: response.data?.type || '',
|
||||
detail: `Failed to fetch ACME directory`,
|
||||
url: this.directoryUrl,
|
||||
});
|
||||
}
|
||||
this.directory = response.data as IAcmeDirectory;
|
||||
return this.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh nonce via HEAD to newNonce
|
||||
*/
|
||||
async getNonce(): Promise<string> {
|
||||
if (this.nonce) {
|
||||
const n = this.nonce;
|
||||
this.nonce = null;
|
||||
return n;
|
||||
}
|
||||
const dir = await this.getDirectory();
|
||||
const response = await this.httpRequest(dir.newNonce, 'HEAD');
|
||||
const nonce = response.headers['replay-nonce'];
|
||||
if (!nonce) {
|
||||
throw new Error('No replay-nonce header in newNonce response');
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JWS-signed POST request to an ACME endpoint.
|
||||
* Handles nonce rotation and bad-nonce retries (up to 5).
|
||||
* payload=null means POST-as-GET.
|
||||
*/
|
||||
async signedRequest(
|
||||
url: string,
|
||||
payload: any | null,
|
||||
options?: { useJwk?: boolean },
|
||||
): Promise<IAcmeHttpResponse> {
|
||||
const maxRetries = 5;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const nonce = await this.getNonce();
|
||||
|
||||
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
|
||||
if (options?.useJwk) {
|
||||
jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
|
||||
} else if (this.kid) {
|
||||
jwsOptions.kid = this.kid;
|
||||
} else {
|
||||
jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
|
||||
}
|
||||
|
||||
const jws = AcmeCrypto.createJws(this.accountKeyPem, url, payload, jwsOptions);
|
||||
const body = JSON.stringify(jws);
|
||||
|
||||
const response = await this.httpRequest(url, 'POST', body, {
|
||||
'Content-Type': 'application/jose+json',
|
||||
});
|
||||
|
||||
// Save nonce from response for reuse
|
||||
if (response.headers['replay-nonce']) {
|
||||
this.nonce = response.headers['replay-nonce'];
|
||||
}
|
||||
|
||||
this.log('debug', `ACME request: POST ${url} → ${response.status}`);
|
||||
|
||||
// Retry on bad-nonce
|
||||
if (
|
||||
response.status === 400 &&
|
||||
response.data?.type === 'urn:ietf:params:acme:error:badNonce'
|
||||
) {
|
||||
this.log('debug', `Bad nonce on attempt ${attempt + 1}, retrying`);
|
||||
if (attempt < maxRetries) {
|
||||
this.nonce = null; // Force fresh nonce
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Throw on error responses
|
||||
if (response.status >= 400) {
|
||||
const retryAfterRaw = response.headers['retry-after'];
|
||||
let retryAfter = 0;
|
||||
if (retryAfterRaw) {
|
||||
const parsed = parseInt(retryAfterRaw, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
retryAfter = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const acmeError = new AcmeError({
|
||||
status: response.status,
|
||||
type: response.data?.type || '',
|
||||
detail: response.data?.detail || JSON.stringify(response.data),
|
||||
subproblems: response.data?.subproblems,
|
||||
url,
|
||||
retryAfter,
|
||||
});
|
||||
|
||||
if (acmeError.isRateLimited) {
|
||||
this.log('warn', `RATE LIMITED: ${url} (HTTP ${response.status}), Retry-After: ${retryAfter}s`, {
|
||||
type: acmeError.type,
|
||||
detail: acmeError.detail,
|
||||
retryAfter,
|
||||
});
|
||||
} else {
|
||||
this.log('warn', `ACME error: ${url} (HTTP ${response.status})`, {
|
||||
type: acmeError.type,
|
||||
detail: acmeError.detail,
|
||||
});
|
||||
}
|
||||
|
||||
throw acmeError;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw new Error('Max bad-nonce retries exceeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HTTP request using native node:https
|
||||
*/
|
||||
private httpRequest(
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<IAcmeHttpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const requestHeaders: Record<string, string | number> = {
|
||||
...headers,
|
||||
'User-Agent': 'smartacme-acme-client/1.0',
|
||||
};
|
||||
if (body) {
|
||||
requestHeaders['Content-Length'] = Buffer.byteLength(body);
|
||||
}
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf-8');
|
||||
|
||||
// Normalize headers to lowercase single-value
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (typeof value === 'string') {
|
||||
responseHeaders[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
responseHeaders[key.toLowerCase()] = value[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON if applicable, otherwise return raw string
|
||||
let data: any;
|
||||
const contentType = responseHeaders['content-type'] || '';
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
data = JSON.parse(responseBody);
|
||||
} catch {
|
||||
data = responseBody;
|
||||
}
|
||||
} else {
|
||||
data = responseBody;
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: res.statusCode || 0,
|
||||
headers: responseHeaders,
|
||||
data,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, () => {
|
||||
req.destroy(new Error('Request timeout'));
|
||||
});
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user