@small-tech/auto-encrypt 4.2.0 → 5.0.0

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/index.js CHANGED
@@ -1,16 +1,21 @@
1
1
  /**
2
- * Automatically provisions and renews Let’s Encrypt™ TLS certificates for
3
- * Node.js® https servers (including Express.js, etc.)
4
- *
5
- * Implements the subset of RFC 8555 – Automatic Certificate Management
6
- * Environment (ACME) necessary for a Node.js https server to provision TLS
7
- * certificates from Let’s Encrypt using the HTTP-01 challenge on first
8
- * hit of an HTTPS route via use of the Server Name Indication (SNI) callback.
9
- *
10
- * @module @small-tech/auto-encrypt
11
- * @copyright © 2020 Aral Balkan, Small Technology Foundation.
12
- * @license AGPLv3 or later.
2
+ Automatically provisions and renews Let’s Encrypt™ TLS certificates for Node.js® https servers (as used in @small-tech/https and Kitten.)
3
+
4
+ Implements the subset of RFC 8555 – Automatic Certificate Management Environment (ACME) – necessary for a Node.js https server to provision TLS certificates from Let’s Encrypt using the HTTP-01 challenge.
5
+
6
+ The certificates are provisioned, if necessary, at time of server creation, prior to server start with hourly renewal checks and automatic renewals.
7
+
8
+ Auto Encrypt uses the new Let’s Encrypt `shortlived` profile *exclusively*, which means certificates are valid for (and renewed before) 160 hours. This should occur at around the 80 hour mark of the certificate’s lifespan, in accordance with data returned by ACME Renewal Information (ARI).
9
+
10
+ Auto Encrypt also implements automatic support for obtaining certificates on IPv4 and IPv6 addresses to enable the use of Web Numbers on the Small Web (see https://ar.al/2025/06/25/web-numbers/).
11
+
12
+ Remember that Auto Encrypt is for the Small Web (peer-to-peer Web). You might find it useful for everyday web purposes also but it is specifically focused for Small Web use cases and explicitly does not aim to be a generic or exhaustive ACME client.
13
+
14
+ @module @small-tech/auto-encrypt
15
+ @copyright © 2020-present Aral Balkan, Small Technology Foundation.
16
+ @license AGPLv3 or later.
13
17
  */
18
+
14
19
  import os from 'os'
15
20
  import util from 'util'
16
21
  import https from 'https'
@@ -18,118 +23,181 @@ import monkeyPatchTls from './lib/staging/monkeyPatchTls.js'
18
23
  import LetsEncryptServer from './lib/LetsEncryptServer.js'
19
24
  import Configuration from './lib/Configuration.js'
20
25
  import Certificate from './lib/Certificate.js'
26
+ import AcmeRequest from './lib/AcmeRequest.js'
21
27
  import Pluralise from './lib/util/Pluralise.js'
22
28
  import Throws from './lib/util/Throws.js'
23
29
  import HttpServer from './lib/HttpServer.js'
30
+ import IPAddresses from './lib/IPAddresses.js'
24
31
  import log from './lib/util/log.js'
25
32
 
26
- // This reverts IP address sort order to pre-Node 17 behaviour.
27
- // See https://github.com/nodejs/node/issues/40537
28
- import dns from 'node:dns'
29
- dns.setDefaultResultOrder('ipv4first')
33
+ // Use module-level await to ensure we have the IP address information we need.
34
+ const ipAddresses = await IPAddresses.getInstanceAsync()
30
35
 
31
36
  // Custom errors thrown by the autoEncrypt function.
