Files
smartacme/ts/acme/acme.classes.http-client.ts

250 lines
7.4 KiB
TypeScript
Raw Normal View History

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<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,
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<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();
});
}
}