@small-tech/auto-encrypt 4.3.0 → 5.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/README.md +64 -64
- package/index.d.ts +152 -0
- package/index.js +220 -205
- package/lib/Account.js +28 -20
- package/lib/AcmeRequest.js +187 -117
- package/lib/Authorisation.js +80 -36
- package/lib/Certificate.js +292 -149
- package/lib/Configuration.js +93 -77
- package/lib/Directory.js +28 -17
- package/lib/HttpServer.js +55 -28
- package/lib/IPAddresses.js +114 -0
- package/lib/Identity.js +65 -37
- package/lib/LetsEncryptServer.js +15 -15
- package/lib/Nonce.js +36 -27
- package/lib/Order.js +65 -39
- package/lib/acme-requests/AuthorisationRequest.js +15 -14
- package/lib/acme-requests/CertificateRequest.js +16 -15
- package/lib/acme-requests/CheckOrderStatusRequest.js +19 -17
- package/lib/acme-requests/FinaliseOrderRequest.js +21 -19
- package/lib/acme-requests/NewAccountRequest.js +13 -14
- package/lib/acme-requests/NewOrderRequest.js +30 -17
- package/lib/acme-requests/ReadyForChallengeValidationRequest.js +17 -16
- package/lib/acmeCsr.js +116 -59
- package/lib/identities/AccountIdentity.js +15 -14
- package/lib/identities/CertificateIdentity.js +16 -16
- package/lib/staging/monkeyPatchTls.js +12 -12
- package/lib/test-helpers/index.js +8 -23
- package/lib/util/Pluralise.js +2 -2
- package/lib/util/Throws.js +61 -21
- package/lib/util/async-foreach.js +21 -21
- package/lib/util/fromNow.js +27 -0
- package/lib/util/log.js +5 -1
- package/lib/util/waitFor.js +5 -0
- package/lib/x.509/rfc5280.js +47 -12
- package/package.json +15 -14
- package/typedefs/lib/AcmeRequest.js +36 -25
package/lib/Configuration.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
Global configuration class. Use initialise() method to populate.
|
|
3
|
+
|
|
4
|
+
@module lib/Configuration
|
|
5
|
+
@exports Configuration
|
|
6
|
+
@copyright © 2020 Aral Balkan, Small Technology Foundation.
|
|
7
|
+
@license AGPLv3 or later.
|
|
8
|
+
*/
|
|
9
9
|
|
|
10
10
|
import os from 'os'
|
|
11
11
|
import fs from 'fs'
|
|
@@ -21,11 +21,17 @@ const throws = new Throws({
|
|
|
21
21
|
() => 'Domains array must be an array of strings'
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* @param {any[]} object
|
|
26
|
+
*/
|
|
24
27
|
function isAnArrayOfStrings (object) {
|
|
25
|
-
const containsOnlyStrings = arrayOfStrings => arrayOfStrings.length !== 0 && arrayOfStrings.filter(s => typeof s === 'string').length === arrayOfStrings.length
|
|
28
|
+
const containsOnlyStrings = (/** @type {any[]} */ arrayOfStrings) => arrayOfStrings.length !== 0 && arrayOfStrings.filter(s => typeof s === 'string').length === arrayOfStrings.length
|
|
26
29
|
return Array.isArray(object) && containsOnlyStrings(object)
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* @param {fs.PathLike} directory
|
|
34
|
+
*/
|
|
29
35
|
function ensureDirSync (directory) {
|
|
30
36
|
if (!fs.existsSync(directory)) {
|
|
31
37
|
fs.mkdirSync(directory, { recursive: true })
|
|
@@ -33,9 +39,9 @@ function ensureDirSync (directory) {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
@alias module:lib/Configuration
|
|
43
|
+
@hideconstructor
|
|
44
|
+
*/
|
|
39
45
|
export default class Configuration {
|
|
40
46
|
#server = null
|
|
41
47
|
#domains = null
|
|
@@ -47,35 +53,42 @@ export default class Configuration {
|
|
|
47
53
|
#certificateIdentityPath = null
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
Initialise the configuration. Must be called before accessing settings. May be called more than once.
|
|
57
|
+
|
|
58
|
+
@typedef { import('./LetsEncryptServer.js').default } LetsEncryptServer
|
|
59
|
+
|
|
60
|
+
@typedef {{
|
|
61
|
+
domains: string[],
|
|
62
|
+
server: LetsEncryptServer,
|
|
63
|
+
settingsPath: string
|
|
64
|
+
}} Settings
|
|
65
|
+
|
|
66
|
+
@param {Settings | void} settings
|
|
67
|
+
*/
|
|
57
68
|
constructor (settings = throws.ifMissing()) {
|
|
58
69
|
|
|
70
|
+
const __settings = /** @type { Settings } */ (settings)
|
|
71
|
+
|
|
59
72
|
// Additional argument validation.
|
|
60
|
-
throws.ifUndefinedOrNull(
|
|
61
|
-
throws.ifUndefined(
|
|
62
|
-
throws.if(!isAnArrayOfStrings(
|
|
73
|
+
throws.ifUndefinedOrNull(__settings.server, 'settings.server')
|
|
74
|
+
throws.ifUndefined(__settings.settingsPath, 'settings.settingsPath')
|
|
75
|
+
throws.if(!isAnArrayOfStrings(__settings.domains), Symbol.for('Configuration.domainsArrayIsNotAnArrayOfStringsError'))
|
|
63
76
|
|
|
64
|
-
if (!util.inspect(
|
|
77
|
+
if (!util.inspect(__settings.server).includes('Let’s Encrypt Server (instance)')) {
|
|
65
78
|
throws.error(Symbol.for('ArgumentError'), 'settings.server', 'must be an instance of LetsEncryptServer but isn’t')
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
this.#server =
|
|
69
|
-
this.#domains =
|
|
81
|
+
this.#server = __settings.server
|
|
82
|
+
this.#domains = __settings.domains
|
|
70
83
|
|
|
71
|
-
const defaultPathFor = serverName => path.join(os.homedir(), '.small-tech.org', 'auto-encrypt', serverName)
|
|
84
|
+
const defaultPathFor = (/** @type {string} */ serverName) => path.join(os.homedir(), '.small-tech.org', 'auto-encrypt', serverName)
|
|
72
85
|
|
|
73
|
-
if (
|
|
86
|
+
if (__settings.settingsPath === null) {
|
|
74
87
|
// Use the correct default path based on staging state.
|
|
75
88
|
this.#settingsPath = defaultPathFor(this.#server.name)
|
|
76
89
|
} else {
|
|
77
90
|
// Create the path to use based on the custom root path we’ve been passed.
|
|
78
|
-
this.#settingsPath = path.join(
|
|
91
|
+
this.#settingsPath = path.join(__settings.settingsPath, this.#server.name)
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
// And ensure that the settings path exists in the file system.
|
|
@@ -104,7 +117,7 @@ export default class Configuration {
|
|
|
104
117
|
// For more than 5 domains, show the first two, followed by the number of domains, and a
|
|
105
118
|
// hash to ensure that the directory name is unique.
|
|
106
119
|
// e.g., ar.al--www.ar.al--and--4--others--9364bd18ea526b3462e4cebc2a6d97c2ffd3b5312ef7b8bb6165c66a37975e46
|
|
107
|
-
const hashOf = arrayOfStrings => crypto.createHash('blake2s256').update(arrayOfStrings.join('--')).digest('hex')
|
|
120
|
+
const hashOf = (/** @type {string[]} */ arrayOfStrings) => crypto.createHash('blake2s256').update(arrayOfStrings.join('--')).digest('hex')
|
|
108
121
|
return domains.slice(0, 2).join('--').concat(`--and--${domains.length-2}--others--${hashOf(domains)}`)
|
|
109
122
|
}
|
|
110
123
|
})(this.#domains)
|
|
@@ -125,91 +138,94 @@ export default class Configuration {
|
|
|
125
138
|
//
|
|
126
139
|
|
|
127
140
|
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
The Let’s Encrypt Server instance.
|
|
142
|
+
|
|
143
|
+
@type {LetsEncryptServer}
|
|
144
|
+
@readonly
|
|
145
|
+
*/
|
|
133
146
|
get server () {
|
|
134
147
|
return this.#server
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
/**
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
List of domains that Auto Encrypt will manage TLS certificates for.
|
|
152
|
+
|
|
153
|
+
@type {String[]}
|
|
154
|
+
@readonly
|
|
155
|
+
*/
|
|
143
156
|
get domains () {
|
|
144
157
|
return this.#domains
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
The root settings path. There is a different root settings path for pebble, staging and production modes.
|
|
162
|
+
|
|
163
|
+
@type {String}
|
|
164
|
+
@readonly
|
|
165
|
+
*/
|
|
153
166
|
get settingsPath () {
|
|
154
167
|
return this.#settingsPath
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
Path to the account.json file that contains the Key Id that uniquely identifies and authorises your account
|
|
172
|
+
in the absence of a JWT (see RFC 8555 § 6.2. Request Authentication).
|
|
173
|
+
|
|
174
|
+
@type {String}
|
|
175
|
+
@readonly
|
|
176
|
+
*/
|
|
164
177
|
get accountPath () {
|
|
165
178
|
return this.#accountPath
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
182
|
+
The path to the account-identity.pem file that contains the private key for the account.
|
|
183
|
+
|
|
184
|
+
@type {String}
|
|
185
|
+
@readonly
|
|
186
|
+
*/
|
|
174
187
|
get accountIdentityPath () { return this.#accountIdentityPath }
|
|
175
188
|
|
|
176
189
|
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
190
|
+
The path to the certificate.pem file that contains the certificate chain provisioned from Let’s Encrypt.
|
|
191
|
+
|
|
192
|
+
@type {String}
|
|
193
|
+
@readonly
|
|
194
|
+
*/
|
|
182
195
|
get certificatePath () { return this.#certificatePath }
|
|
183
196
|
|
|
184
197
|
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
The directory the certificate and certificate identity (private key) PEM files are stored in.
|
|
199
|
+
|
|
200
|
+
@type {String}
|
|
201
|
+
@readonly
|
|
202
|
+
*/
|
|
190
203
|
get certificateDirectoryPath () { return this.#certificateDirectoryPath }
|
|
191
204
|
|
|
192
205
|
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
The path to the certificate-identity.pem file that holds the private key for the TLS certificate.
|
|
207
|
+
|
|
208
|
+
@type {String}
|
|
209
|
+
@readonly
|
|
210
|
+
*/
|
|
198
211
|
get certificateIdentityPath () { return this.#certificateIdentityPath }
|
|
199
212
|
|
|
200
213
|
//
|
|
201
214
|
// Enforce read-only access.
|
|
202
215
|
//
|
|
203
216
|
|
|
204
|
-
set server (
|
|
205
|
-
set domains (
|
|
206
|
-
set settingsPath (
|
|
207
|
-
set accountPath (
|
|
208
|
-
set accountIdentityPath (
|
|
209
|
-
set certificatePath (
|
|
210
|
-
set certificateDirectoryPath (
|
|
211
|
-
set certificateIdentityPath (
|
|
217
|
+
set server (_state) { this.throwReadOnlyAccessorError('server') }
|
|
218
|
+
set domains (_state) { this.throwReadOnlyAccessorError('domains') }
|
|
219
|
+
set settingsPath (_state) { this.throwReadOnlyAccessorError('settingsPath') }
|
|
220
|
+
set accountPath (_state) { this.throwReadOnlyAccessorError('accountPath') }
|
|
221
|
+
set accountIdentityPath (_state) { this.throwReadOnlyAccessorError('accountIdentityPath') }
|
|
222
|
+
set certificatePath (_state) { this.throwReadOnlyAccessorError('certificatePath') }
|
|
223
|
+
set certificateDirectoryPath (_state) { this.throwReadOnlyAccessorError('certificateDirectoryPath') }
|
|
224
|
+
set certificateIdentityPath (_state) { this.throwReadOnlyAccessorError('certificateIdentityPath') }
|
|
212
225
|
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} setterName
|
|
228
|
+
*/
|
|
213
229
|
throwReadOnlyAccessorError (setterName) {
|
|
214
230
|
throws.error(Symbol.for('ReadOnlyAccessorError'), setterName, 'All configuration accessors are read-only.')
|
|
215
231
|
}
|
package/lib/Directory.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
////////////////////////////////////////////////////////////////////////////////
|
|
1
|
+
/**
|
|
2
|
+
Directory
|
|
3
|
+
|
|
4
|
+
In order to help clients configure themselves with the right URLs for
|
|
5
|
+
each ACME operation, ACME servers provide a directory object. This
|
|
6
|
+
should be the only URL needed to configure clients. (RFC §7.1.1)
|
|
7
|
+
|
|
8
|
+
Copyright © 2020 Aral Balkan, Small Technology Foundation.
|
|
9
|
+
License: AGPLv3 or later.
|
|
10
|
+
*/
|
|
13
11
|
|
|
14
12
|
import util from 'util'
|
|
15
|
-
import prepareRequest from 'bent'
|
|
16
13
|
import log from './util/log.js'
|
|
17
14
|
import Throws from './util/Throws.js'
|
|
18
15
|
|
|
@@ -21,16 +18,18 @@ const throws = new Throws()
|
|
|
21
18
|
export default class Directory {
|
|
22
19
|
directory = null
|
|
23
20
|
letsEncryptServer = null
|
|
24
|
-
directoryRequest = null
|
|
25
21
|
|
|
26
22
|
//
|
|
27
23
|
// Factory method access (async).
|
|
28
24
|
//
|
|
29
25
|
static isBeingInstantiatedViaAsyncFactoryMethod = false
|
|
30
26
|
|
|
27
|
+
/**
|
|
28
|
+
@param {import('./Configuration.js').default|void} configuration
|
|
29
|
+
*/
|
|
31
30
|
static async getInstanceAsync (configuration = throws.ifMissing()) {
|
|
32
31
|
Directory.isBeingInstantiatedViaAsyncFactoryMethod = true
|
|
33
|
-
const directory = new Directory(configuration)
|
|
32
|
+
const directory = new Directory(/** @type {import('./Configuration.js').default} */ (configuration))
|
|
34
33
|
await directory.getUrls()
|
|
35
34
|
return directory
|
|
36
35
|
}
|
|
@@ -47,11 +46,16 @@ export default class Directory {
|
|
|
47
46
|
get revokeCertUrl() { return this.directory.revokeCert }
|
|
48
47
|
get termsOfServiceUrl() { return this.directory.meta.termsOfService }
|
|
49
48
|
get websiteUrl() { return this.directory.meta.website }
|
|
49
|
+
get renewalInfoUrl() { return this.directory.renewalInfo }
|
|
50
|
+
get profiles() { return this.directory.meta.profiles }
|
|
50
51
|
|
|
51
52
|
//
|
|
52
53
|
// Private.
|
|
53
54
|
//
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
@param {import('./Configuration.js').default} configuration
|
|
58
|
+
*/
|
|
55
59
|
constructor(configuration) {
|
|
56
60
|
// Ensure async factory method instantiation.
|
|
57
61
|
if (Directory.isBeingInstantiatedViaAsyncFactoryMethod === false) {
|
|
@@ -60,7 +64,6 @@ export default class Directory {
|
|
|
60
64
|
Directory.isBeingInstantiatedViaAsyncFactoryMethod = false
|
|
61
65
|
|
|
62
66
|
this.letsEncryptServer = configuration.server
|
|
63
|
-
this.directoryRequest = prepareRequest('GET', 'json', this.letsEncryptServer.endpoint)
|
|
64
67
|
|
|
65
68
|
log(` 📕 ❨auto-encrypt❩ Directory is using endpoint ${this.letsEncryptServer.endpoint}`)
|
|
66
69
|
}
|
|
@@ -68,7 +71,13 @@ export default class Directory {
|
|
|
68
71
|
// (Async) Fetches the latest Urls from the Let’s Encrypt ACME endpoint being used.
|
|
69
72
|
// This will throw if the request fails. Ensure that you catch the error when
|
|
70
73
|
// using it.
|
|
71
|
-
async getUrls() {
|
|
74
|
+
async getUrls() {
|
|
75
|
+
const response = await fetch(this.letsEncryptServer.endpoint)
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`)
|
|
78
|
+
}
|
|
79
|
+
this.directory = await response.json()
|
|
80
|
+
}
|
|
72
81
|
|
|
73
82
|
// Custom object description for console output (for debugging).
|
|
74
83
|
[util.inspect.custom] () {
|
|
@@ -86,6 +95,8 @@ export default class Directory {
|
|
|
86
95
|
- revokeCertUrl : ${this.revokeCertUrl}
|
|
87
96
|
- termsOfServiceUrl: ${this.termsOfServiceUrl}
|
|
88
97
|
- websiteUrl : ${this.websiteUrl}
|
|
98
|
+
- renewalInfoUrl : ${this.renewalInfoUrl}
|
|
99
|
+
- profiles : ${Object.entries(this.profiles).map(([key, value], index) => `${index > 0 ? ', ' : ''}${key} (${value})`)}
|
|
89
100
|
`
|
|
90
101
|
}
|
|
91
102
|
}
|
package/lib/HttpServer.js
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
|
+
/**
|
|
1
2
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// License: AGPLv3 or later.
|
|
25
|
-
//
|
|
26
|
-
////////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
HttpServer
|
|
4
|
+
|
|
5
|
+
(Singleton; please use HttpServer.getSharedInstance() to access.)
|
|
6
|
+
|
|
7
|
+
A simple HTTP server that:
|
|
8
|
+
|
|
9
|
+
A. While provisioning Let’s Encrypt certificates:
|
|
10
|
+
=================================================
|
|
11
|
+
|
|
12
|
+
Acts as a challenge server. See RFC 8555 § 8.3 (HTTP Challenge)
|
|
13
|
+
|
|
14
|
+
Responds to http-01 challenges and forwards all other
|
|
15
|
+
requests to an HTTPS server that it expects to be active at the same domain.
|
|
16
|
+
|
|
17
|
+
B. At all other times:
|
|
18
|
+
======================
|
|
19
|
+
|
|
20
|
+
Forwards http requests to https requests using a 307 redirect.
|
|
21
|
+
|
|
22
|
+
Copyright © 2020 Aral Balkan, Small Technology Foundation.
|
|
23
|
+
License: AGPLv3 or later.
|
|
24
|
+
*/
|
|
27
25
|
|
|
28
26
|
import http from 'http'
|
|
29
27
|
import encodeUrl from 'encodeurl'
|
|
@@ -33,12 +31,18 @@ export default class HttpServer {
|
|
|
33
31
|
//
|
|
34
32
|
// Singleton access (async).
|
|
35
33
|
//
|
|
34
|
+
/** @type { HttpServer } */
|
|
36
35
|
static instance = null
|
|
37
36
|
static isBeingInstantiatedViaSingletonFactoryMethod = false
|
|
38
37
|
|
|
39
38
|
// Is the HTTP server acting as a Let’s Encrypt challenge server?
|
|
40
39
|
#isChallengeServer = false
|
|
41
40
|
|
|
41
|
+
/** @typedef { (request: http.IncomingMessage, response: http.ServerResponse) => Boolean } Responder
|
|
42
|
+
|
|
43
|
+
/** @type Array<Responder> */
|
|
44
|
+
responders
|
|
45
|
+
|
|
42
46
|
static async getSharedInstance () {
|
|
43
47
|
if (HttpServer.instance === null) {
|
|
44
48
|
HttpServer.isBeingInstantiatedViaSingletonFactoryMethod = true
|
|
@@ -59,6 +63,11 @@ export default class HttpServer {
|
|
|
59
63
|
log(' 🚮 ❨auto-encrypt❩ HTTP Server is destroyed.')
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
Adds a responder to this server’s responders list.
|
|
68
|
+
|
|
69
|
+
@param { Responder } responder
|
|
70
|
+
*/
|
|
62
71
|
addResponder (responder) {
|
|
63
72
|
this.responders.push(responder)
|
|
64
73
|
}
|
|
@@ -85,6 +94,8 @@ export default class HttpServer {
|
|
|
85
94
|
// Act as a Let’s Encrypt challenge server.
|
|
86
95
|
let responded = false
|
|
87
96
|
|
|
97
|
+
log(` 📥 ❨auto-encrypt❩ Received HTTP request: ${request.url} on ${request.headers.host}`)
|
|
98
|
+
|
|
88
99
|
for (let i = 0; i < this.responders.length; i++) {
|
|
89
100
|
const responder = this.responders[i]
|
|
90
101
|
responded = responder(request, response)
|
|
@@ -113,14 +124,19 @@ export default class HttpServer {
|
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
// Redirect HTTP to HTTPS.
|
|
116
|
-
log(` 👉 ❨auto-encrypt❩ Redirecting HTTP request to HTTPS
|
|
127
|
+
log(` 👉 ❨auto-encrypt❩ Redirecting HTTP request to HTTPS: ${httpsUrl.toString()}`)
|
|
117
128
|
response.statusCode = 307
|
|
118
|
-
response.setHeader('Location', encodeUrl(httpsUrl))
|
|
129
|
+
response.setHeader('Location', encodeUrl(httpsUrl.toString()))
|
|
119
130
|
response.end()
|
|
120
131
|
}
|
|
121
132
|
})
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/**
|
|
136
|
+
Sets the state of the server (either to challenge server (true) or to HTTP -> HTTPS forwarding server (false)).
|
|
137
|
+
|
|
138
|
+
@param { Boolean } state
|
|
139
|
+
*/
|
|
124
140
|
set challengeServer (state) {
|
|
125
141
|
if (state) {
|
|
126
142
|
log(` 🔒 ❨auto-encrypt❩ HTTP server is now only responding to Let’s Encrypt challenges.`)
|
|
@@ -138,7 +154,8 @@ export default class HttpServer {
|
|
|
138
154
|
// vulnerability at worst in the global digital network age.
|
|
139
155
|
await new Promise((resolve, reject) => {
|
|
140
156
|
try {
|
|
141
|
-
|
|
157
|
+
// Listen on all IP addresses, including IPv6.
|
|
158
|
+
this.server.listen(80, '::', () => {
|
|
142
159
|
log(` 🔒 ❨auto-encrypt❩ HTTP server is listening on port 80.`)
|
|
143
160
|
resolve()
|
|
144
161
|
})
|
|
@@ -149,12 +166,22 @@ export default class HttpServer {
|
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
async destroy () {
|
|
169
|
+
if (!this.server.listening) {
|
|
170
|
+
log(' 🚮 ❨auto-encrypt❩ HTTP Server is not listening. Nothing to destroy.')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
152
174
|
// Starts killing all connections and closes the server.
|
|
153
175
|
this.server.closeAllConnections()
|
|
154
176
|
|
|
155
177
|
await new Promise((resolve, reject) => {
|
|
156
178
|
this.server.close(error => {
|
|
157
179
|
if (error) {
|
|
180
|
+
// If the server was already closed or not running, we don't want to throw an error.
|
|
181
|
+
if (error.code === 'ERR_SERVER_NOT_RUNNING') {
|
|
182
|
+
resolve()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
158
185
|
console.error(error)
|
|
159
186
|
reject(error)
|
|
160
187
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Gathers external IPv4 address information using https://ip.small-web.org/
|
|
3
|
+
|
|
4
|
+
And Ipv6 information using built-in `networkInterfaces` function from node:os.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import dns from 'node:dns'
|
|
9
|
+
import { execSync } from 'node:child_process'
|
|
10
|
+
import Throws from './util/Throws.js'
|
|
11
|
+
|
|
12
|
+
const throws = new Throws()
|
|
13
|
+
|
|
14
|
+
export default class IPAddresses {
|
|
15
|
+
/** @type {IPAddresses} */
|
|
16
|
+
static instance
|
|
17
|
+
|
|
18
|
+
static extractIPv6Address = "sed -n 's/.*inet6 \\([a-f0-9:]*\\).*/\\1/p'"
|
|
19
|
+
static platformSpecificStableIPv6DiscoveryCommands = {
|
|
20
|
+
'linux': `ip -6 address show scope global | grep -v 'temporary' | ${IPAddresses.extractIPv6Address}`,
|
|
21
|
+
'darwin': `ifconfig | grep 'inet6' | grep 'secured' | grep -v 'fe80:' | ${IPAddresses.extractIPv6Address}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @type {string|undefined} */
|
|
25
|
+
ipv4Address
|
|
26
|
+
|
|
27
|
+
/** @type {Array<string>} */
|
|
28
|
+
ipv6Addresses = []
|
|
29
|
+
|
|
30
|
+
//
|
|
31
|
+
// Factory method access (async).
|
|
32
|
+
//
|
|
33
|
+
static isBeingInstantiatedViaAsyncFactoryMethod = false
|
|
34
|
+
|
|
35
|
+
static async getInstanceAsync () {
|
|
36
|
+
if (this.instance !== undefined) {
|
|
37
|
+
return this.instance
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create singleton instance.
|
|
41
|
+
this.isBeingInstantiatedViaAsyncFactoryMethod = true
|
|
42
|
+
|
|
43
|
+
this.instance = new IPAddresses()
|
|
44
|
+
|
|
45
|
+
// Initialise it asynchronously.
|
|
46
|
+
await this.instance.discoverIpAddresses()
|
|
47
|
+
|
|
48
|
+
return this.instance
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get hasIPv4Address () {
|
|
52
|
+
return this.ipv4Address !== undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get hasIPv6Addresses () {
|
|
56
|
+
return this.ipv6Addresses.length > 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//
|
|
60
|
+
// Private.
|
|
61
|
+
//
|
|
62
|
+
|
|
63
|
+
constructor () {
|
|
64
|
+
// Ensure singelton access.
|
|
65
|
+
if (IPAddresses.isBeingInstantiatedViaAsyncFactoryMethod === false) {
|
|
66
|
+
throws.error(Symbol.for('MustBeInstantiatedViaAsyncFactoryMethodError'), 'IPAddresses')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// This reverts IP address sort order to pre-Node 17 behaviour.
|
|
70
|
+
// See https://github.com/nodejs/node/issues/40537
|
|
71
|
+
dns.setDefaultResultOrder('ipv4first')
|
|
72
|
+
|
|
73
|
+
IPAddresses.isBeingInstantiatedViaAsyncFactoryMethod = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async discoverIpAddresses() {
|
|
77
|
+
await this.discoverIPv4Address()
|
|
78
|
+
this.discoverIPv6Addresses()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async discoverIPv4Address () {
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch('https://ip.small-web.org/json/')
|
|
84
|
+
if (response.status === 200) {
|
|
85
|
+
const ip = (await response.json()).ip
|
|
86
|
+
if (ip !== '::1') {
|
|
87
|
+
this.ipv4Address = ip
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn('Could not discover external IPv4 address.', error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
Runs platform-specific command for retriving stable IPv6 addresses as we don’t
|
|
97
|
+
have a built-in way of differentiating between stable and temporary addresses
|
|
98
|
+
using Node.js alone (`os.networkInterfaces()` does not have that information.)
|
|
99
|
+
*/
|
|
100
|
+
discoverIPv6Addresses () {
|
|
101
|
+
const platform = os.platform()
|
|
102
|
+
const platformSpecificStableIPv6DiscoveryCommand = IPAddresses.platformSpecificStableIPv6DiscoveryCommands[platform]
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const stdout = execSync(platformSpecificStableIPv6DiscoveryCommand, { encoding: 'utf-8' })
|
|
106
|
+
this.ipv6Addresses = stdout
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map(address => address.trim())
|
|
109
|
+
.filter(address => address.length > 0)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.warn(`[Auto Encrypt] Failed to discover IPv6 addresses using platform-specific (${platform}) command:`, platformSpecificStableIPv6DiscoveryCommand, error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|