pairnet 0.1.0 → 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/eslint.config.js +3 -0
- package/index.js +206 -109
- 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/{promisified → lib/promisified}/server-close.js +2 -2
- package/{promisified → lib/promisified}/server-listen.js +2 -2
- 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 +32 -27
- package/test/index.js +54 -21
- package/test/wrapped-token.js +30 -0
- package/.eslintrc +0 -3
- package/lib/maybe-call.js +0 -7
- package/lib/protocol.js +0 -191
- package/promisified/disconnect-socket.js +0 -11
- package/streams/infinite-queue.js +0 -37
- /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/eslint.config.js
ADDED
package/index.js
CHANGED
|
@@ -2,30 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require("crypto");
|
|
4
4
|
const tls = require("tls");
|
|
5
|
-
const
|
|
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 }) {
|
|
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
|
|
@@ -53,12 +59,15 @@ async function makeClientSocket({ hostname, port, options, onQuery, protocolConf
|
|
|
53
59
|
});
|
|
54
60
|
|
|
55
61
|
let api = await handleProtocol(socket, {
|
|
62
|
+
debug,
|
|
56
63
|
protocolConfiguration: protocolConfiguration,
|
|
64
|
+
role: "client",
|
|
57
65
|
onQuery: onQuery
|
|
58
66
|
});
|
|
59
67
|
|
|
60
68
|
return {
|
|
61
69
|
peerFingerprint: getFingerprint(socket),
|
|
70
|
+
peerAddress: socket.remoteAddress,
|
|
62
71
|
eventStream: api.eventStream,
|
|
63
72
|
send: api.sender.sendEvent,
|
|
64
73
|
query: api.sender.sendQuery,
|
|
@@ -73,16 +82,23 @@ function isStorageProvider(value) {
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
module.exports = {
|
|
85
|
+
NotSet: NotSet,
|
|
86
|
+
AlreadySet: AlreadySet,
|
|
76
87
|
createQueryError: createQueryError,
|
|
77
88
|
createMemoryStorage: createMemoryStorage,
|
|
78
89
|
createFileStorage: createFileStorage,
|
|
79
90
|
createPeer: async function createPeer(_options) {
|
|
80
|
-
let
|
|
91
|
+
let debug = makeDebug("peer");
|
|
92
|
+
|
|
93
|
+
let { storage, generateClientHello, onClientHello, onServerHello } = validateOptions(arguments, [{
|
|
81
94
|
storage: [ required, isStorageProvider ],
|
|
82
|
-
generateClientHello: [
|
|
83
|
-
onClientHello: [
|
|
84
|
-
onServerHello: [
|
|
85
|
-
}
|
|
95
|
+
generateClientHello: [ isFunction ],
|
|
96
|
+
onClientHello: [ isFunction ],
|
|
97
|
+
onServerHello: [ isFunction ]
|
|
98
|
+
}, either([
|
|
99
|
+
allowExtraProperties({ generateClientHello: required, onServerHello: required }),
|
|
100
|
+
allowExtraProperties({ onClientHello: required })
|
|
101
|
+
])]);
|
|
86
102
|
|
|
87
103
|
// FIXME: Why do we still have this fragile 'background task' mechanism?
|
|
88
104
|
let backgroundTasks = createBackgroundTaskManager();
|
|
@@ -126,104 +142,181 @@ module.exports = {
|
|
|
126
142
|
}
|
|
127
143
|
};
|
|
128
144
|
}
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
fingerprint: identity.fingerprint,
|
|
132
|
-
generatePairingToken: async function () {
|
|
133
|
-
let token = crypto.randomBytes(16).toString("hex");
|
|
134
|
-
// TODO: Expiry
|
|
135
|
-
await store.setValidPairingToken(token);
|
|
136
|
-
debug(`[generatePairingToken] Generated pairing token ${token}`);
|
|
137
|
-
return token;
|
|
138
|
-
},
|
|
139
|
-
pair: async function (_options) {
|
|
140
|
-
let { hostname, port, fingerprint, pairingToken } = validateOptions(arguments, {
|
|
141
|
-
hostname: [ required, isNonEmptyString ],
|
|
142
|
-
port: [ required, isInteger ],
|
|
143
|
-
fingerprint: [ required, isNonEmptyString ],
|
|
144
|
-
pairingToken: [ required, isNonEmptyString ]
|
|
145
|
-
});
|
|
146
145
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
154
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
});
|
|
155
173
|
|
|
156
|
-
|
|
157
|
-
|
|
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 ];
|
|
158
179
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
socket.disconnect(),
|
|
162
|
-
backgroundTasks.awaitCompletion()
|
|
163
|
-
]);
|
|
164
|
-
throw new Error(`Fingerprint of server certificate does not match expected fingerprint`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// We need to drive the read loop, even though we don't care about the events
|
|
168
|
-
let reader = pipe([
|
|
169
|
-
socket.eventStream,
|
|
170
|
-
simpleSink(() => {
|
|
171
|
-
debug("[pair] Ignored event message in pairing mode");
|
|
172
|
-
})
|
|
173
|
-
]);
|
|
180
|
+
debug(`[pair] Expected fingerprint: ${fingerprint}`);
|
|
181
|
+
debug(`[pair] Actual fingerprint: ${socket.peerFingerprint}`);
|
|
174
182
|
|
|
183
|
+
if (socket.peerFingerprint !== fingerprint) {
|
|
175
184
|
await Promise.all([
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
(async () => {
|
|
179
|
-
try {
|
|
180
|
-
// Let any pairing errors throw
|
|
181
|
-
await socket.query({ type: "PAIR", token: pairingToken });
|
|
182
|
-
await store.trustPeer(fingerprint);
|
|
183
|
-
} finally {
|
|
184
|
-
await socket.disconnect();
|
|
185
|
-
}
|
|
186
|
-
})()
|
|
185
|
+
socket.disconnect(),
|
|
186
|
+
backgroundTasks.awaitCompletion()
|
|
187
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`);
|
|
188
239
|
} else {
|
|
189
|
-
|
|
240
|
+
return makeProtocolPeer(socket, backgroundTasks);
|
|
190
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
|
+
});
|
|
191
267
|
},
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
onQuery: [ required, isFunction ],
|
|
268
|
+
generateRawPairingToken: makeRawPairingToken,
|
|
269
|
+
pair: function (_token) {
|
|
270
|
+
let [ token ] = validateArguments(arguments, {
|
|
271
|
+
token: [ required, isNonEmptyString ]
|
|
197
272
|
});
|
|
198
273
|
|
|
199
|
-
|
|
200
|
-
debug(`[connect] Initiating client connection to ${hostname}:${port}...`);
|
|
274
|
+
let tokenData = wrappedToken.decode(token);
|
|
201
275
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
205
300
|
onQuery: onQuery
|
|
206
301
|
});
|
|
207
|
-
|
|
208
|
-
let isPaired = await store.isPeerTrusted(socket.peerFingerprint);
|
|
209
|
-
|
|
210
|
-
debug(`[connect] Peer fingerprint: ${socket.peerFingerprint}`);
|
|
211
|
-
debug(`[connect] Is trusted: ${isPaired}`);
|
|
212
|
-
|
|
213
|
-
if (isPaired !== true) {
|
|
214
|
-
await socket.disconnect();
|
|
215
|
-
throw new Error(`Not paired to specified peer`);
|
|
216
|
-
} else {
|
|
217
|
-
return makeProtocolPeer(socket, backgroundTasks);
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
throw new Error(`This peer is not configured as a client`);
|
|
221
302
|
}
|
|
222
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
|
+
},
|
|
223
316
|
createServer: async function (_option) {
|
|
224
317
|
let { hostname, port, onConnect, onDisconnect, onQuery } = validateOptions(arguments, {
|
|
225
|
-
hostname: [
|
|
226
|
-
port: [ required,
|
|
318
|
+
hostname: [ isNonEmptyString ], /* undefined == listen on all public interfaces */
|
|
319
|
+
port: [ required, isPortNumber ],
|
|
227
320
|
onConnect: [ required, isFunction ],
|
|
228
321
|
onDisconnect: [ isFunction ],
|
|
229
322
|
onQuery: [ required, isFunction ]
|
|
@@ -252,17 +345,20 @@ module.exports = {
|
|
|
252
345
|
debug(`[server] Is trusted: ${isPaired}`);
|
|
253
346
|
|
|
254
347
|
let api = await handleProtocol(socket, {
|
|
348
|
+
debug,
|
|
255
349
|
protocolConfiguration,
|
|
350
|
+
role: "server",
|
|
256
351
|
onQuery: async (query) => {
|
|
257
352
|
return matchValue(query.type, {
|
|
258
353
|
PAIR: async () => {
|
|
259
354
|
if (isPaired) {
|
|
355
|
+
// FIXME: Query error instead?
|
|
260
356
|
throw new Error(`Already paired`);
|
|
261
357
|
} else {
|
|
262
358
|
let { token } = query;
|
|
263
359
|
|
|
264
360
|
if (await store.isValidPairingToken(token) === true) {
|
|
265
|
-
await store.trustPeer(clientFingerprint);
|
|
361
|
+
await store.trustPeer(clientFingerprint, { /* FIXME */ });
|
|
266
362
|
await store.removeValidPairingToken(token);
|
|
267
363
|
return true;
|
|
268
364
|
} else {
|
|
@@ -276,6 +372,7 @@ module.exports = {
|
|
|
276
372
|
return onQuery(clientFingerprint, query.m);
|
|
277
373
|
} else {
|
|
278
374
|
// FIXME: Force disconnect?
|
|
375
|
+
// FIXME: Query error instead?
|
|
279
376
|
throw new Error(`Client unauthorized`);
|
|
280
377
|
}
|
|
281
378
|
},
|
|
@@ -299,7 +396,7 @@ module.exports = {
|
|
|
299
396
|
await onConnect(protocolPeer);
|
|
300
397
|
|
|
301
398
|
socket.on("close", () => {
|
|
302
|
-
|
|
399
|
+
onDisconnect?.(protocolPeer);
|
|
303
400
|
});
|
|
304
401
|
}
|
|
305
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
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { once } = require("node:events");
|
|
4
4
|
const debug = require("debug")("pairnet:server-listen");
|
|
5
5
|
|
|
6
6
|
module.exports = async function serverListen(server, port, hostname) {
|
|
7
7
|
server.listen(port, hostname);
|
|
8
|
-
await
|
|
8
|
+
await once(server, "listening");
|
|
9
9
|
debug(`[createServer] Listening on ${hostname}:${port}...`);
|
|
10
10
|
};
|
|
@@ -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
|
+
};
|