@push.rocks/smartacme 8.0.0 → 9.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/acme/acme.classes.account.d.ts +20 -0
- package/dist_ts/acme/acme.classes.account.js +37 -0
- package/dist_ts/acme/acme.classes.challenge.d.ts +22 -0
- package/dist_ts/acme/acme.classes.challenge.js +36 -0
- package/dist_ts/acme/acme.classes.client.d.ts +72 -0
- package/dist_ts/acme/acme.classes.client.js +77 -0
- package/dist_ts/acme/acme.classes.crypto.d.ts +50 -0
- package/dist_ts/acme/acme.classes.crypto.js +180 -0
- package/dist_ts/acme/acme.classes.directory.d.ts +13 -0
- package/dist_ts/acme/acme.classes.directory.js +14 -0
- package/dist_ts/acme/acme.classes.error.d.ts +44 -0
- package/dist_ts/acme/acme.classes.error.js +41 -0
- package/dist_ts/acme/acme.classes.http-client.d.ts +42 -0
- package/dist_ts/acme/acme.classes.http-client.js +211 -0
- package/dist_ts/acme/acme.classes.order.d.ts +40 -0
- package/dist_ts/acme/acme.classes.order.js +100 -0
- package/dist_ts/acme/acme.interfaces.d.ts +64 -0
- package/dist_ts/acme/acme.interfaces.js +5 -0
- package/dist_ts/acme/index.d.ts +5 -0
- package/dist_ts/acme/index.js +5 -0
- package/dist_ts/certmanagers/mongo.js +1 -2
- package/dist_ts/plugins.d.ts +2 -7
- package/dist_ts/plugins.js +5 -11
- package/dist_ts/smartacme.classes.smartacme.d.ts +3 -1
- package/dist_ts/smartacme.classes.smartacme.js +48 -13
- package/npmextra.json +12 -6
- package/package.json +23 -24
- package/readme.hints.md +27 -3
- package/readme.md +245 -267
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme.classes.account.ts +45 -0
- package/ts/acme/acme.classes.challenge.ts +45 -0
- package/ts/acme/acme.classes.client.ts +106 -0
- package/ts/acme/acme.classes.crypto.ts +220 -0
- package/ts/acme/acme.classes.directory.ts +13 -0
- package/ts/acme/acme.classes.error.ts +55 -0
- package/ts/acme/acme.classes.http-client.ts +249 -0
- package/ts/acme/acme.classes.order.ts +125 -0
- package/ts/acme/acme.interfaces.ts +74 -0
- package/ts/acme/index.ts +16 -0
- package/ts/certmanagers/mongo.ts +0 -1
- package/ts/plugins.ts +3 -14
- package/ts/smartacme.classes.smartacme.ts +49 -16
- package/dist_ts/certmanagers.d.ts +0 -42
- package/dist_ts/certmanagers.js +0 -86
- package/dist_ts/smartacme.classes.certmanager.d.ts +0 -43
- package/dist_ts/smartacme.classes.certmanager.js +0 -92
- package/dist_ts/smartacme.plugins.d.ts +0 -21
- package/dist_ts/smartacme.plugins.js +0 -28
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import * as https from 'node:https';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import { AcmeCrypto } from './acme.classes.crypto.js';
|
|
4
|
+
import { AcmeError } from './acme.classes.error.js';
|
|
5
|
+
import type { IAcmeDirectory, IAcmeHttpResponse } from './acme.interfaces.js';
|
|
6
|
+
|
|
7
|
+
export type TAcmeLogger = (level: string, message: string, data?: any) => void;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* JWS-signed HTTP transport for ACME protocol.
|
|
11
|
+
* Handles nonce management, bad-nonce retries, and signed requests.
|
|
12
|
+
*/
|
|
13
|
+
export class AcmeHttpClient {
|
|
14
|
+
private directoryUrl: string;
|
|
15
|
+
private accountKeyPem: string;
|
|
16
|
+
private directory: IAcmeDirectory | null = null;
|
|
17
|
+
private nonce: string | null = null;
|
|
18
|
+
public kid: string | null = null;
|
|
19
|
+
private logger?: TAcmeLogger;
|
|
20
|
+
private httpsAgent: https.Agent;
|
|
21
|
+
private httpAgent: http.Agent;
|
|
22
|
+
|
|
23
|
+
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
|
24
|
+
this.directoryUrl = directoryUrl;
|
|
25
|
+
this.accountKeyPem = accountKeyPem;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
this.httpsAgent = new https.Agent({ keepAlive: false });
|
|
28
|
+
this.httpAgent = new http.Agent({ keepAlive: false });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Destroy HTTP agents to release sockets and allow process exit.
|
|
33
|
+
*/
|
|
34
|
+
destroy(): void {
|
|
35
|
+
this.httpsAgent.destroy();
|
|
36
|
+
this.httpAgent.destroy();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private log(level: string, message: string, data?: any): void {
|
|
40
|
+
if (this.logger) {
|
|
41
|
+
this.logger(level, message, data);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* GET the ACME directory (cached after first call)
|
|
47
|
+
*/
|
|
48
|
+
async getDirectory(): Promise<IAcmeDirectory> {
|
|
49
|
+
if (this.directory) return this.directory;
|
|
50
|
+
const response = await this.httpRequest(this.directoryUrl, 'GET');
|
|
51
|
+
if (response.status !== 200) {
|
|
52
|
+
throw new AcmeError({
|
|
53
|
+
status: response.status,
|
|
54
|
+
type: response.data?.type || '',
|
|
55
|
+
detail: `Failed to fetch ACME directory`,
|
|
56
|
+
url: this.directoryUrl,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.directory = response.data as IAcmeDirectory;
|
|
60
|
+
return this.directory;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fetch a fresh nonce via HEAD to newNonce
|
|
65
|
+
*/
|
|
66
|
+
async getNonce(): Promise<string> {
|
|
67
|
+
if (this.nonce) {
|
|
68
|
+
const n = this.nonce;
|
|
69
|
+
this.nonce = null;
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
const dir = await this.getDirectory();
|
|
73
|
+
const response = await this.httpRequest(dir.newNonce, 'HEAD');
|
|
74
|
+
const nonce = response.headers['replay-nonce'];
|
|
75
|
+
if (!nonce) {
|
|
76
|
+
throw new Error('No replay-nonce header in newNonce response');
|
|
77
|
+
}
|
|
78
|
+
return nonce;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send a JWS-signed POST request to an ACME endpoint.
|
|
83
|
+
* Handles nonce rotation and bad-nonce retries (up to 5).
|
|
84
|
+
* payload=null means POST-as-GET.
|
|
85
|
+
*/
|
|
86
|
+
async signedRequest(
|
|
87
|
+
url: string,
|
|
88
|
+
payload: any | null,
|
|
89
|
+
options?: { useJwk?: boolean },
|
|
90
|
+
): Promise<IAcmeHttpResponse> {
|
|
91
|
+
const maxRetries = 5;
|
|
92
|
+
|
|
93
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
94
|
+
const nonce = await this.getNonce();
|
|
95
|
+
|
|
96
|
+
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
|
|
97
|
+
if (options?.useJwk) {
|
|
98
|
+
jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
|
|
99
|
+
} else if (this.kid) {
|
|
100
|
+
jwsOptions.kid = this.kid;
|
|
101
|
+
} else {
|
|
102
|
+
jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const jws = AcmeCrypto.createJws(this.accountKeyPem, url, payload, jwsOptions);
|
|
106
|
+
const body = JSON.stringify(jws);
|
|
107
|
+
|
|
108
|
+
const response = await this.httpRequest(url, 'POST', body, {
|
|
109
|
+
'Content-Type': 'application/jose+json',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Save nonce from response for reuse
|
|
113
|
+
if (response.headers['replay-nonce']) {
|
|
114
|
+
this.nonce = response.headers['replay-nonce'];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.log('debug', `ACME request: POST ${url} → ${response.status}`);
|
|
118
|
+
|
|
119
|
+
// Retry on bad-nonce
|
|
120
|
+
if (
|
|
121
|
+
response.status === 400 &&
|
|
122
|
+
response.data?.type === 'urn:ietf:params:acme:error:badNonce'
|
|
123
|
+
) {
|
|
124
|
+
this.log('debug', `Bad nonce on attempt ${attempt + 1}, retrying`);
|
|
125
|
+
if (attempt < maxRetries) {
|
|
126
|
+
this.nonce = null; // Force fresh nonce
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Throw on error responses
|
|
132
|
+
if (response.status >= 400) {
|
|
133
|
+
const retryAfterRaw = response.headers['retry-after'];
|
|
134
|
+
let retryAfter = 0;
|
|
135
|
+
if (retryAfterRaw) {
|
|
136
|
+
const parsed = parseInt(retryAfterRaw, 10);
|
|
137
|
+
if (!isNaN(parsed)) {
|
|
138
|
+
retryAfter = parsed;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const acmeError = new AcmeError({
|
|
143
|
+
status: response.status,
|
|
144
|
+
type: response.data?.type || '',
|
|
145
|
+
detail: response.data?.detail || JSON.stringify(response.data),
|
|
146
|
+
subproblems: response.data?.subproblems,
|
|
147
|
+
url,
|
|
148
|
+
retryAfter,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (acmeError.isRateLimited) {
|
|
152
|
+
this.log('warn', `RATE LIMITED: ${url} (HTTP ${response.status}), Retry-After: ${retryAfter}s`, {
|
|
153
|
+
type: acmeError.type,
|
|
154
|
+
detail: acmeError.detail,
|
|
155
|
+
retryAfter,
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
this.log('warn', `ACME error: ${url} (HTTP ${response.status})`, {
|
|
159
|
+
type: acmeError.type,
|
|
160
|
+
detail: acmeError.detail,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw acmeError;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return response;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
throw new Error('Max bad-nonce retries exceeded');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Raw HTTP request using native node:https
|
|
175
|
+
*/
|
|
176
|
+
private httpRequest(
|
|
177
|
+
url: string,
|
|
178
|
+
method: string,
|
|
179
|
+
body?: string,
|
|
180
|
+
headers?: Record<string, string>,
|
|
181
|
+
): Promise<IAcmeHttpResponse> {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const urlObj = new URL(url);
|
|
184
|
+
const isHttps = urlObj.protocol === 'https:';
|
|
185
|
+
const lib = isHttps ? https : http;
|
|
186
|
+
|
|
187
|
+
const requestHeaders: Record<string, string | number> = {
|
|
188
|
+
...headers,
|
|
189
|
+
'User-Agent': 'smartacme-acme-client/1.0',
|
|
190
|
+
};
|
|
191
|
+
if (body) {
|
|
192
|
+
requestHeaders['Content-Length'] = Buffer.byteLength(body);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const options: https.RequestOptions = {
|
|
196
|
+
hostname: urlObj.hostname,
|
|
197
|
+
port: urlObj.port || (isHttps ? 443 : 80),
|
|
198
|
+
path: urlObj.pathname + urlObj.search,
|
|
199
|
+
method,
|
|
200
|
+
headers: requestHeaders,
|
|
201
|
+
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const req = lib.request(options, (res) => {
|
|
205
|
+
const chunks: Buffer[] = [];
|
|
206
|
+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
207
|
+
res.on('end', () => {
|
|
208
|
+
const responseBody = Buffer.concat(chunks).toString('utf-8');
|
|
209
|
+
|
|
210
|
+
// Normalize headers to lowercase single-value
|
|
211
|
+
const responseHeaders: Record<string, string> = {};
|
|
212
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
213
|
+
if (typeof value === 'string') {
|
|
214
|
+
responseHeaders[key.toLowerCase()] = value;
|
|
215
|
+
} else if (Array.isArray(value)) {
|
|
216
|
+
responseHeaders[key.toLowerCase()] = value[0];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Parse JSON if applicable, otherwise return raw string
|
|
221
|
+
let data: any;
|
|
222
|
+
const contentType = responseHeaders['content-type'] || '';
|
|
223
|
+
if (contentType.includes('json')) {
|
|
224
|
+
try {
|
|
225
|
+
data = JSON.parse(responseBody);
|
|
226
|
+
} catch {
|
|
227
|
+
data = responseBody;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
data = responseBody;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
resolve({
|
|
234
|
+
status: res.statusCode || 0,
|
|
235
|
+
headers: responseHeaders,
|
|
236
|
+
data,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
req.on('error', reject);
|
|
242
|
+
req.setTimeout(30000, () => {
|
|
243
|
+
req.destroy(new Error('Request timeout'));
|
|
244
|
+
});
|
|
245
|
+
if (body) req.write(body);
|
|
246
|
+
req.end();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { AcmeCrypto } from './acme.classes.crypto.js';
|
|
2
|
+
import { AcmeError } from './acme.classes.error.js';
|
|
3
|
+
import type { AcmeHttpClient } from './acme.classes.http-client.js';
|
|
4
|
+
import type {
|
|
5
|
+
IAcmeAuthorization,
|
|
6
|
+
IAcmeIdentifier,
|
|
7
|
+
IAcmeOrder,
|
|
8
|
+
} from './acme.interfaces.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ACME order lifecycle management.
|
|
12
|
+
* Handles order creation, authorization retrieval, finalization, and certificate download.
|
|
13
|
+
*/
|
|
14
|
+
export class AcmeOrderManager {
|
|
15
|
+
private httpClient: AcmeHttpClient;
|
|
16
|
+
|
|
17
|
+
constructor(httpClient: AcmeHttpClient) {
|
|
18
|
+
this.httpClient = httpClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a new ACME order for the given identifiers
|
|
23
|
+
*/
|
|
24
|
+
async create(opts: { identifiers: IAcmeIdentifier[] }): Promise<IAcmeOrder> {
|
|
25
|
+
const dir = await this.httpClient.getDirectory();
|
|
26
|
+
const response = await this.httpClient.signedRequest(dir.newOrder, {
|
|
27
|
+
identifiers: opts.identifiers,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const order = response.data as IAcmeOrder;
|
|
31
|
+
// Capture order URL from Location header
|
|
32
|
+
order.url = response.headers['location'] || '';
|
|
33
|
+
return order;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Retrieve all authorizations for an order (POST-as-GET each authorization URL)
|
|
38
|
+
*/
|
|
39
|
+
async getAuthorizations(order: IAcmeOrder): Promise<IAcmeAuthorization[]> {
|
|
40
|
+
const authorizations: IAcmeAuthorization[] = [];
|
|
41
|
+
for (const authzUrl of order.authorizations) {
|
|
42
|
+
const response = await this.httpClient.signedRequest(authzUrl, null);
|
|
43
|
+
authorizations.push(response.data as IAcmeAuthorization);
|
|
44
|
+
}
|
|
45
|
+
return authorizations;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Finalize an order by submitting the CSR.
|
|
50
|
+
* Waits for the order to reach 'valid' status.
|
|
51
|
+
* Mutates the order object with updated status and certificate URL.
|
|
52
|
+
*/
|
|
53
|
+
async finalize(order: IAcmeOrder, csrPem: string): Promise<void> {
|
|
54
|
+
// Convert PEM CSR to base64url DER for ACME
|
|
55
|
+
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
|
56
|
+
const csrB64url = csrDer.toString('base64url');
|
|
57
|
+
|
|
58
|
+
const response = await this.httpClient.signedRequest(order.finalize, { csr: csrB64url });
|
|
59
|
+
|
|
60
|
+
// Update order with response data
|
|
61
|
+
const updatedOrder = response.data;
|
|
62
|
+
order.status = updatedOrder.status;
|
|
63
|
+
if (updatedOrder.certificate) {
|
|
64
|
+
order.certificate = updatedOrder.certificate;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// If not yet valid, poll until it is
|
|
68
|
+
if (order.status !== 'valid' && order.url) {
|
|
69
|
+
const finalOrder = await this.waitForValidStatus({ url: order.url });
|
|
70
|
+
order.status = finalOrder.status;
|
|
71
|
+
order.certificate = finalOrder.certificate;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Download the certificate chain (PEM) from the order's certificate URL
|
|
77
|
+
*/
|
|
78
|
+
async getCertificate(order: IAcmeOrder): Promise<string> {
|
|
79
|
+
if (!order.certificate) {
|
|
80
|
+
throw new Error('Order does not have a certificate URL - finalize first');
|
|
81
|
+
}
|
|
82
|
+
const response = await this.httpClient.signedRequest(order.certificate, null);
|
|
83
|
+
// Certificate chain is returned as PEM text
|
|
84
|
+
return typeof response.data === 'string' ? response.data : response.data.toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Poll an ACME resource (order or challenge) until it reaches 'valid' or 'ready' status.
|
|
89
|
+
* Uses exponential backoff with Retry-After header support.
|
|
90
|
+
*/
|
|
91
|
+
async waitForValidStatus(
|
|
92
|
+
item: { url: string },
|
|
93
|
+
opts?: { maxAttempts?: number; initialDelayMs?: number },
|
|
94
|
+
): Promise<any> {
|
|
95
|
+
const maxAttempts = opts?.maxAttempts ?? 30;
|
|
96
|
+
const initialDelay = opts?.initialDelayMs ?? 1000;
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
99
|
+
const response = await this.httpClient.signedRequest(item.url, null);
|
|
100
|
+
const body = response.data;
|
|
101
|
+
|
|
102
|
+
if (body.status === 'valid' || body.status === 'ready') {
|
|
103
|
+
return body;
|
|
104
|
+
}
|
|
105
|
+
if (body.status === 'invalid') {
|
|
106
|
+
const challengeError = body.challenges?.find((c: any) => c.error)?.error;
|
|
107
|
+
throw new AcmeError({
|
|
108
|
+
status: 0,
|
|
109
|
+
type: challengeError?.type || 'urn:ietf:params:acme:error:rejectedIdentifier',
|
|
110
|
+
detail: challengeError?.detail || JSON.stringify(body),
|
|
111
|
+
subproblems: challengeError?.subproblems,
|
|
112
|
+
url: item.url,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Respect Retry-After header, otherwise exponential backoff
|
|
117
|
+
const retryAfter = parseInt(response.headers['retry-after'] || '0', 10);
|
|
118
|
+
const delay =
|
|
119
|
+
retryAfter > 0 ? retryAfter * 1000 : Math.min(initialDelay * Math.pow(2, i), 30000);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error(`Timeout waiting for valid status after ${maxAttempts} attempts`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME Protocol interfaces per RFC 8555
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface IAcmeDirectory {
|
|
6
|
+
newNonce: string;
|
|
7
|
+
newAccount: string;
|
|
8
|
+
newOrder: string;
|
|
9
|
+
newAuthz?: string;
|
|
10
|
+
revokeCert?: string;
|
|
11
|
+
keyChange?: string;
|
|
12
|
+
meta?: IAcmeDirectoryMeta;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IAcmeDirectoryMeta {
|
|
16
|
+
termsOfService?: string;
|
|
17
|
+
website?: string;
|
|
18
|
+
caaIdentities?: string[];
|
|
19
|
+
externalAccountRequired?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IAcmeIdentifier {
|
|
23
|
+
type: 'dns';
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IAcmeAccount {
|
|
28
|
+
status: string;
|
|
29
|
+
contact?: string[];
|
|
30
|
+
termsOfServiceAgreed?: boolean;
|
|
31
|
+
orders?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IAcmeAccountCreateRequest {
|
|
35
|
+
termsOfServiceAgreed: boolean;
|
|
36
|
+
contact?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IAcmeOrder {
|
|
40
|
+
url: string;
|
|
41
|
+
status: string;
|
|
42
|
+
expires?: string;
|
|
43
|
+
identifiers: IAcmeIdentifier[];
|
|
44
|
+
authorizations: string[];
|
|
45
|
+
finalize: string;
|
|
46
|
+
certificate?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IAcmeAuthorization {
|
|
50
|
+
identifier: IAcmeIdentifier;
|
|
51
|
+
status: string;
|
|
52
|
+
expires?: string;
|
|
53
|
+
challenges: IAcmeChallenge[];
|
|
54
|
+
wildcard?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface IAcmeChallenge {
|
|
58
|
+
type: string;
|
|
59
|
+
url: string;
|
|
60
|
+
status: string;
|
|
61
|
+
token: string;
|
|
62
|
+
validated?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IAcmeCsrOptions {
|
|
66
|
+
commonName: string;
|
|
67
|
+
altNames?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IAcmeHttpResponse {
|
|
71
|
+
status: number;
|
|
72
|
+
headers: Record<string, string>;
|
|
73
|
+
data: any;
|
|
74
|
+
}
|
package/ts/acme/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { AcmeClient, type IAcmeClientOptions } from './acme.classes.client.js';
|
|
2
|
+
export { AcmeCrypto } from './acme.classes.crypto.js';
|
|
3
|
+
export { AcmeError } from './acme.classes.error.js';
|
|
4
|
+
export { ACME_DIRECTORY_URLS } from './acme.classes.directory.js';
|
|
5
|
+
export type {
|
|
6
|
+
IAcmeDirectory,
|
|
7
|
+
IAcmeDirectoryMeta,
|
|
8
|
+
IAcmeIdentifier,
|
|
9
|
+
IAcmeAccount,
|
|
10
|
+
IAcmeAccountCreateRequest,
|
|
11
|
+
IAcmeOrder,
|
|
12
|
+
IAcmeAuthorization,
|
|
13
|
+
IAcmeChallenge,
|
|
14
|
+
IAcmeCsrOptions,
|
|
15
|
+
IAcmeHttpResponse,
|
|
16
|
+
} from './acme.interfaces.js';
|
package/ts/certmanagers/mongo.ts
CHANGED
|
@@ -14,7 +14,6 @@ export class MongoCertManager implements ICertManager {
|
|
|
14
14
|
*/
|
|
15
15
|
constructor(mongoDescriptor: plugins.smartdata.IMongoDescriptor) {
|
|
16
16
|
this.db = new plugins.smartdata.SmartdataDb(mongoDescriptor);
|
|
17
|
-
// Use a single EasyStore document to hold all certs keyed by domainName
|
|
18
17
|
this.store = new plugins.smartdata.EasyStore<Record<string, any>>(
|
|
19
18
|
'smartacme-certs',
|
|
20
19
|
this.db,
|
package/ts/plugins.ts
CHANGED
|
@@ -9,21 +9,13 @@ import * as cloudflare from '@apiclient.xyz/cloudflare';
|
|
|
9
9
|
|
|
10
10
|
export { cloudflare };
|
|
11
11
|
|
|
12
|
-
// @
|
|
13
|
-
import * as typedserver from '@api.global/typedserver';
|
|
14
|
-
|
|
15
|
-
export { typedserver };
|
|
16
|
-
|
|
17
|
-
// @pushrocks scope
|
|
12
|
+
// @push.rocks scope
|
|
18
13
|
import * as lik from '@push.rocks/lik';
|
|
19
14
|
import * as smartdata from '@push.rocks/smartdata';
|
|
20
15
|
import * as smartdelay from '@push.rocks/smartdelay';
|
|
21
16
|
import * as smartdnsClient from '@push.rocks/smartdns/client';
|
|
22
|
-
import * as smartfile from '@push.rocks/smartfile';
|
|
23
17
|
import * as smartlog from '@push.rocks/smartlog';
|
|
24
18
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
|
25
|
-
import * as smartpromise from '@push.rocks/smartpromise';
|
|
26
|
-
import * as smartrequest from '@push.rocks/smartrequest';
|
|
27
19
|
import * as smartunique from '@push.rocks/smartunique';
|
|
28
20
|
import * as smartstring from '@push.rocks/smartstring';
|
|
29
21
|
import * as smarttime from '@push.rocks/smarttime';
|
|
@@ -33,11 +25,8 @@ export {
|
|
|
33
25
|
smartdata,
|
|
34
26
|
smartdelay,
|
|
35
27
|
smartdnsClient,
|
|
36
|
-
smartfile,
|
|
37
28
|
smartlog,
|
|
38
29
|
smartnetwork,
|
|
39
|
-
smartpromise,
|
|
40
|
-
smartrequest,
|
|
41
30
|
smartunique,
|
|
42
31
|
smartstring,
|
|
43
32
|
smarttime,
|
|
@@ -48,8 +37,8 @@ import * as tsclass from '@tsclass/tsclass';
|
|
|
48
37
|
|
|
49
38
|
export { tsclass };
|
|
50
39
|
|
|
51
|
-
//
|
|
52
|
-
import * as acme from 'acme
|
|
40
|
+
// acme protocol (custom implementation)
|
|
41
|
+
import * as acme from './acme/index.js';
|
|
53
42
|
|
|
54
43
|
export { acme };
|
|
55
44
|
// local handlers for challenge types
|
|
@@ -54,7 +54,7 @@ export class SmartAcme {
|
|
|
54
54
|
private options: ISmartAcmeOptions;
|
|
55
55
|
|
|
56
56
|
// the acme client
|
|
57
|
-
private client: plugins.acme.
|
|
57
|
+
private client: plugins.acme.AcmeClient;
|
|
58
58
|
private smartdns = new plugins.smartdnsClient.Smartdns({});
|
|
59
59
|
public logger: plugins.smartlog.Smartlog;
|
|
60
60
|
|
|
@@ -77,6 +77,9 @@ export class SmartAcme {
|
|
|
77
77
|
private challengePriority: string[];
|
|
78
78
|
// Map for coordinating concurrent certificate requests
|
|
79
79
|
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
|
|
80
|
+
// bound signal handlers so they can be removed on stop()
|
|
81
|
+
private boundSigintHandler: (() => void) | null = null;
|
|
82
|
+
private boundSigtermHandler: (() => void) | null = null;
|
|
80
83
|
|
|
81
84
|
constructor(optionsArg: ISmartAcmeOptions) {
|
|
82
85
|
this.options = optionsArg;
|
|
@@ -114,7 +117,7 @@ export class SmartAcme {
|
|
|
114
117
|
*/
|
|
115
118
|
public async start() {
|
|
116
119
|
this.privateKey =
|
|
117
|
-
this.options.accountPrivateKey ||
|
|
120
|
+
this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey();
|
|
118
121
|
|
|
119
122
|
// Initialize certificate manager
|
|
120
123
|
if (!this.options.certManager) {
|
|
@@ -127,15 +130,18 @@ export class SmartAcme {
|
|
|
127
130
|
this.certmatcher = new SmartacmeCertMatcher();
|
|
128
131
|
|
|
129
132
|
// ACME Client
|
|
130
|
-
this.client = new plugins.acme.
|
|
133
|
+
this.client = new plugins.acme.AcmeClient({
|
|
131
134
|
directoryUrl: (() => {
|
|
132
135
|
if (this.options.environment === 'production') {
|
|
133
|
-
return plugins.acme.
|
|
136
|
+
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
|
|
134
137
|
} else {
|
|
135
|
-
return plugins.acme.
|
|
138
|
+
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
|
|
136
139
|
}
|
|
137
140
|
})(),
|
|
138
|
-
|
|
141
|
+
accountKeyPem: this.privateKey,
|
|
142
|
+
logger: (level, message, data) => {
|
|
143
|
+
this.logger.log(level as any, message, data);
|
|
144
|
+
},
|
|
139
145
|
});
|
|
140
146
|
|
|
141
147
|
/* Register account */
|
|
@@ -143,20 +149,39 @@ export class SmartAcme {
|
|
|
143
149
|
termsOfServiceAgreed: true,
|
|
144
150
|
contact: [`mailto:${this.options.accountEmail}`],
|
|
145
151
|
});
|
|
146
|
-
// Setup graceful shutdown handlers
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// Setup graceful shutdown handlers (store references for removal in stop())
|
|
153
|
+
this.boundSigintHandler = () => this.handleSignal('SIGINT');
|
|
154
|
+
this.boundSigtermHandler = () => this.handleSignal('SIGTERM');
|
|
155
|
+
process.on('SIGINT', this.boundSigintHandler);
|
|
156
|
+
process.on('SIGTERM', this.boundSigtermHandler);
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/**
|
|
152
160
|
* Stops the SmartAcme instance and closes certificate store connections.
|
|
153
161
|
*/
|
|
154
162
|
public async stop() {
|
|
163
|
+
// Remove signal handlers so the process can exit cleanly
|
|
164
|
+
if (this.boundSigintHandler) {
|
|
165
|
+
process.removeListener('SIGINT', this.boundSigintHandler);
|
|
166
|
+
this.boundSigintHandler = null;
|
|
167
|
+
}
|
|
168
|
+
if (this.boundSigtermHandler) {
|
|
169
|
+
process.removeListener('SIGTERM', this.boundSigtermHandler);
|
|
170
|
+
this.boundSigtermHandler = null;
|
|
171
|
+
}
|
|
172
|
+
// Destroy ACME HTTP transport (closes keep-alive sockets)
|
|
173
|
+
if (this.client) {
|
|
174
|
+
this.client.destroy();
|
|
175
|
+
}
|
|
176
|
+
// Destroy DNS client (kills Rust bridge child process if spawned)
|
|
177
|
+
if (this.smartdns) {
|
|
178
|
+
this.smartdns.destroy();
|
|
179
|
+
}
|
|
155
180
|
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
|
|
156
181
|
await (this.certmanager as any).close();
|
|
157
182
|
}
|
|
158
183
|
}
|
|
159
|
-
/** Retry helper with exponential backoff */
|
|
184
|
+
/** Retry helper with exponential backoff and AcmeError awareness */
|
|
160
185
|
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
|
|
161
186
|
let attempt = 0;
|
|
162
187
|
let delay = this.retryOptions.minTimeoutMs;
|
|
@@ -164,6 +189,19 @@ export class SmartAcme {
|
|
|
164
189
|
try {
|
|
165
190
|
return await operation();
|
|
166
191
|
} catch (err) {
|
|
192
|
+
// Check if it's a non-retryable ACME error — throw immediately
|
|
193
|
+
if (err instanceof plugins.acme.AcmeError) {
|
|
194
|
+
if (!err.isRetryable) {
|
|
195
|
+
await this.logger.log('error', `Operation ${operationName} failed with non-retryable error (${err.type}, HTTP ${err.status}) at ${err.url}`, err);
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
// For rate-limited errors, use server-specified Retry-After delay
|
|
199
|
+
if (err.isRateLimited && err.retryAfter > 0) {
|
|
200
|
+
delay = err.retryAfter * 1000;
|
|
201
|
+
await this.logger.log('warn', `Operation ${operationName} rate-limited, Retry-After: ${err.retryAfter}s`, err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
167
205
|
attempt++;
|
|
168
206
|
if (attempt > this.retryOptions.retries) {
|
|
169
207
|
await this.logger.log('error', `Operation ${operationName} failed after ${attempt} attempts`, err);
|
|
@@ -347,11 +385,6 @@ export class SmartAcme {
|
|
|
347
385
|
this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
|
|
348
386
|
await plugins.smartdelay.delayFor(60000);
|
|
349
387
|
}
|
|
350
|
-
// Official ACME verification (ensures challenge is publicly reachable)
|
|
351
|
-
await this.retry(
|
|
352
|
-
() => this.client.verifyChallenge(authz, selectedChallengeArg),
|
|
353
|
-
`${type}.verifyChallenge`,
|
|
354
|
-
);
|
|
355
388
|
// Notify ACME server to complete the challenge
|
|
356
389
|
await this.retry(
|
|
357
390
|
() => this.client.completeChallenge(selectedChallengeArg),
|
|
@@ -399,7 +432,7 @@ export class SmartAcme {
|
|
|
399
432
|
}
|
|
400
433
|
}
|
|
401
434
|
|
|
402
|
-
const [key, csr] = await plugins.acme.
|
|
435
|
+
const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({
|
|
403
436
|
commonName,
|
|
404
437
|
altNames: csrDomains,
|
|
405
438
|
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import * as plugins from './smartacme.plugins.js';
|
|
2
|
-
import type { ICertManager } from './interfaces/certmanager.js';
|
|
3
|
-
import { SmartacmeCert } from './smartacme.classes.cert.js';
|
|
4
|
-
/**
|
|
5
|
-
* In-memory certificate manager for mongoless mode.
|
|
6
|
-
* Stores certificates in memory only and does not connect to MongoDB.
|
|
7
|
-
*/
|
|
8
|
-
export declare class MemoryCertManager implements ICertManager {
|
|
9
|
-
interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
|
|
10
|
-
private certs;
|
|
11
|
-
constructor();
|
|
12
|
-
init(): Promise<void>;
|
|
13
|
-
retrieveCertificate(domainName: string): Promise<SmartacmeCert | null>;
|
|
14
|
-
storeCertificate(cert: SmartacmeCert): Promise<void>;
|
|
15
|
-
deleteCertificate(domainName: string): Promise<void>;
|
|
16
|
-
close(): Promise<void>;
|
|
17
|
-
/**
|
|
18
|
-
* Wipe all certificates from the in-memory store (for testing)
|
|
19
|
-
*/
|
|
20
|
-
wipe(): Promise<void>;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* MongoDB-backed certificate manager using EasyStore from smartdata.
|
|
24
|
-
*/
|
|
25
|
-
export declare class MongoCertManager implements ICertManager {
|
|
26
|
-
interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
|
|
27
|
-
private db;
|
|
28
|
-
private store;
|
|
29
|
-
/**
|
|
30
|
-
* @param mongoDescriptor MongoDB connection settings
|
|
31
|
-
*/
|
|
32
|
-
constructor(mongoDescriptor: plugins.smartdata.IMongoDescriptor);
|
|
33
|
-
init(): Promise<void>;
|
|
34
|
-
retrieveCertificate(domainName: string): Promise<SmartacmeCert | null>;
|
|
35
|
-
storeCertificate(cert: SmartacmeCert): Promise<void>;
|
|
36
|
-
deleteCertificate(domainName: string): Promise<void>;
|
|
37
|
-
close(): Promise<void>;
|
|
38
|
-
/**
|
|
39
|
-
* Wipe all certificates from the persistent store (for integration testing)
|
|
40
|
-
*/
|
|
41
|
-
wipe(): Promise<void>;
|
|
42
|
-
}
|