32
37
  const throws = new Throws({
33
38
  [Symbol.for('BusyProvisioningCertificateError')]:
34
39
  () => 'We’re busy provisioning TLS certificates and rejecting all other calls at the moment.',
35
40
 
41
+ [Symbol.for('IPv4AddressNotFound')]:
42
+ () => 'IPv4 certificate requested but could not automatically find machine’s IPv4 address',
43
+
44
+ [Symbol.for('IPv6AddressNotFound')]:
45
+ () => 'IPv6 certificates requested but could not automatically find any stable IPv6 addresses',
46
+
36
47
  [Symbol.for('SNIIgnoreUnsupportedDomainError')]:
37
- (serverName, domains) => {
48
+ (/** @type {string} */ serverName, /** @type {Array<string>} */ domains) => {
38
49
  return `SNI: Not responding to request for unsupported domain ${serverName} (valid ${Pluralise.word('domain', domains)} ${Pluralise.isAre(domains)} ${domains}).`
39
50
  }
40
51
  })
41
52
 
42
53
  /**
43
- * Auto Encrypt is a static class. Please do not instantiate.
44
- *
45
- * Use: AutoEncrypt.https.createServer(…)
46
- *
47
- * @alias module:@small-tech/auto-encrypt
48
- * @hideconstructor
49
- */
54
+ @typedef {import('./index.d.ts').AutoEncryptedServer} AutoEncryptedServer
55
+ */
56
+
57
+ /**
58
+ Auto Encrypt is a static class. Please do not instantiate.
59
+
60
+ Use: AutoEncrypt.https.createServer(…)
61
+
62
+ @alias module:@small-tech/auto-encrypt
63
+ @hideconstructor
64
+ */
50
65
  export default class AutoEncrypt {
66
+ /** @type { AutoEncryptedServer } */
67
+ static server = null
68
+
69
+ /** @type { import('./lib/LetsEncryptServer.js').default | null } */
51
70
  static letsEncryptServer = null
71
+
72
+ /** @type { string[] | null } */
52
73
  static defaultDomains = null
74
+
75
+ /** @type { string[] | null } */
53
76
  static domains = null
77
+
78
+ /** @type { string | null } */
54
79
  static settingsPath = null
80
+
81
+ /** @type { ((req: import('http').IncomingMessage, res: import('http').ServerResponse) => void) | null } */
55
82
  static listener = null
83
+
84
+ /** @type { Certificate | null } */
56
85
  static certificate = null
57
86
 
58
87
  /**
59
- * Enumeration.
60
- *
61
- * @type {LetsEncryptServer.type}
62
- * @readonly
63
- * @static
64
- */
88
+ Enumeration.
89
+
90
+ @type {LetsEncryptServer.type}
91
+ @readonly
92
+ @static
93
+ */
65
94
  static serverType = LetsEncryptServer.type
66
95
 
67
96
  /**
68
- * By aliasing the https property to the AutoEncrypt static class itself, we enable
69
- * people to add AutoEncrypt to their existing apps by requiring the module
70
- * and prefixing their https.createServer(…) line with AutoEncrypt:
71
- *
72
- * @example import AutoEncrypt from '@small-tech/auto-encrypt'
73
- * const server = AutoEncrypt.https.createServer()
74
- *
75
- * @static
76
- */
97
+ By aliasing the https property to the AutoEncrypt static class itself, we enable
98
+ people to add AutoEncrypt to their existing apps by requiring the module
99
+ and prefixing their https.createServer(…) line with await AutoEncrypt:
100
+
101
+ @example import AutoEncrypt from '@small-tech/auto-encrypt'
102
+ const server = await AutoEncrypt.https.createServer()
103
+
104
+ @static
105
+ */
77
106
  static get https () { return AutoEncrypt }
78
107
 
79
108
  /**
80
- * Automatically manages Let’s Encrypt certificate provisioning and renewal for Node.js
81
- * https servers using the HTTP-01 challenge on first hit of an HTTPS route via use of
82
- * the Server Name Indication (SNI) callback.
83
- *
84
- * @static
85
- * @param {Object} [options] Optional HTTPS options object with optional additional
86
- * Auto Encrypt-specific configuration settings.
87
- * @param {String[]} [options.domains] Domain names to provision TLS certificates for. If missing, defaults to
88
- * the hostname of the current computer and its www prefixed subdomain.
89
- * @param {Enum} [options.serverType=AutoEncrypt.serverType.PRODUCTION] Let’s Encrypt server type to use.
90
- * AutoEncrypt.serverType.PRODUCTION, ….STAGING,
91
- * or ….PEBBLE (see LetsEncryptServer.type).
92
- * @param {String} [options.settingsPath=~/.small-tech.org/auto-encrypt/] Path to save certificates/keys to.
93
- *
94
- * @returns {https.Server} The server instance returned by Node’s https.createServer() method.
95
- */
96
- static createServer(_options, _listener) {
109
+ Automatically manages Let’s Encrypt certificate provisioning and renewal for Node.js
110
+ https servers using the HTTP-01 challenge on first hit of an HTTPS route via use of
111
+ the Server Name Indication (SNI) callback.
112
+
113
+ @static
114
+
115
+ @param {import('tls').SecureContextOptions & {
116
+ domains?: Array<string>,
117
+ serverType?: number,
118
+ settingsPath?: string,
119
+ ipv4?: boolean,
120
+ ipv6?: boolean,
121
+ ipOnly?: boolean,
122
+ SNICallback?: (serverName: string, callback: (...args: any[]) => void) => Promise<void> | void
123
+ } | ((...any: any[]) => void)} _options
124
+
125
+ @param {(...any: any[]) => void} [_listener]
126
+
127
+ @returns {Promise<import('./index.d.ts').AutoEncryptedServer>} A promise for the server instance (a monkey-patched https.Server similar to the one returned by Node’s https.createServer() method).
128
+ */
129
+ static async createServer(_options, _listener) {
130
+ // Auto Encrypt only supports one server per Node process. As such, if the server already exists, return the existing instance.
131
+ if (this.server !== null) {
132
+ log(' ☝️ ❨auto-encrypt❩ Returning reference to existing server (there can be only one… per process).')
133
+ return this.server
134
+ }
135
+
97
136
  // The first parameter is optional. If omitted, the first argument, if any, is treated as the request listener.
98
137
  if (typeof _options === 'function') {
99
138
  _listener = _options
100
139
  _options = {}
101
140
  }
102
141
 
103
- const defaultStagingAndProductionDomains = [os.hostname(), `www.${os.hostname()}`]
104
- const defaultPebbleDomains = ['localhost', 'pebble']
142
+ const defaultPebbleDomains = []
105
143
  const options = _options || {}
106
144
  const letsEncryptServer = new LetsEncryptServer(options.serverType || LetsEncryptServer.type.PRODUCTION)
107
145
  const listener = _listener || null
108
146
  const settingsPath = options.settingsPath || null
109
147
 
110
- //
111
- // Ignore passed domains (if any) if we’re using pebble as we can only issue for localhost and pebble.
112
- //
148
+ let defaultStagingAndProductionDomains = []
149
+ if (!options.ipOnly) {
150
+ defaultStagingAndProductionDomains.push(os.hostname())
151
+ defaultPebbleDomains.push('localhost')
152
+ defaultPebbleDomains.push('pebble')
153
+ }
154
+
155
+ if (options.ipv4 === true) {
156
+ if (letsEncryptServer.type === LetsEncryptServer.type.PEBBLE) {
157
+ const localIPv4Address = '127.0.0.1'
158
+ defaultPebbleDomains.push(localIPv4Address)
159
+ log(` 📍 ❨auto-encrypt❩ Will provision TLS certificate from Pebble server for local IPv4 address (${localIPv4Address})`)
160
+ } else {
161
+ if (ipAddresses.hasIPv4Address) {
162
+ const ipv4Address = ipAddresses.ipv4Address
163
+ defaultStagingAndProductionDomains.push(ipv4Address)
164
+ log(` 📍 ❨auto-encrypt❩ Will provision TLS certificate for detected IPv4 address: ${ipv4Address}`)
165
+ } else {
166
+ throws.error(Symbol.for('IPv4AddressNotFound'))
167
+ }
168
+ }
169
+ }
170
+
171
+ if (options.ipv6 === true) {
172
+ if (letsEncryptServer.type === LetsEncryptServer.type.PEBBLE) {
173
+ // IPv6: Pebble.
174
+ const localIPv6Address = '::1'
175
+ defaultPebbleDomains.push(localIPv6Address)
176
+ log(` 📍 ❨auto-encrypt❩ Will provision TLS certificate from Pebble server for local IPv6 address (${localIPv6Address})`)
177
+ } else {
178
+ // IPv6: Staging and production.
179
+ const ipv6Addresses = ipAddresses.ipv6Addresses
180
+ if (ipv6Addresses.length > 0) {
181
+ defaultStagingAndProductionDomains = defaultStagingAndProductionDomains.concat(ipv6Addresses)
182
+ ipv6Addresses.forEach(ipv6Address => defaultPebbleDomains.push(ipv6Address))
183
+ log(` 📍 ❨auto-encrypt❩ Will provision TLS certificate for detected stable IPv6 address${ipv6Addresses.length > 1 ? 'es' : ''}: ${ipv6Addresses}`)
184
+ } else {
185
+ throws.error(Symbol.for('IPv6AddressNotFound'))
186
+ }
187
+ }
188
+ }
189
+
113
190
  let defaultDomains = defaultStagingAndProductionDomains
114
191
 
192
+ // Behaviour specific to Let’s Encrypt server type (Pebble/staging).
115
193
  switch (letsEncryptServer.type) {
116
194
  case LetsEncryptServer.type.PEBBLE:
117
195
  options.domains = null
118
196
  defaultDomains = defaultPebbleDomains
119
197
  break
120
198
 
121
- // If this is a staging server, we add the intermediary certificate to Node.js’s trust store (only valid during
122
- // the current Node.js process) so that Node will accept the certificate. Useful when running tests against the
123
- // staging server.
124
- //
125
- // If you’re using Pebble for your tests, please install and use node-pebble manually in your tests.
126
- // (We cannot automatically provide support for Pebble as it dynamically generates its root and
127
- // intermediary CA certificates, which is an asynchronous process whereas the createServer method is
128
- // synchronous.)*
129
- //
130
- // * Yes, we could check for and start the Pebble server in the asynchronous SNICallback, below, but given how
131
- // often that function is called, I will not add anything to it beyond the essentials for performance reasons.
132
199
  case LetsEncryptServer.type.STAGING:
200
+ // Add the intermediary certificate to Node.js’s trust store (only valid during the current Node.js process) so that Node will accept the certificate. Useful when running tests against the staging server.
133
201
  monkeyPatchTls()
134
202
  break
135
203
  }
@@ -140,6 +208,9 @@ export default class AutoEncrypt {
140
208
  delete options.domains
141
209
  delete options.serverType
142
210
  delete options.settingsPath
211
+ delete options.ipv4
212
+ delete options.ipv6
213
+ delete options.ipOnly
143
214
 
144
215
  const configuration = new Configuration({ settingsPath, domains, server: letsEncryptServer})
145
216
  const certificate = new Certificate(configuration)
@@ -151,69 +222,101 @@ export default class AutoEncrypt {
151
222
  this.listener = listener
152
223
  this.certificate = certificate
153
224
 
154
- function sniError (symbolName, callback, emoji, ...args) {
155
- const error = Symbol.for(symbolName)
156
- log(` ${emoji} ❨auto-encrypt❩ ${throws.errors[error](...args)}`)
157
- callback(throws.createError(error, ...args))
158
- }
225
+ // Start HTTP server.
226
+ log(' 🌍 ❨auto-encrypt❩ Starting HTTP challenge responder server…')
227
+ await HttpServer.getSharedInstance()
159
228
 
160
- options.SNICallback = async (serverName, callback) => {
161
- if (domains.includes(serverName)) {
162
- const secureContext = await certificate.getSecureContext()
163
- if (secureContext === null) {
164
- sniError('BusyProvisioningCertificateError', callback, '⏳')
165
- return
166
- }
167
- callback(null, secureContext)
168
- } else {
169
- sniError('SNIIgnoreUnsupportedDomainError', callback, '🤨', serverName, domains)
170
- }
171
- }
229
+ // Provision certificate (if required).
230
+ log(` 📃 ❨auto-encrypt❩ Provisioning certificate (if required) for domains: ${domains.join(', ')}…`)
231
+ await certificate.getSecureContext()
232
+ log(' ✅ ❨auto-encrypt❩ Certificate provisioned (or already exists).')
233
+
234
+ // Pass the certificate and key to the https.createServer options as well.
235
+ // This is required for IP hits (which don't use SNI) and also acts as the
236
+ // default certificate.
237
+ options.key = certificate.key
238
+ options.cert = certificate.pem
172
239
 
173
- const server = https.createServer(options, listener)
240
+ // Create node:https server.
241
+ const server = /** @type {import('./index.d.ts').AutoEncryptedServer} */ (/** @type {unknown} */ (https.createServer(options, listener)))
242
+ this.server = server
174
243
 
175
244
  //
176
- // Monkey-patch the server.
245
+ // Monkey-patch server.
177
246
  //
178
247
 
179
- server.__autoEncrypt__self = this
248
+ /**
249
+ Auto Encrypt’s async version of Node’s built-in `https.listen()` method.
180
250
 
181
- // Monkey-patch the server’s listen method so that we can start up the HTTP
182
- // Server at the same time.
183
- server.__autoEncrypt__originalListen = server.listen
184
- server.listen = function(...args) {
185
- // Start the HTTP server.
186
- HttpServer.getSharedInstance().then(() => {
187
- // Start the HTTPS server.
188
- return this.__autoEncrypt__originalListen.apply(this, args)
251
+ You can use this method with exactly the same signature as Node’s and
252
+ with a callback but, ideally, you should be awaiting it and not using the callback.
253
+
254
+ @param {...*} args - Arguments to pass to https.Server.listen()
255
+ @returns {Promise<AutoEncryptedServer>}
256
+ */
257
+ const listenAsync = async function (...args) {
258
+ return new Promise((resolve, reject) => {
259
+ const server = this['__original_listen__'](...args)
260
+ .once('listening', () => resolve(server))
261
+ .once('error', reject)
189
262
  })
190
263
  }
264
+ server['__original_listen__'] = server.listen.bind(server)
265
+ server.listen = listenAsync.bind(server)
191
266
 
267
+ /**
268
+ Auto Encrypt Localhost’s async version of Node’s built-in `https.close()` method.
192
269
 
193
- // Monkey-patch the server’s close method so that we can perform clean-up and
194
- // also shut down the HTTP server transparently when server.close() is called.
195
- server.__autoEncrypt__originalClose = server.close
196
- server.close = function (...args) {
270
+ Unlike Node’s `close()` method, this version also calls `closeAllConnections()` so it
271
+ will sever any existing idle and active connections. When `await`ed, this call will
272
+ only return once `node:https`’s close callback is called. This is also where your
273
+ callback will fire, if you are using Node’s original method signature will callbacks.
274
+
275
+ You can still use this method with exactly the same signature as Node’s and
276
+ with a callback but, ideally, you should be awaiting it and not using the callback.
277
+
278
+ @returns {Promise<void>}
279
+ */
280
+ const closeAsync = async function () {
197
281
  // Clean-up our own house.
198
- this.__autoEncrypt__self.shutdown()
282
+ await AutoEncrypt.shutdown()
199
283
 
200
- // Shut down the HTTP server.
201
- HttpServer.destroySharedInstance().then(() => {
202
- // Shut down the HTTPS server.
203
- return this.__autoEncrypt__originalClose.apply(this, args)
284
+ // Start shutting down the HTTPS server.
285
+ const closePromise = new Promise((resolve, reject) => {
286
+ this['__original_close__']((/** @type { Error } */ error) => {
287
+ if (error) {
288
+ reject(error)
289
+ } else {
290
+ resolve()
291
+ }
292
+ })
204
293
  })
205
- }
206
294
 
207
- return server
208
- }
295
+ // Sever all idle and active connections.
296
+ this.closeAllConnections()
209
297
 
298
+ // Only now await the promise.
299
+ // (See note at: https://nodejs.org/docs/latest-v24.x/api/http.html#servercloseallconnections)
300
+ await closePromise
301
+ }
302
+ server['__original_close__'] = server.close.bind(server)
303
+ server.close = closeAsync.bind(server)
210
304
 
305
+ // Return the server singleton.
306
+ return this.server
307
+ }
308
+
211
309
  /**
212
- * Shut Auto Encrypt down. Do this before app exit. Performs necessary clean-up and removes
213
- * any references that might cause the app to not exit.
214
- */
215
- static shutdown () {
216
- this.certificate.stopCheckingForRenewal()
310
+ Shut Auto Encrypt down. Do this before app exit. Performs necessary clean-up and removes
311
+ any references that might cause the app to not exit.
312
+ */
313
+ static async shutdown () {
314
+ if (this.certificate) {
315
+ this.certificate.stopCheckingForRenewal()
316
+ }
317
+ await HttpServer.destroySharedInstance()
318
+ AcmeRequest.uninitialise()
319
+ this.server = null
217
320
  }
218
321
 
219
322
  //
package/lib/Account.js CHANGED
@@ -1,18 +1,10 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // Account
4
- //
5
- // (Async; please use Account.getInstanceAsync() factory method.)
6
- //
7
- // Represents a Let’s Encrypt account. Currently this is limited to the account
8
- // URL used as the "kid" value in the JWS authenticating subsequent requests
9
- // by this account after it is created using the JWT public key.
10
- // See RFC 8555 § 6.2, 7.3.
11
- //
12
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
13
- // License: AGPLv3 or later.
14
- //
15
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+ Represents a Let’s Encrypt account.
3
+
4
+ @module
5
+ @copyright Copyright © 2020-present Aral Balkan, Small Technology Foundation.
6
+ @license AGPLv3 or later.
7
+ */
16
8
 
17
9
  import fs from 'fs'
18
10
  import Throws from './util/Throws.js'
@@ -22,11 +14,23 @@ const throws = new Throws({
22
14
  // No custom errors are thrown by this class.
23
15
  })
24
16
 
17
+ /**
18
+ (Async; please use Account.getInstanceAsync() factory method.)
19
+
20
+ Represents a Let’s Encrypt account. Currently this is limited to the account
21
+ URL used as the "kid" value in the JWS authenticating subsequent requests
22
+ by this account after it is created using the JWT public key.
23
+ See RFC 8555 § 6.2, 7.3.
24
+
25
+ @alias module:lib/Account
26
+ */
25
27
  export default class Account {
26
- //
27
- // Async factory method.
28
- //
28
+ /**
29
+ Async static factory method. __Use this to instantiate, not the constructor.__
29
30
 
31
+ @param { import('./Configuration.js').default } configuration
32
+ @public
33
+ */
30
34
  static async getInstanceAsync (configuration) {
31
35
  Account.isBeingInstantiatedViaSingletonFactoryMethod = true
32
36
  const account = new Account(configuration)
@@ -39,6 +43,10 @@ export default class Account {
39
43
  //
40
44
  static isBeingInstantiatedViaSingletonFactoryMethod = false
41
45
 
46
+ /**
47
+ @private
48
+ @param { import('./Configuration.js').default } configuration
49
+ */
42
50
  constructor (configuration) {
43
51
  // Ensure factory method-based initialisation.
44
52
  if (Account.isBeingInstantiatedViaSingletonFactoryMethod === false) {
@@ -61,7 +69,7 @@ export default class Account {
61
69
  }
62
70
  }
63
71
 
64
- // TODO: throw error if Account has not been initialised instead of crashing in getter below.
72
+ /** @type { string } */
65
73
  get kid () { return this.data.kid }
66
- set kid (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'kid') }
74
+ set kid (_value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'kid') }
67
75
  }