2026-02-15 20:20:46 +00:00
|
|
|
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;
|
2026-02-15 20:43:06 +00:00
|
|
|
private httpsAgent: https.Agent;
|
|
|
|
|
private httpAgent: http.Agent;
|
2026-02-15 20:20:46 +00:00
|
|
|
|
|
|
|
|
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
|
|
|
|
this.directoryUrl = directoryUrl;
|
|
|
|
|
this.accountKeyPem = accountKeyPem;
|
|
|
|
|
this.logger = logger;
|
2026-02-15 20:43:06 +00:00
|
|
|
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();
|
2026-02-15 20:20:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-02-15 20:43:06 +00:00
|
|
|
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
2026-02-15 20:20:46 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|