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; private httpsAgent: https.Agent; private httpAgent: http.Agent; constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) { this.directoryUrl = directoryUrl; this.accountKeyPem = accountKeyPem; this.logger = logger; this.httpsAgent = new https.Agent({ keepAlive: false }); this.httpAgent = new http.Agent({ keepAlive: false }); } /** * Destroy HTTP agents to release sockets and allow process exit. */ destroy(): void { this.httpsAgent.destroy(); this.httpAgent.destroy(); } 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 { 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 { 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 { const maxRetries = 5; for (let attempt = 0; attempt <= maxRetries; attempt++) { const nonce = await this.getNonce(); const jwsOptions: { nonce: string; kid?: string; jwk?: Record } = { 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, ): Promise { return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const lib = isHttps ? https : http; const requestHeaders: Record = { ...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, agent: isHttps ? this.httpsAgent : this.httpAgent, }; 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 = {}; 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(); }); } }