pairnet 0.1.1 → 0.2.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 +199 -108
- package/lib/create-query-error.js +8 -0
- package/lib/network/connect-multiple.js +74 -0
- package/lib/pairing-token/generate.js +7 -0
- package/lib/pairing-token/reduce-addresses.js +21 -0
- package/lib/pairing-token/wrapped.js +77 -0
- package/lib/protocol/evaluate-query.js +19 -0
- package/lib/protocol/index.js +161 -0
- package/lib/protocol/message-types.js +37 -0
- package/lib/reply-tracker.js +25 -10
- package/lib/sender.js +1 -1
- package/{storages → lib/storages}/memory.js +7 -0
- package/lib/store.js +12 -6
- package/notes.txt +3 -0
- package/package.json +23 -12
- package/test/index.js +54 -21
- package/test/wrapped-token.js +30 -0
- package/lib/maybe-call.js +0 -7
- package/lib/protocol.js +0 -191
- /package/{promisified → lib/promisified}/server-close.js +0 -0
- /package/{promisified → lib/promisified}/server-listen.js +0 -0
- /package/{storages → lib/storages}/file.js +0 -0
- /package/{storages → lib/storages}/symbols.js +0 -0
- /package/lib/{generate-keys.js → tls/generate-keys.js} +0 -0
- /package/lib/{get-fingerprint.js → tls/get-fingerprint.js} +0 -0
package/index.js
CHANGED
|
@@ -4,28 +4,40 @@ const crypto = require("crypto");
|
|
|
4
4
|
const tls = require("tls");
|
|
5
5
|
const makeDebug = require("debug-instanced")("pairnet");
|
|
6
6
|
const matchValue = require("match-value");
|
|
7
|
+
const getIPs = require("get-ips");
|
|
7
8
|
const pipe = require("@promistream/pipe");
|
|
8
9
|
const simpleSink = require("@promistream/simple-sink");
|
|
9
10
|
|
|
10
|
-
const createMemoryStorage = require("./storages/memory");
|
|
11
|
-
const createFileStorage = require("./storages/file");
|
|
12
|
-
const { NotSet } = require("./storages/symbols");
|
|
11
|
+
const createMemoryStorage = require("./lib/storages/memory");
|
|
12
|
+
const createFileStorage = require("./lib/storages/file");
|
|
13
|
+
const { NotSet, AlreadySet } = require("./lib/storages/symbols");
|
|
13
14
|
|
|
14
|
-
const generateKeys = require("./lib/generate-keys");
|
|
15
|
+
const generateKeys = require("./lib/tls/generate-keys");
|
|
15
16
|
const createBackgroundTaskManager = require("./lib/background-tasks");
|
|
16
17
|
const createStore = require("./lib/store");
|
|
17
|
-
const getFingerprint = require("./lib/get-fingerprint");
|
|
18
|
-
const maybeCall = require("./lib/maybe-call");
|
|
18
|
+
const getFingerprint = require("./lib/tls/get-fingerprint");
|
|
19
19
|
const handleProtocol = require("./lib/protocol");
|
|
20
|
-
const serverListen = require("./promisified/server-listen");
|
|
21
|
-
const serverClose = require("./promisified/server-close");
|
|
22
|
-
|
|
23
|
-
const
|
|
20
|
+
const serverListen = require("./lib/promisified/server-listen");
|
|
21
|
+
const serverClose = require("./lib/promisified/server-close");
|
|
22
|
+
const wrappedToken = require("./lib/pairing-token/wrapped");
|
|
23
|
+
const generatePairingToken = require("./lib/pairing-token/generate");
|
|
24
|
+
const connectMultiple = require("./lib/network/connect-multiple");
|
|
25
|
+
const reduceAddresses = require("./lib/pairing-token/reduce-addresses");
|
|
26
|
+
const createQueryError = require("./lib/create-query-error");
|
|
27
|
+
|
|
28
|
+
const { validateOptions, validateArguments } = require("@validatem/core");
|
|
29
|
+
const ValidationError = require("@validatem/error");
|
|
24
30
|
const required = require("@validatem/required");
|
|
31
|
+
const defaultTo = require("@validatem/default-to");
|
|
32
|
+
const requireEither = require("@validatem/require-either");
|
|
33
|
+
const either = require("@validatem/either");
|
|
34
|
+
const arrayOf = require("@validatem/array-of");
|
|
35
|
+
const allowExtraProperties = require("@validatem/allow-extra-properties");
|
|
25
36
|
const isFunction = require("@validatem/is-function");
|
|
26
|
-
const
|
|
27
|
-
const
|
|
37
|
+
const isBuffer = require("@validatem/is-buffer");
|
|
38
|
+
const isBoolean = require("@validatem/is-boolean");
|
|
28
39
|
const isNonEmptyString = require("@validatem/is-non-empty-string");
|
|
40
|
+
const isPortNumber = require("@validatem/is-port-number");
|
|
29
41
|
|
|
30
42
|
/* TODO:
|
|
31
43
|
- Verify that errors don't get eaten anywhere
|
|
@@ -33,16 +45,10 @@ const isNonEmptyString = require("@validatem/is-non-empty-string");
|
|
|
33
45
|
- Auto-reconnection
|
|
34
46
|
*/
|
|
35
47
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return error;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function makeClientSocket({ hostname, port, options, onQuery, protocolConfiguration, debug }) {
|
|
44
|
-
let socket = tls.connect({
|
|
45
|
-
host: hostname,
|
|
48
|
+
async function makeClientSocket({ addresses, port, options, onQuery, protocolConfiguration, debug }) {
|
|
49
|
+
// FIXME: tolerate protocol mismatches in negotiation, and fall over to the next address in that case as well
|
|
50
|
+
// TODO: Skip IPv6 addresses if we can determine that the system doesn't have IPv6 connectivity anyway, and likewise for IPv4; and figure out how to test that that works correctly
|
|
51
|
+
let socket = await connectMultiple(addresses, {
|
|
46
52
|
port: port,
|
|
47
53
|
rejectUnauthorized: false,
|
|
48
54
|
// We manually pin the certificate, so we don't have to care about the subject name at all; that's why checkServerIdentity always passes
|
|
@@ -61,6 +67,7 @@ async function makeClientSocket({ hostname, port, options, onQuery, protocolConf
|
|
|
61
67
|
|
|
62
68
|
return {
|
|
63
69
|
peerFingerprint: getFingerprint(socket),
|
|
70
|
+
peerAddress: socket.remoteAddress,
|
|
64
71
|
eventStream: api.eventStream,
|
|
65
72
|
send: api.sender.sendEvent,
|
|
66
73
|
query: api.sender.sendQuery,
|
|
@@ -75,18 +82,23 @@ function isStorageProvider(value) {
|
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
module.exports = {
|
|
85
|
+
NotSet: NotSet,
|
|
86
|
+
AlreadySet: AlreadySet,
|
|
78
87
|
createQueryError: createQueryError,
|
|
79
88
|
createMemoryStorage: createMemoryStorage,
|
|
80
89
|
createFileStorage: createFileStorage,
|
|
81
90
|
createPeer: async function createPeer(_options) {
|
|
82
91
|
let debug = makeDebug("peer");
|
|
83
92
|
|
|
84
|
-
let { storage, generateClientHello, onClientHello, onServerHello } = validateOptions(arguments, {
|
|
93
|
+
let { storage, generateClientHello, onClientHello, onServerHello } = validateOptions(arguments, [{
|
|
85
94
|
storage: [ required, isStorageProvider ],
|
|
86
|
-
generateClientHello: [
|
|
87
|
-
onClientHello: [
|
|
88
|
-
onServerHello: [
|
|
89
|
-
}
|
|
95
|
+
generateClientHello: [ isFunction ],
|
|
96
|
+
onClientHello: [ isFunction ],
|
|
97
|
+
onServerHello: [ isFunction ]
|
|
98
|
+
}, either([
|
|
99
|
+
allowExtraProperties({ generateClientHello: required, onServerHello: required }),
|
|
100
|
+
allowExtraProperties({ onClientHello: required })
|
|
101
|
+
])]);
|
|
90
102
|
|
|
91
103
|
// FIXME: Why do we still have this fragile 'background task' mechanism?
|
|
92
104
|
let backgroundTasks = createBackgroundTaskManager();
|
|
@@ -130,104 +142,181 @@ module.exports = {
|
|
|
130
142
|
}
|
|
131
143
|
};
|
|
132
144
|
}
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
fingerprint: identity.fingerprint,
|
|
136
|
-
generatePairingToken: async function () {
|
|
137
|
-
let token = crypto.randomBytes(16).toString("hex");
|
|
138
|
-
// TODO: Expiry
|
|
139
|
-
await store.setValidPairingToken(token);
|
|
140
|
-
debug(`[generatePairingToken] Generated pairing token ${token}`);
|
|
141
|
-
return token;
|
|
142
|
-
},
|
|
143
|
-
pair: async function (_options) {
|
|
144
|
-
let { hostname, port, fingerprint, pairingToken } = validateOptions(arguments, {
|
|
145
|
-
hostname: [ required, isNonEmptyString ],
|
|
146
|
-
port: [ required, isInteger ],
|
|
147
|
-
fingerprint: [ required, isNonEmptyString ],
|
|
148
|
-
pairingToken: [ required, isNonEmptyString ]
|
|
149
|
-
});
|
|
150
145
|
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
async function makeRawPairingToken() {
|
|
147
|
+
let token = generatePairingToken();
|
|
148
|
+
// TODO: Expiry
|
|
149
|
+
// TODO: Allow data to be associated with the token?
|
|
150
|
+
await store.setValidPairingToken(token);
|
|
151
|
+
debug(`[generatePairingToken] Generated pairing token ${token}`);
|
|
152
|
+
return token;
|
|
153
|
+
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
async function pairRaw(_options) {
|
|
156
|
+
let { hostname, addresses, port, fingerprint, pairingToken } = validateOptions(arguments, [{
|
|
157
|
+
hostname: [ isNonEmptyString ],
|
|
158
|
+
addresses: [ arrayOf([ required, isNonEmptyString ]) ],
|
|
159
|
+
port: [ required, isPortNumber ],
|
|
160
|
+
fingerprint: [ required, isNonEmptyString ],
|
|
161
|
+
pairingToken: [ required, isBuffer ]
|
|
162
|
+
}, requireEither([ "hostname", "addresses" ])]);
|
|
163
|
+
|
|
164
|
+
if (protocolConfiguration.isClient) {
|
|
165
|
+
debug(`[pair] Initiating pairing connection to ${hostname}:${port}...`);
|
|
166
|
+
|
|
167
|
+
let socket = await makeClientSocket({
|
|
168
|
+
addresses: addresses ?? [ hostname ],
|
|
169
|
+
debug, port, protocolConfiguration,
|
|
170
|
+
// We don't care about anything other than pairing messages in this mode, which are in the form of a query reply
|
|
171
|
+
onQuery: () => {}
|
|
172
|
+
});
|
|
159
173
|
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
/* NOTE: We put the address that actually worked at the front of the list, so that that's the first thing we try upon connecting. But we leave the rest of the list in our store as well, in case we need to fall back (eg. due to temporary connectivity issues over IPv4 or IPv6 specifically). */
|
|
175
|
+
// TODO: Detect when the successful address does not exist in the original address list? To detect potential wrong assumptions in our implementation.
|
|
176
|
+
let orderedAddresses = (addresses != null)
|
|
177
|
+
? [ socket.peerAddress, ... addresses.filter((addr) => addr !== socket.peerAddress) ]
|
|
178
|
+
: [ hostname ];
|
|
162
179
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
socket.disconnect(),
|
|
166
|
-
backgroundTasks.awaitCompletion()
|
|
167
|
-
]);
|
|
168
|
-
throw new Error(`Fingerprint of server certificate does not match expected fingerprint`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// We need to drive the read loop, even though we don't care about the events
|
|
172
|
-
let reader = pipe([
|
|
173
|
-
socket.eventStream,
|
|
174
|
-
simpleSink(() => {
|
|
175
|
-
debug("[pair] Ignored event message in pairing mode");
|
|
176
|
-
})
|
|
177
|
-
]);
|
|
180
|
+
debug(`[pair] Expected fingerprint: ${fingerprint}`);
|
|
181
|
+
debug(`[pair] Actual fingerprint: ${socket.peerFingerprint}`);
|
|
178
182
|
|
|
183
|
+
if (socket.peerFingerprint !== fingerprint) {
|
|
179
184
|
await Promise.all([
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
(async () => {
|
|
183
|
-
try {
|
|
184
|
-
// Let any pairing errors throw
|
|
185
|
-
await socket.query({ type: "PAIR", token: pairingToken });
|
|
186
|
-
await store.trustPeer(fingerprint);
|
|
187
|
-
} finally {
|
|
188
|
-
await socket.disconnect();
|
|
189
|
-
}
|
|
190
|
-
})()
|
|
185
|
+
socket.disconnect(),
|
|
186
|
+
backgroundTasks.awaitCompletion()
|
|
191
187
|
]);
|
|
188
|
+
throw new Error(`Fingerprint of server certificate does not match expected fingerprint`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// We need to drive the read loop, even though we don't care about the events
|
|
192
|
+
let reader = pipe([
|
|
193
|
+
socket.eventStream,
|
|
194
|
+
simpleSink(() => {
|
|
195
|
+
debug("[pair] Ignored event message in pairing mode");
|
|
196
|
+
})
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
await Promise.all([
|
|
200
|
+
// FIXME: Await completion of background tasks?
|
|
201
|
+
reader.read(),
|
|
202
|
+
(async () => {
|
|
203
|
+
try {
|
|
204
|
+
// Let any pairing errors throw
|
|
205
|
+
await socket.query({ type: "PAIR", token: pairingToken });
|
|
206
|
+
await store.trustPeer(fingerprint, { addresses: orderedAddresses, port });
|
|
207
|
+
} finally {
|
|
208
|
+
await socket.disconnect();
|
|
209
|
+
}
|
|
210
|
+
})()
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
return fingerprint;
|
|
214
|
+
} else {
|
|
215
|
+
throw new Error(`This peer is not configured as a client`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function connectRaw({ addresses, port, onQuery }) {
|
|
220
|
+
/* FIXME[security]: Verify that there are no exploitable issues if eg. one trusted peer presents the certificate of another trusted peer on an unexpected address (especially when connecting by fingerprint!) */
|
|
221
|
+
if (protocolConfiguration.isClient) {
|
|
222
|
+
debug(`[connect] Initiating client connection to (${addresses.join(", ")}):${port}...`);
|
|
223
|
+
|
|
224
|
+
// connect with client certificate made from own identity, and validate server pubkey
|
|
225
|
+
let socket = await makeClientSocket({
|
|
226
|
+
addresses: addresses,
|
|
227
|
+
debug, port, protocolConfiguration,
|
|
228
|
+
onQuery: onQuery
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
let isPaired = await store.isPeerTrusted(socket.peerFingerprint);
|
|
232
|
+
|
|
233
|
+
debug(`[connect] Peer fingerprint: ${socket.peerFingerprint}`);
|
|
234
|
+
debug(`[connect] Is trusted: ${isPaired}`);
|
|
235
|
+
|
|
236
|
+
if (isPaired !== true) {
|
|
237
|
+
await socket.disconnect();
|
|
238
|
+
throw new Error(`Not paired to specified peer`);
|
|
192
239
|
} else {
|
|
193
|
-
|
|
240
|
+
return makeProtocolPeer(socket, backgroundTasks);
|
|
194
241
|
}
|
|
242
|
+
} else {
|
|
243
|
+
throw new Error(`This peer is not configured as a client`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
fingerprint: identity.fingerprint,
|
|
249
|
+
generatePairingToken: async function (_port, _options) {
|
|
250
|
+
let [ port, { compact }] = validateArguments(arguments, {
|
|
251
|
+
port: [ required, isPortNumber ],
|
|
252
|
+
options: [ defaultTo({}), {
|
|
253
|
+
compact: [ defaultTo(false), isBoolean ]
|
|
254
|
+
}]
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
let ips = await getIPs();
|
|
258
|
+
|
|
259
|
+
return wrappedToken.encode({
|
|
260
|
+
addresses: (compact === true)
|
|
261
|
+
? reduceAddresses(ips.all)
|
|
262
|
+
: ips.all,
|
|
263
|
+
port: port,
|
|
264
|
+
rawToken: await makeRawPairingToken(),
|
|
265
|
+
fingerprint: identity.fingerprint,
|
|
266
|
+
});
|
|
195
267
|
},
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
onQuery: [ required, isFunction ],
|
|
268
|
+
generateRawPairingToken: makeRawPairingToken,
|
|
269
|
+
pair: function (_token) {
|
|
270
|
+
let [ token ] = validateArguments(arguments, {
|
|
271
|
+
token: [ required, isNonEmptyString ]
|
|
201
272
|
});
|
|
202
273
|
|
|
203
|
-
|
|
204
|
-
debug(`[connect] Initiating client connection to ${hostname}:${port}...`);
|
|
274
|
+
let tokenData = wrappedToken.decode(token);
|
|
205
275
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
276
|
+
return pairRaw({
|
|
277
|
+
addresses: tokenData.addresses.map((item) => item.address),
|
|
278
|
+
port: tokenData.port,
|
|
279
|
+
fingerprint: tokenData.fingerprint,
|
|
280
|
+
pairingToken: tokenData.rawToken
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
pairRaw: pairRaw,
|
|
284
|
+
async connect(_options) {
|
|
285
|
+
let { fingerprint, onQuery } = validateOptions(arguments, {
|
|
286
|
+
fingerprint: [ required, isNonEmptyString ], // FIXME: Validate stricter
|
|
287
|
+
onQuery: [ required, isFunction ],
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
let storedData = await store.getPeerData(fingerprint);
|
|
291
|
+
|
|
292
|
+
if (storedData === NotSet) {
|
|
293
|
+
throw new Error(`Specified fingerprint is not known, cannot connect`);
|
|
294
|
+
} else if (storedData.addresses == null) {
|
|
295
|
+
throw new Error(`No stored addresses known for peer; try connectRaw instead`);
|
|
296
|
+
} else {
|
|
297
|
+
return connectRaw({
|
|
298
|
+
addresses: storedData.addresses,
|
|
299
|
+
port: storedData.port,
|
|
209
300
|
onQuery: onQuery
|
|
210
301
|
});
|
|
211
|
-
|
|
212
|
-
let isPaired = await store.isPeerTrusted(socket.peerFingerprint);
|
|
213
|
-
|
|
214
|
-
debug(`[connect] Peer fingerprint: ${socket.peerFingerprint}`);
|
|
215
|
-
debug(`[connect] Is trusted: ${isPaired}`);
|
|
216
|
-
|
|
217
|
-
if (isPaired !== true) {
|
|
218
|
-
await socket.disconnect();
|
|
219
|
-
throw new Error(`Not paired to specified peer`);
|
|
220
|
-
} else {
|
|
221
|
-
return makeProtocolPeer(socket, backgroundTasks);
|
|
222
|
-
}
|
|
223
|
-
} else {
|
|
224
|
-
throw new Error(`This peer is not configured as a client`);
|
|
225
302
|
}
|
|
226
303
|
},
|
|
304
|
+
connectRaw: async function (_options) {
|
|
305
|
+
let { hostname, port, onQuery } = validateOptions(arguments, {
|
|
306
|
+
hostname: [ required, isNonEmptyString ],
|
|
307
|
+
port: [ required, isPortNumber ],
|
|
308
|
+
onQuery: [ required, isFunction ],
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return connectRaw({
|
|
312
|
+
addresses: [ hostname ],
|
|
313
|
+
port, onQuery,
|
|
314
|
+
});
|
|
315
|
+
},
|
|
227
316
|
createServer: async function (_option) {
|
|
228
317
|
let { hostname, port, onConnect, onDisconnect, onQuery } = validateOptions(arguments, {
|
|
229
|
-
hostname: [
|
|
230
|
-
port: [ required,
|
|
318
|
+
hostname: [ isNonEmptyString ], /* undefined == listen on all public interfaces */
|
|
319
|
+
port: [ required, isPortNumber ],
|
|
231
320
|
onConnect: [ required, isFunction ],
|
|
232
321
|
onDisconnect: [ isFunction ],
|
|
233
322
|
onQuery: [ required, isFunction ]
|
|
@@ -263,12 +352,13 @@ module.exports = {
|
|
|
263
352
|
return matchValue(query.type, {
|
|
264
353
|
PAIR: async () => {
|
|
265
354
|
if (isPaired) {
|
|
355
|
+
// FIXME: Query error instead?
|
|
266
356
|
throw new Error(`Already paired`);
|
|
267
357
|
} else {
|
|
268
358
|
let { token } = query;
|
|
269
359
|
|
|
270
360
|
if (await store.isValidPairingToken(token) === true) {
|
|
271
|
-
await store.trustPeer(clientFingerprint);
|
|
361
|
+
await store.trustPeer(clientFingerprint, { /* FIXME */ });
|
|
272
362
|
await store.removeValidPairingToken(token);
|
|
273
363
|
return true;
|
|
274
364
|
} else {
|
|
@@ -282,6 +372,7 @@ module.exports = {
|
|
|
282
372
|
return onQuery(clientFingerprint, query.m);
|
|
283
373
|
} else {
|
|
284
374
|
// FIXME: Force disconnect?
|
|
375
|
+
// FIXME: Query error instead?
|
|
285
376
|
throw new Error(`Client unauthorized`);
|
|
286
377
|
}
|
|
287
378
|
},
|
|
@@ -305,7 +396,7 @@ module.exports = {
|
|
|
305
396
|
await onConnect(protocolPeer);
|
|
306
397
|
|
|
307
398
|
socket.on("close", () => {
|
|
308
|
-
|
|
399
|
+
onDisconnect?.(protocolPeer);
|
|
309
400
|
});
|
|
310
401
|
}
|
|
311
402
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("node:assert");
|
|
4
|
+
const tls = require("node:tls");
|
|
5
|
+
const timers = require("node:timers/promises");
|
|
6
|
+
|
|
7
|
+
function noop() {}
|
|
8
|
+
|
|
9
|
+
module.exports = async function connectMultiple(addresses, options) {
|
|
10
|
+
return new Promise(async (resolveWinner, rejectAll) => {
|
|
11
|
+
/* attempt a connection to a series of addresses, until one succeeds. start a new connection every time a previous connection fails *or* every X milliseconds, whichever comes first, and then consider the 'winning' connection to be whichever one successfully completes connecting first. this mechanism ensures that when some addresses are very slow to respond (eg. due to network weather), this doesn't hold up any faster options that may appear after it in the list, keeping the delay from having to fail over to a different address minimal. */
|
|
12
|
+
/* NOTE: This function assumes that only IP addresses are passed in the `addresses` array; in particular, it assumes that if any connection attempt failed for a given address, that entire address has failed. This is not compatible with the family auto-selection that may occur for DNS names, where only the second IP address attempted may succeed. */
|
|
13
|
+
let pendingConnections = new Set();
|
|
14
|
+
let allConnectionPromises = [];
|
|
15
|
+
let connectionErrors = [];
|
|
16
|
+
let winner;
|
|
17
|
+
|
|
18
|
+
for (let address of addresses) {
|
|
19
|
+
assert(address != null); /* undefined == implicit localhost */
|
|
20
|
+
let connection = tls.connect({ ... options, host: address });
|
|
21
|
+
pendingConnections.add(connection);
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line no-loop-func
|
|
24
|
+
let connectionPromise = new Promise((resolve, reject) => {
|
|
25
|
+
let timeout = setTimeout(() => connection.destroy(), 5000);
|
|
26
|
+
|
|
27
|
+
connection.on("error", noop);
|
|
28
|
+
|
|
29
|
+
connection.on("connect", () => {
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
|
|
32
|
+
/* Abort any other connection attempts (whether or not they have already connected, although the latter is very unlikely to happen) */
|
|
33
|
+
for (let otherCandidate of pendingConnections) {
|
|
34
|
+
if (otherCandidate !== connection) {
|
|
35
|
+
otherCandidate.destroy();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
winner = connection;
|
|
40
|
+
resolve();
|
|
41
|
+
resolveWinner(connection);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
connection.on("connectionAttemptFailed", (ip, port, family, error) => {
|
|
45
|
+
pendingConnections.delete(connection);
|
|
46
|
+
reject(error);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
allConnectionPromises.push(connectionPromise.catch(() => {/*ignore*/}));
|
|
51
|
+
|
|
52
|
+
await Promise.race([
|
|
53
|
+
timers.setTimeout(100),
|
|
54
|
+
connectionPromise.catch((error) => { connectionErrors.push(error); })
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
if (winner != null) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Wait for every connection attempt to complete, one of them may just be slow (and we want all of the connection errors in any case) */
|
|
63
|
+
await Promise.all(allConnectionPromises);
|
|
64
|
+
|
|
65
|
+
if (winner == null) {
|
|
66
|
+
let aggregateError = new Error("Could not connect to any of the specified addresses");
|
|
67
|
+
aggregateError.errors = connectionErrors;
|
|
68
|
+
rejectAll(aggregateError);
|
|
69
|
+
} else {
|
|
70
|
+
/* By this point the socket is no longer under our control, and we want to make sure that we don't swallow any errors after this point */
|
|
71
|
+
winner.removeListener("error", noop);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const isLocalIP = require("is-local-ip");
|
|
4
|
+
|
|
5
|
+
module.exports = function reduceAddresses(addresses) {
|
|
6
|
+
let seen4 = false;
|
|
7
|
+
let seen6 = false;
|
|
8
|
+
let reducedList = [];
|
|
9
|
+
|
|
10
|
+
for (let address of addresses) {
|
|
11
|
+
if (address.family === 4 && !seen4 && !isLocalIP(address.address)) {
|
|
12
|
+
seen4 = true;
|
|
13
|
+
reducedList.push(address);
|
|
14
|
+
} else if (address.family === 6 && !seen6 && !isLocalIP(address.address)) {
|
|
15
|
+
seen6 = true;
|
|
16
|
+
reducedList.push(address);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return reducedList;
|
|
21
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("node:assert");
|
|
4
|
+
const cbor = require("cbor");
|
|
5
|
+
|
|
6
|
+
function uncompactAddress(compactedAddress) {
|
|
7
|
+
if (Buffer.isBuffer(compactedAddress)) {
|
|
8
|
+
/* IPv4 address */
|
|
9
|
+
return {
|
|
10
|
+
family: 4,
|
|
11
|
+
address: Array.from(compactedAddress)
|
|
12
|
+
.map((byte) => byte.toString(10))
|
|
13
|
+
.join(".")
|
|
14
|
+
};
|
|
15
|
+
} else {
|
|
16
|
+
/* IPv6 address */
|
|
17
|
+
return {
|
|
18
|
+
family: 6,
|
|
19
|
+
address: compactedAddress
|
|
20
|
+
.map((value) => value == null ? "" : value.toString(16))
|
|
21
|
+
.join(":")
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function uncompactFingerprint(compactedFingerprint) {
|
|
27
|
+
return Array.from(compactedFingerprint)
|
|
28
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
29
|
+
.join(":")
|
|
30
|
+
.toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compactAddress(address, family) {
|
|
34
|
+
/* This significantly reduces encoded token size */
|
|
35
|
+
// FIXME: Stricter validation/assertions to catch bugs loudly
|
|
36
|
+
if (family === 4) {
|
|
37
|
+
return Buffer.from(address.split(".").map((segment) => parseInt(segment, 10)));
|
|
38
|
+
} else if (family === 6) {
|
|
39
|
+
return address.split(":").map((segment) => segment === "" ? null : parseInt(segment, 16));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compactFingerprint(fingerprint) {
|
|
44
|
+
// FIXME: Stricter validation/assertions to catch bugs loudly
|
|
45
|
+
return Buffer.from(fingerprint.split(":").map((byte) => parseInt(byte, 16)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
encode({ addresses, port, rawToken, fingerprint }) {
|
|
50
|
+
/* We use an array to shave a couple bytes off the token size */
|
|
51
|
+
let tokenContents = [
|
|
52
|
+
0, /* token version */
|
|
53
|
+
addresses.map((ip) => compactAddress(ip.address, ip.family)),
|
|
54
|
+
port,
|
|
55
|
+
compactFingerprint(fingerprint),
|
|
56
|
+
rawToken,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
return cbor.encode(tokenContents)
|
|
60
|
+
.toString("base64")
|
|
61
|
+
.replace(/\//g, "_")
|
|
62
|
+
.replace(/\+/g, "-")
|
|
63
|
+
.replace(/=+$/, "");
|
|
64
|
+
},
|
|
65
|
+
decode(wrappedToken) {
|
|
66
|
+
// FIXME: Validate decoded token strictly!
|
|
67
|
+
let data = cbor.decode(Buffer.from(wrappedToken, "base64"));
|
|
68
|
+
assert(Array.isArray(data) && data.length === 5);
|
|
69
|
+
|
|
70
|
+
let [ tokenVersion, compactedAddresses, port, compactedFingerprint, rawToken ] = data;
|
|
71
|
+
assert(tokenVersion === 0);
|
|
72
|
+
let addresses = compactedAddresses.map((address) => uncompactAddress(address));
|
|
73
|
+
let fingerprint = uncompactFingerprint(compactedFingerprint);
|
|
74
|
+
|
|
75
|
+
return { addresses, port, rawToken, fingerprint };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
module.exports = async function evaluateQuery(query, handler) {
|
|
4
|
+
try {
|
|
5
|
+
return {
|
|
6
|
+
success: true,
|
|
7
|
+
message: await handler(query)
|
|
8
|
+
};
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error.code === "QUERY_ERROR_RESPONSE") {
|
|
11
|
+
return {
|
|
12
|
+
success: false,
|
|
13
|
+
message: error.messageData
|
|
14
|
+
};
|
|
15
|
+
} else {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|