@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.
Files changed (50) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/acme/acme.classes.account.d.ts +20 -0
  3. package/dist_ts/acme/acme.classes.account.js +37 -0
  4. package/dist_ts/acme/acme.classes.challenge.d.ts +22 -0
  5. package/dist_ts/acme/acme.classes.challenge.js +36 -0
  6. package/dist_ts/acme/acme.classes.client.d.ts +72 -0
  7. package/dist_ts/acme/acme.classes.client.js +77 -0
  8. package/dist_ts/acme/acme.classes.crypto.d.ts +50 -0
  9. package/dist_ts/acme/acme.classes.crypto.js +180 -0
  10. package/dist_ts/acme/acme.classes.directory.d.ts +13 -0
  11. package/dist_ts/acme/acme.classes.directory.js +14 -0
  12. package/dist_ts/acme/acme.classes.error.d.ts +44 -0
  13. package/dist_ts/acme/acme.classes.error.js +41 -0
  14. package/dist_ts/acme/acme.classes.http-client.d.ts +42 -0
  15. package/dist_ts/acme/acme.classes.http-client.js +211 -0
  16. package/dist_ts/acme/acme.classes.order.d.ts +40 -0
  17. package/dist_ts/acme/acme.classes.order.js +100 -0
  18. package/dist_ts/acme/acme.interfaces.d.ts +64 -0
  19. package/dist_ts/acme/acme.interfaces.js +5 -0
  20. package/dist_ts/acme/index.d.ts +5 -0
  21. package/dist_ts/acme/index.js +5 -0
  22. package/dist_ts/certmanagers/mongo.js +1 -2
  23. package/dist_ts/plugins.d.ts +2 -7
  24. package/dist_ts/plugins.js +5 -11
  25. package/dist_ts/smartacme.classes.smartacme.d.ts +3 -1
  26. package/dist_ts/smartacme.classes.smartacme.js +48 -13
  27. package/npmextra.json +12 -6
  28. package/package.json +23 -24
  29. package/readme.hints.md +27 -3
  30. package/readme.md +245 -267
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/acme/acme.classes.account.ts +45 -0
  33. package/ts/acme/acme.classes.challenge.ts +45 -0
  34. package/ts/acme/acme.classes.client.ts +106 -0
  35. package/ts/acme/acme.classes.crypto.ts +220 -0
  36. package/ts/acme/acme.classes.directory.ts +13 -0
  37. package/ts/acme/acme.classes.error.ts +55 -0
  38. package/ts/acme/acme.classes.http-client.ts +249 -0
  39. package/ts/acme/acme.classes.order.ts +125 -0
  40. package/ts/acme/acme.interfaces.ts +74 -0
  41. package/ts/acme/index.ts +16 -0
  42. package/ts/certmanagers/mongo.ts +0 -1
  43. package/ts/plugins.ts +3 -14
  44. package/ts/smartacme.classes.smartacme.ts +49 -16
  45. package/dist_ts/certmanagers.d.ts +0 -42
  46. package/dist_ts/certmanagers.js +0 -86
  47. package/dist_ts/smartacme.classes.certmanager.d.ts +0 -43
  48. package/dist_ts/smartacme.classes.certmanager.js +0 -92
  49. package/dist_ts/smartacme.plugins.d.ts +0 -21
  50. 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
+ }
@@ -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';
@@ -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
- // @apiglobal scope
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
- // third party scope
52
- import * as acme from 'acme-client';
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.Client;
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 || (await plugins.acme.forge.createPrivateKey()).toString();
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.Client({
133
+ this.client = new plugins.acme.AcmeClient({
131
134
  directoryUrl: (() => {
132
135
  if (this.options.environment === 'production') {
133
- return plugins.acme.directory.letsencrypt.production;
136
+ return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
134
137
  } else {
135
- return plugins.acme.directory.letsencrypt.staging;
138
+ return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
136
139
  }
137
140
  })(),
138
- accountKey: this.privateKey,
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
- process.on('SIGINT', () => this.handleSignal('SIGINT'));
148
- process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
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.forge.createCsr({
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
- }