@factoidal/core 0.32.0 → 0.34.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/CHANGELOG.md +21 -2
- package/bin/factoidal.mjs +184 -23
- package/bin/foafnaut.mjs +29 -7
- package/browser.js +5 -2
- package/index.d.ts +20 -7
- package/index.mjs +7 -4
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +5 -3
- package/lib/sbm11-lws.mjs +25 -35
- package/lib/version.mjs +1 -1
- package/lws/server.mjs +82 -14
- package/package.json +1 -1
- package/webdav/server.mjs +12 -0
- package/xmpp/README.md +58 -4
- package/xmpp/client.mjs +18 -3
- package/xmpp/inpage.mjs +7 -1
- package/xmpp/server.mjs +314 -25
package/xmpp/server.mjs
CHANGED
|
@@ -37,13 +37,26 @@
|
|
|
37
37
|
// server-to-server federation, and any limit on how many bytes
|
|
38
38
|
// a client may send without completing a unit beyond `maxBuffered`,
|
|
39
39
|
// which is a transport limit this host applies by closing the socket.
|
|
40
|
-
// The other transport limits are `maxConnections`, `loginTimeoutMs
|
|
41
|
-
// `maxQueuedBytes
|
|
42
|
-
//
|
|
40
|
+
// The other transport limits are `maxConnections`, `loginTimeoutMs`,
|
|
41
|
+
// `maxQueuedBytes`, and `pingIntervalMs` with `pingTimeoutMs`: a bound session
|
|
42
|
+
// silent for the interval gets a ping from the engine (`xmppSessionPing`, or
|
|
43
|
+
// `xmppWsPing` on a WebSocket), and one that sends no byte in the timeout after
|
|
44
|
+
// that is closed. There is no connection limit per address: behind a
|
|
45
|
+
// proxy, every client has the same one.
|
|
46
|
+
//
|
|
47
|
+
// Client addresses (the failed-login limit): the engine keeps a tag of each,
|
|
48
|
+
// HMAC-SHA-256 under `addressKey`, a random key made here at start and kept in
|
|
49
|
+
// memory only (`L4Factoidal/Net/LoginLimit.lean`, `addressTag`). Logs show the
|
|
50
|
+
// first 12 digits of the tag, never the address. With `proxyProtocol` each
|
|
51
|
+
// connection must start with a PROXY protocol v2 header, which names the client
|
|
52
|
+
// behind a proxy; the engine decides on it (`proxyHeaderParse`,
|
|
53
|
+
// `L4Factoidal/Net/ProxyProtocol.lean`) and this file only buffers the bytes,
|
|
54
|
+
// then hands what follows the header to TLS or to the XMPP session.
|
|
43
55
|
|
|
44
|
-
import { randomBytes } from 'node:crypto'
|
|
56
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
45
57
|
import { StringDecoder } from 'node:string_decoder'
|
|
46
|
-
import {
|
|
58
|
+
import { Duplex } from 'node:stream'
|
|
59
|
+
import { mkdir, readFile, writeFile, readdir, unlink, rename, stat } from 'node:fs/promises'
|
|
47
60
|
import { join } from 'node:path'
|
|
48
61
|
import { loadEngine } from '../bin/engine.mjs'
|
|
49
62
|
import { createXmppRouter } from './router.mjs'
|
|
@@ -51,8 +64,8 @@ import { createXmppRouter } from './router.mjs'
|
|
|
51
64
|
/** The ops this host needs from the engine. */
|
|
52
65
|
export const XMPP_SERVER_OPS = [
|
|
53
66
|
'xmppOpen', 'xmppClose',
|
|
54
|
-
'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionClose',
|
|
55
|
-
'xmppSessionTlsUp', 'xmppRosterGet', 'xmppRosterSet', 'xmppWsOpen', 'xmppStateLoad'
|
|
67
|
+
'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionPing', 'xmppSessionClose',
|
|
68
|
+
'xmppSessionTlsUp', 'xmppRosterGet', 'xmppRosterSet', 'xmppWsOpen', 'xmppStateLoad', 'xmppClock'
|
|
56
69
|
]
|
|
57
70
|
|
|
58
71
|
/** An error raised by this host, as opposed to one the engine reported.
|
|
@@ -119,10 +132,19 @@ function randomToken (bytes = 18) {
|
|
|
119
132
|
* @param {object} options
|
|
120
133
|
* @param {string} [options.domain] the domain the server is authoritative for (default `localhost`)
|
|
121
134
|
* @param {Array<{username: string, password: string}>} [options.accounts]
|
|
122
|
-
* @param {string} [options.accountsText]
|
|
123
|
-
*
|
|
135
|
+
* @param {string} [options.accountsText] the accounts file's text, one account per line:
|
|
136
|
+
* `name:password`, or the keys `factoidal xmpp-serve --verifier NAME` writes. A line that is
|
|
137
|
+
* neither stops the start (`Server.accountsOfText`)
|
|
138
|
+
* @param {string} [options.saltB64] one SCRAM salt for every name, base64; absent, each name has its own
|
|
139
|
+
* @param {string} [options.saltSeed] the seed of the salts; absent, a random one for this process
|
|
140
|
+
* @param {string} [options.occupantSeed] the key of the rooms' occupant ids; absent, a random one
|
|
124
141
|
* @param {number} [options.iterationCount] the SCRAM iteration count
|
|
125
142
|
* @param {boolean} [options.tlsCarrier] whether the carrier terminates TLS
|
|
143
|
+
* @param {boolean} [options.disableRateLimits] no failed SASL login limit and no sender rate on
|
|
144
|
+
* held stanzas (`--dangerously-disable-ratelimits`)
|
|
145
|
+
* @param {object} [options.heldLimits] caps of the stanzas held for offline accounts, each a
|
|
146
|
+
* number: `perAccount`, `perAccountBytes`, `total`, `totalBytes`, `senderPerWindow`,
|
|
147
|
+
* `senderWindow` (seconds); what is absent keeps the engine's default
|
|
126
148
|
* @param {object} [options.engine] an engine handle to reuse
|
|
127
149
|
* @returns {Promise<{engine: object, handle: string, domain: string,
|
|
128
150
|
* openSession: Function, roster: Function, setRoster: Function, close: Function}>}
|
|
@@ -133,13 +155,23 @@ export async function openXmppServer (options = {}) {
|
|
|
133
155
|
if (typeof options.domain === 'string') config.domain = options.domain
|
|
134
156
|
if (Array.isArray(options.accounts)) config.accounts = options.accounts
|
|
135
157
|
if (typeof options.accountsText === 'string') config.accountsText = options.accountsText
|
|
158
|
+
// One salt per name, from a seed the engine cannot make; a fixed `saltB64`
|
|
159
|
+
// (a transcript that must replay) is one salt for all.
|
|
136
160
|
if (typeof options.saltB64 === 'string') config.saltB64 = options.saltB64
|
|
161
|
+
else config.saltSeed = typeof options.saltSeed === 'string' ? options.saltSeed : randomToken(32)
|
|
162
|
+
// The key of the XEP-0421 occupant ids of the rooms.
|
|
163
|
+
config.occupantSeed = typeof options.occupantSeed === 'string' ? options.occupantSeed : randomToken(32)
|
|
137
164
|
if (typeof options.iterationCount === 'number') config.iterationCount = options.iterationCount
|
|
138
165
|
if (typeof options.tlsCarrier === 'boolean') config.tlsCarrier = options.tlsCarrier
|
|
139
166
|
// XEP-0045 room service (L4Factoidal/XMPP/Muc.lean); off unless named.
|
|
140
167
|
if (typeof options.mucDomain === 'string') config.mucDomain = options.mucDomain
|
|
141
168
|
// Members-only rooms: [{room, members: [bare JID]}]; no list, an open room.
|
|
142
169
|
if (Array.isArray(options.mucMembers)) config.mucMembers = options.mucMembers
|
|
170
|
+
// Owners: [{room, owners: [bare JID]}], room '*' for every room. Present, also empty: nobody
|
|
171
|
+
// becomes an owner by being first in a room. Absent: the first occupant is the owner.
|
|
172
|
+
if (Array.isArray(options.mucOwners)) config.mucOwners = options.mucOwners
|
|
173
|
+
// Nicks reserved for a JID: [{nick, jid}]. Each account's name is reserved for it anyway.
|
|
174
|
+
if (Array.isArray(options.mucReservedNicks)) config.mucReservedNicks = options.mucReservedNicks
|
|
143
175
|
// XEP-0369 channels (L4Factoidal/XMPP/Mix.lean), joined through MIX-PAM; off unless named.
|
|
144
176
|
if (typeof options.mixDomain === 'string') config.mixDomain = options.mixDomain
|
|
145
177
|
// GC3 prototype groups (L4Factoidal/XMPP/Gc3.lean; no published spec); off unless named.
|
|
@@ -147,6 +179,13 @@ export async function openXmppServer (options = {}) {
|
|
|
147
179
|
// The host keeps the state across restarts (a state key and a DIR): only
|
|
148
180
|
// then does the engine produce `store`/`unstore`.
|
|
149
181
|
if (options.persist === true) config.persist = true
|
|
182
|
+
// The failed SASL login limit is on unless the operator turns it off
|
|
183
|
+
// (https://github.com/danbri/factoidal/issues/1128); the engine decides.
|
|
184
|
+
if (options.disableRateLimits === true) config.disableRateLimits = true
|
|
185
|
+
if (options.heldLimits && typeof options.heldLimits === 'object') config.heldLimits = options.heldLimits
|
|
186
|
+
// The key of the client address tags: random, in memory only, new at each start.
|
|
187
|
+
// A tag is no use to anyone who does not hold it, so it never goes to a log or a file.
|
|
188
|
+
config.addressKey = typeof options.addressKey === 'string' ? options.addressKey : randomToken(32)
|
|
150
189
|
let opened
|
|
151
190
|
try {
|
|
152
191
|
opened = engine.call('xmppOpen', [JSON.stringify(config)])
|
|
@@ -162,25 +201,50 @@ export async function openXmppServer (options = {}) {
|
|
|
162
201
|
throw new XmppHostError('xmppOpen answered with no handle')
|
|
163
202
|
}
|
|
164
203
|
const handle = opened.handle
|
|
204
|
+
// The rate limits read the host's clock: tell the engine when the second changes.
|
|
205
|
+
let lastSecond = 0
|
|
206
|
+
const tick = () => {
|
|
207
|
+
const now = Math.floor(Date.now() / 1000)
|
|
208
|
+
if (now === lastSecond) return
|
|
209
|
+
lastSecond = now
|
|
210
|
+
try {
|
|
211
|
+
engine.call('xmppClock', [handle, String(now)])
|
|
212
|
+
} catch (error) {
|
|
213
|
+
// An older engine has no such op, and no limit that reads it.
|
|
214
|
+
if (!isUnknownOp(error)) throw error
|
|
215
|
+
}
|
|
216
|
+
}
|
|
165
217
|
// An older engine ignores the list, and the rooms would be open: do not start.
|
|
166
218
|
if (Array.isArray(options.mucMembers) && opened.mucMembers !== options.mucMembers.length) {
|
|
167
219
|
engine.call('xmppClose', [handle])
|
|
168
220
|
throw new XmppHostError('this engine does not carry members-only rooms; rebuild the engine')
|
|
169
221
|
}
|
|
222
|
+
// The same for owners: an older engine would make the first occupant the owner.
|
|
223
|
+
if (Array.isArray(options.mucOwners) && opened.mucOwners !== options.mucOwners.length) {
|
|
224
|
+
engine.call('xmppClose', [handle])
|
|
225
|
+
throw new XmppHostError('this engine does not carry configured room owners; rebuild the engine')
|
|
226
|
+
}
|
|
170
227
|
return {
|
|
171
228
|
engine,
|
|
172
229
|
handle,
|
|
173
230
|
domain: typeof opened.domain === 'string' ? opened.domain : (config.domain || 'localhost'),
|
|
174
231
|
accounts: typeof opened.accounts === 'number' ? opened.accounts : undefined,
|
|
232
|
+
/** The engine's start warnings (--dangerously-disable-ratelimits). */
|
|
233
|
+
warnings: Array.isArray(opened.warnings) ? opened.warnings : [],
|
|
234
|
+
tick,
|
|
175
235
|
/**
|
|
176
236
|
* One client connection.
|
|
177
|
-
*
|
|
237
|
+
* `remoteAddress` (the socket's) and the clock go to the engine for the
|
|
238
|
+
* failed SASL login limit (https://github.com/danbri/factoidal/issues/1128).
|
|
239
|
+
* @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, startTls?: boolean, maxUnits?: number, remoteAddress?: string}} [sessionOptions]
|
|
178
240
|
*/
|
|
179
241
|
openSession (sessionOptions = {}) {
|
|
180
242
|
const record = {
|
|
181
243
|
streamId: typeof sessionOptions.streamId === 'string' ? sessionOptions.streamId : randomToken(),
|
|
182
|
-
nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken()
|
|
244
|
+
nonce: typeof sessionOptions.nonce === 'string' ? sessionOptions.nonce : randomToken(),
|
|
245
|
+
now: Math.floor(Date.now() / 1000)
|
|
183
246
|
}
|
|
247
|
+
if (typeof sessionOptions.remoteAddress === 'string') record.remoteAddress = sessionOptions.remoteAddress
|
|
184
248
|
if (typeof sessionOptions.tlsCarrier === 'boolean') record.tlsCarrier = sessionOptions.tlsCarrier
|
|
185
249
|
if (typeof sessionOptions.startTls === 'boolean') record.startTls = sessionOptions.startTls
|
|
186
250
|
if (typeof sessionOptions.maxUnits === 'number') record.maxUnits = sessionOptions.maxUnits
|
|
@@ -190,8 +254,11 @@ export async function openXmppServer (options = {}) {
|
|
|
190
254
|
let open = true
|
|
191
255
|
return {
|
|
192
256
|
handle: session,
|
|
257
|
+
/** The first digits of the client's address tag, for a log line ('' for no address). */
|
|
258
|
+
addressTag: typeof answer.addressTag === 'string' ? answer.addressTag : '',
|
|
193
259
|
/** Feed received text; returns the engine's answer document. */
|
|
194
260
|
feed (text) {
|
|
261
|
+
tick()
|
|
195
262
|
return engine.call('xmppSessionFeed', [session, text])
|
|
196
263
|
},
|
|
197
264
|
/** The TLS handshake a feed asked for (`startTls: true`) finished. */
|
|
@@ -202,6 +269,10 @@ export async function openXmppServer (options = {}) {
|
|
|
202
269
|
deliver (text) {
|
|
203
270
|
return engine.call('xmppSessionDeliver', [session, text])
|
|
204
271
|
},
|
|
272
|
+
/** This session has been silent: `{sent, send}`, an XEP-0199 ping when it is bound. */
|
|
273
|
+
ping (id) {
|
|
274
|
+
return engine.call('xmppSessionPing', [session, id])
|
|
275
|
+
},
|
|
205
276
|
/** End of input: returns `{send: string[]}` and releases the handle. */
|
|
206
277
|
close () {
|
|
207
278
|
if (!open) return { send: [], routeFull: [] }
|
|
@@ -238,6 +309,14 @@ async function readAccountsFile (path) {
|
|
|
238
309
|
}
|
|
239
310
|
}
|
|
240
311
|
|
|
312
|
+
/** A warning when the accounts file can be read by anyone but its owner. */
|
|
313
|
+
async function accountsFileWarnings (path) {
|
|
314
|
+
if (process.platform === 'win32') return []
|
|
315
|
+
const { mode } = await stat(path)
|
|
316
|
+
if ((mode & 0o077) === 0) return []
|
|
317
|
+
return [`${path} can be read by other users of this machine: chmod 600 it`]
|
|
318
|
+
}
|
|
319
|
+
|
|
241
320
|
/**
|
|
242
321
|
* A `net.Server` (or `tls.Server`, when `cert` and `key` are given —
|
|
243
322
|
* XEP-0368 direct TLS) that runs one engine session per connection.
|
|
@@ -245,6 +324,7 @@ async function readAccountsFile (path) {
|
|
|
245
324
|
* The server is returned unlistened: the caller chooses the port.
|
|
246
325
|
*
|
|
247
326
|
* @param {object} options as `openXmppServer`, plus:
|
|
327
|
+
* @param {string} [options.accountsFile] the path `accountsText` came from, to check its mode
|
|
248
328
|
* @param {string} [options.dir] state directory: `accounts` is read from it when
|
|
249
329
|
* no accounts are given; rosters are persisted under `roster/`; `spool/` is
|
|
250
330
|
* polled for stanzas written by other processes
|
|
@@ -265,6 +345,18 @@ async function readAccountsFile (path) {
|
|
|
265
345
|
* without a bound resource before it is closed (default 30000; 0 disables)
|
|
266
346
|
* @param {number} [options.maxQueuedBytes] bytes waiting to be written to a
|
|
267
347
|
* client that is not reading, before the socket is closed (default 8 MiB)
|
|
348
|
+
* @param {number} [options.pingIntervalMs] how long a bound session may be silent before it
|
|
349
|
+
* gets a ping (default 30000; 0 disables pings and the timeout)
|
|
350
|
+
* @param {number} [options.pingTimeoutMs] how long after a ping a session that sends
|
|
351
|
+
* no byte is closed, so that its resource is free again (default 20000)
|
|
352
|
+
* @param {boolean} [options.addressLimit] false: count failed logins per account name only
|
|
353
|
+
* (default true). Behind a proxy that sends no PROXY header every client has the proxy's address
|
|
354
|
+
* @param {boolean} [options.proxyProtocol] every connection, on every listener, starts with a
|
|
355
|
+
* PROXY protocol v2 header, read before TLS; the client address it names is the one the
|
|
356
|
+
* failed-login limit counts. A connection without a valid header is closed. Without this option no
|
|
357
|
+
* header is ever read, so a client cannot claim an address (default false)
|
|
358
|
+
* @param {number} [options.proxyHeaderTimeoutMs] with `proxyProtocol`, how long a connection has to
|
|
359
|
+
* send its header and, on a TLS listener, finish the TLS handshake (default 10000)
|
|
268
360
|
* @param {Function} [options.onError]
|
|
269
361
|
* @param {Function} [options.onLog]
|
|
270
362
|
* @returns {Promise<{server: object, xmpp: object, close: Function, sessions: Function}>}
|
|
@@ -285,6 +377,11 @@ export async function createXmppServer (options = {}) {
|
|
|
285
377
|
const maxConnections = limit('maxConnections', 1024)
|
|
286
378
|
const loginTimeoutMs = limit('loginTimeoutMs', 30000)
|
|
287
379
|
const maxQueuedBytes = limit('maxQueuedBytes', 8 * 1024 * 1024)
|
|
380
|
+
const pingIntervalMs = limit('pingIntervalMs', 30000)
|
|
381
|
+
const pingTimeoutMs = limit('pingTimeoutMs', 20000)
|
|
382
|
+
const proxyProtocol = options.proxyProtocol === true
|
|
383
|
+
const proxyHeaderTimeoutMs = limit('proxyHeaderTimeoutMs', 10000)
|
|
384
|
+
if (pingIntervalMs > 0 && pingTimeoutMs === 0) throw new XmppHostError('pingTimeoutMs must be positive when pings are on')
|
|
288
385
|
if (!Number.isSafeInteger(maxBuffered) || maxBuffered < 0) {
|
|
289
386
|
throw new XmppHostError('maxBuffered must be a nonnegative safe integer')
|
|
290
387
|
}
|
|
@@ -293,9 +390,19 @@ export async function createXmppServer (options = {}) {
|
|
|
293
390
|
}
|
|
294
391
|
|
|
295
392
|
const serverOptions = { ...options }
|
|
393
|
+
let accountsFile = typeof options.accountsFile === 'string' ? options.accountsFile : null
|
|
296
394
|
if (dir !== null && !Array.isArray(options.accounts) && typeof options.accountsText !== 'string') {
|
|
297
395
|
const text = await readAccountsFile(join(dir, 'accounts'))
|
|
298
|
-
if (text !== null) serverOptions.accountsText = text
|
|
396
|
+
if (text !== null) { serverOptions.accountsText = text; accountsFile = join(dir, 'accounts') }
|
|
397
|
+
}
|
|
398
|
+
// Salts that survive a restart when the state does: a client that keeps a
|
|
399
|
+
// salted password goes on working, and a name's salt does not change.
|
|
400
|
+
if (typeof options.saltSeed !== 'string' && typeof options.stateKey === 'string') {
|
|
401
|
+
serverOptions.saltSeed = createHash('sha256').update('xmpp salt seed\0' + options.stateKey).digest('base64url')
|
|
402
|
+
}
|
|
403
|
+
// Occupant ids that survive a restart, for the same reason.
|
|
404
|
+
if (typeof options.occupantSeed !== 'string' && typeof options.stateKey === 'string') {
|
|
405
|
+
serverOptions.occupantSeed = createHash('sha256').update('xmpp occupant seed\0' + options.stateKey).digest('base64url')
|
|
299
406
|
}
|
|
300
407
|
const haveKey = options.cert !== undefined && options.key !== undefined
|
|
301
408
|
if (options.startTls === true && !haveKey) {
|
|
@@ -306,6 +413,18 @@ export async function createXmppServer (options = {}) {
|
|
|
306
413
|
if (typeof serverOptions.tlsCarrier !== 'boolean') serverOptions.tlsCarrier = useTls
|
|
307
414
|
if (dir !== null && options.stateKey !== undefined) serverOptions.persist = true
|
|
308
415
|
const xmpp = await openXmppServer(serverOptions)
|
|
416
|
+
if (accountsFile !== null) xmpp.warnings.push(...await accountsFileWarnings(accountsFile))
|
|
417
|
+
if (proxyProtocol) {
|
|
418
|
+
try {
|
|
419
|
+
xmpp.engine.callBlobIO('proxyHeaderParse', [], new Uint8Array(0))
|
|
420
|
+
} catch (error) {
|
|
421
|
+
xmpp.close()
|
|
422
|
+
if (isUnknownOp(error)) {
|
|
423
|
+
throw new XmppHostError('the loaded l4factoidal WebAssembly module does not carry proxyHeaderParse', { unknownOp: true })
|
|
424
|
+
}
|
|
425
|
+
throw error
|
|
426
|
+
}
|
|
427
|
+
}
|
|
309
428
|
|
|
310
429
|
// Rosters the previous run persisted.
|
|
311
430
|
try {
|
|
@@ -352,7 +471,7 @@ export async function createXmppServer (options = {}) {
|
|
|
352
471
|
' acl:mode acl:Read, acl:Write, acl:Control .\n'
|
|
353
472
|
})
|
|
354
473
|
const get = (target, accept) => state.step({ method: 'GET', target, headers: accept ? [['Accept', accept]] : [], body: new Uint8Array(0) })
|
|
355
|
-
for (const kind of ['roster', 'held', 'mix', 'gc3']) {
|
|
474
|
+
for (const kind of ['roster', 'held', 'mix', 'gc3', 'muc']) {
|
|
356
475
|
const listing = await get(`/xmpp/${kind}/`, 'application/lws+json')
|
|
357
476
|
if (listing.status === 404) continue
|
|
358
477
|
if (listing.status !== 200) throw new XmppHostError(`state: listing /xmpp/${kind}/ answered ${listing.status}`)
|
|
@@ -363,6 +482,10 @@ export async function createXmppServer (options = {}) {
|
|
|
363
482
|
if (got.status !== 200) throw new XmppHostError(`state: ${path} answered ${got.status}`)
|
|
364
483
|
const loaded = xmpp.engine.call('xmppStateLoad', [xmpp.handle, path, new TextDecoder().decode(got.body)])
|
|
365
484
|
if (loaded === null || typeof loaded !== 'object') throw new XmppHostError(`state: ${path} was refused`)
|
|
485
|
+
if (loaded.stale === true) {
|
|
486
|
+
const gone = await state.step({ method: 'DELETE', target: path, headers: [], body: new Uint8Array(0) })
|
|
487
|
+
if (gone.status >= 300 && gone.status !== 404) throw new XmppHostError(`state: DELETE ${path} answered ${gone.status}`)
|
|
488
|
+
}
|
|
366
489
|
}
|
|
367
490
|
}
|
|
368
491
|
} catch (error) {
|
|
@@ -411,6 +534,96 @@ export async function createXmppServer (options = {}) {
|
|
|
411
534
|
// connection callback and therefore have no engine session yet.
|
|
412
535
|
const transports = new Set()
|
|
413
536
|
|
|
537
|
+
// The client's address for the login limit: the socket's, or the one a PROXY
|
|
538
|
+
// header named. The engine keeps only its tag. `addressLimit: false` gives none.
|
|
539
|
+
const addressOf = (address) =>
|
|
540
|
+
options.addressLimit !== false && typeof address === 'string' ? address : ''
|
|
541
|
+
|
|
542
|
+
/** With `proxyProtocol`: buffer a new connection's bytes until the engine
|
|
543
|
+
* decides on its PROXY v2 header, then `then(address, rest)` with the client
|
|
544
|
+
* address and the bytes after the header. A refused header, or none in
|
|
545
|
+
* `proxyHeaderTimeoutMs`, closes the connection. */
|
|
546
|
+
function readProxyHeader (socket, then) {
|
|
547
|
+
let buffered = Buffer.alloc(0)
|
|
548
|
+
const timer = setTimeout(() => {
|
|
549
|
+
onLog('closing a connection that sent no PROXY header in time')
|
|
550
|
+
finish()
|
|
551
|
+
socket.destroy()
|
|
552
|
+
}, proxyHeaderTimeoutMs)
|
|
553
|
+
timer.unref()
|
|
554
|
+
const finish = () => {
|
|
555
|
+
clearTimeout(timer)
|
|
556
|
+
socket.removeListener('data', onData)
|
|
557
|
+
socket.removeListener('error', onSocketError)
|
|
558
|
+
}
|
|
559
|
+
const onSocketError = () => { finish(); socket.destroy() }
|
|
560
|
+
const onData = (chunk) => {
|
|
561
|
+
buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk])
|
|
562
|
+
let decision
|
|
563
|
+
try {
|
|
564
|
+
decision = xmpp.engine.callBlobIO('proxyHeaderParse', [],
|
|
565
|
+
new Uint8Array(buffered.buffer, buffered.byteOffset, buffered.byteLength)).envelope
|
|
566
|
+
} catch (error) {
|
|
567
|
+
onError(error)
|
|
568
|
+
finish()
|
|
569
|
+
socket.destroy()
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
if (decision.decision === 'more') return
|
|
573
|
+
finish()
|
|
574
|
+
if ((decision.decision !== 'proxy' && decision.decision !== 'local') ||
|
|
575
|
+
!Number.isSafeInteger(decision.consumed) || decision.consumed > buffered.length) {
|
|
576
|
+
onLog(`closing a connection: PROXY header refused (${decision.reason || decision.error || 'no decision'})`)
|
|
577
|
+
socket.destroy()
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
// LOCAL: the proxy's own connection (a health check); its address is the socket's.
|
|
581
|
+
const address = decision.decision === 'proxy' ? decision.address : socket.remoteAddress
|
|
582
|
+
then(address, buffered.subarray(decision.consumed))
|
|
583
|
+
}
|
|
584
|
+
socket.on('data', onData)
|
|
585
|
+
socket.on('error', onSocketError)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** A stream that yields `head` and then what `socket` reads, and writes to
|
|
589
|
+
* `socket`: TLS over a connection whose first bytes were a PROXY header. */
|
|
590
|
+
function prefixed (socket, head) {
|
|
591
|
+
const stream = new Duplex({
|
|
592
|
+
read () { socket.resume() },
|
|
593
|
+
write (chunk, _encoding, callback) { socket.write(chunk, callback) },
|
|
594
|
+
final (callback) { socket.end(); callback() },
|
|
595
|
+
destroy (error, callback) { socket.destroy(); callback(error) }
|
|
596
|
+
})
|
|
597
|
+
if (head.length > 0) stream.push(head)
|
|
598
|
+
socket.on('data', (chunk) => { if (!stream.push(chunk)) socket.pause() })
|
|
599
|
+
socket.on('end', () => stream.push(null))
|
|
600
|
+
socket.on('error', (error) => stream.destroy(error))
|
|
601
|
+
socket.on('close', () => stream.destroy())
|
|
602
|
+
return stream
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** With `proxyProtocol`, a TLS listener is a TCP listener: read the header,
|
|
606
|
+
* then be the TLS server over the rest, within `proxyHeaderTimeoutMs`. */
|
|
607
|
+
function acceptTls (tls, socket, tlsOptions, then) {
|
|
608
|
+
readProxyHeader(socket, (address, rest) => {
|
|
609
|
+
const secure = new tls.TLSSocket(prefixed(socket, rest), { isServer: true, ...tlsOptions })
|
|
610
|
+
const timer = setTimeout(() => { secure.destroy(); socket.destroy() }, proxyHeaderTimeoutMs)
|
|
611
|
+
timer.unref()
|
|
612
|
+
const onHandshakeError = (error) => {
|
|
613
|
+
clearTimeout(timer)
|
|
614
|
+
if (error && error.code !== 'ECONNRESET') onError(new XmppHostError(`TLS handshake: ${error.message}`))
|
|
615
|
+
secure.destroy()
|
|
616
|
+
socket.destroy()
|
|
617
|
+
}
|
|
618
|
+
secure.once('error', onHandshakeError)
|
|
619
|
+
secure.once('secure', () => {
|
|
620
|
+
clearTimeout(timer)
|
|
621
|
+
secure.removeListener('error', onHandshakeError)
|
|
622
|
+
then(secure, address)
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
414
627
|
// Transport limits, like `maxBuffered`: bytes and time, not protocol.
|
|
415
628
|
function closeIfBacklogged (c) {
|
|
416
629
|
if (c.socket.destroyed || !(c.socket.writableLength > maxQueuedBytes)) return
|
|
@@ -435,6 +648,35 @@ export async function createXmppServer (options = {}) {
|
|
|
435
648
|
c.loginTimer.unref()
|
|
436
649
|
}
|
|
437
650
|
|
|
651
|
+
/** Any byte from a peer shows it is alive, whatever the byte says. */
|
|
652
|
+
function heard (c) {
|
|
653
|
+
c.lastHeard = Date.now()
|
|
654
|
+
c.pingedAt = 0
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Ping the bound sessions that have been silent, and close the ones that stayed silent.
|
|
658
|
+
* A dead TCP connection sends no FIN, so without this it holds its resource for as long as
|
|
659
|
+
* the operating system keeps retrying. */
|
|
660
|
+
function checkSilence () {
|
|
661
|
+
const now = Date.now()
|
|
662
|
+
for (const c of connections) {
|
|
663
|
+
if (c.full === null || c.closing || c.upgrading) continue
|
|
664
|
+
if (c.pingedAt !== 0) {
|
|
665
|
+
if (now - c.pingedAt >= pingTimeoutMs) {
|
|
666
|
+
onLog(`closing ${c.full}: no answer to a ping`)
|
|
667
|
+
c.socket.destroy()
|
|
668
|
+
}
|
|
669
|
+
} else if (now - c.lastHeard >= pingIntervalMs) {
|
|
670
|
+
c.pingedAt = now
|
|
671
|
+
try { c.ping() } catch (error) { onError(error) }
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const pinger = pingIntervalMs > 0
|
|
676
|
+
? setInterval(checkSilence, Math.ceil(Math.min(pingIntervalMs, pingTimeoutMs, 10000) / 2))
|
|
677
|
+
: null
|
|
678
|
+
if (pinger !== null) pinger.unref()
|
|
679
|
+
|
|
438
680
|
/** Count a new socket, or close it at once if the server is full. */
|
|
439
681
|
function admit (socket) {
|
|
440
682
|
if (transports.size >= maxConnections) {
|
|
@@ -540,10 +782,10 @@ export async function createXmppServer (options = {}) {
|
|
|
540
782
|
}
|
|
541
783
|
}
|
|
542
784
|
|
|
543
|
-
function onConnection (socket, direct = false) {
|
|
785
|
+
function onConnection (socket, direct = false, address = socket.remoteAddress, rest = null) {
|
|
544
786
|
let session
|
|
545
787
|
try {
|
|
546
|
-
session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct })
|
|
788
|
+
session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct, remoteAddress: addressOf(address) })
|
|
547
789
|
} catch (error) {
|
|
548
790
|
onError(error)
|
|
549
791
|
socket.destroy()
|
|
@@ -556,16 +798,25 @@ export async function createXmppServer (options = {}) {
|
|
|
556
798
|
full: null,
|
|
557
799
|
closing: false,
|
|
558
800
|
decoder: new StringDecoder('utf8'),
|
|
801
|
+
lastHeard: Date.now(),
|
|
802
|
+
pingedAt: 0,
|
|
803
|
+
pings: 0,
|
|
559
804
|
deliver (text) {
|
|
560
805
|
const answer = c.session.deliver(text)
|
|
561
806
|
if (answer.delivered === true) { writeAll(c, answer.send); return true }
|
|
562
807
|
return false
|
|
808
|
+
},
|
|
809
|
+
ping () {
|
|
810
|
+
const answer = c.session.ping(`ping-${++c.pings}`)
|
|
811
|
+
if (answer.sent === true) writeAll(c, answer.send)
|
|
563
812
|
}
|
|
564
813
|
}
|
|
565
814
|
connections.add(c)
|
|
566
815
|
startLoginTimer(c)
|
|
567
|
-
|
|
816
|
+
// The tag's first digits, never the address (the tag's key is in memory only).
|
|
817
|
+
onLog(`connection from ${session.addressTag === '' ? 'no address' : 'address ' + session.addressTag}`)
|
|
568
818
|
c.onData = (chunk) => {
|
|
819
|
+
heard(c)
|
|
569
820
|
if (!connections.has(c) || c.closing || c.upgrading) return
|
|
570
821
|
const text = c.decoder.write(chunk)
|
|
571
822
|
if (text.length === 0) return
|
|
@@ -597,6 +848,8 @@ export async function createXmppServer (options = {}) {
|
|
|
597
848
|
if (error && error.code !== 'ECONNRESET') onError(error)
|
|
598
849
|
endSession(c)
|
|
599
850
|
})
|
|
851
|
+
// The bytes that came in one read with a PROXY header.
|
|
852
|
+
if (rest !== null && rest.length > 0) c.onData(rest)
|
|
600
853
|
}
|
|
601
854
|
|
|
602
855
|
// XMPP over WebSocket (RFC 7395): every byte goes to xmppWsFeed, which
|
|
@@ -607,17 +860,19 @@ export async function createXmppServer (options = {}) {
|
|
|
607
860
|
const got = xmpp.engine.callBlobIO(op, args, bytes)
|
|
608
861
|
return { envelope: got.envelope, bytes: got.bytes instanceof Uint8Array ? got.bytes : new Uint8Array(0) }
|
|
609
862
|
}
|
|
610
|
-
function onWsConnection (socket) {
|
|
863
|
+
function onWsConnection (socket, address = socket.remoteAddress, rest = null) {
|
|
611
864
|
let handle
|
|
865
|
+
let tag = ''
|
|
612
866
|
try {
|
|
613
867
|
const opened = xmpp.engine.call('xmppWsOpen', [xmpp.handle, JSON.stringify({
|
|
614
|
-
streamId: randomToken(), nonce: randomToken(),
|
|
868
|
+
streamId: randomToken(), nonce: randomToken(), remoteAddress: addressOf(address),
|
|
615
869
|
path: typeof ws.path === 'string' ? ws.path : '/xmpp-websocket',
|
|
616
870
|
allowedOrigins: Array.isArray(ws.allowedOrigins) ? ws.allowedOrigins : [],
|
|
617
871
|
allowNoOrigin: ws.allowNoOrigin === true,
|
|
618
872
|
tlsCarrier: ws.cert !== undefined && ws.key !== undefined
|
|
619
873
|
})])
|
|
620
874
|
handle = opened.handle
|
|
875
|
+
if (typeof opened.addressTag === 'string') tag = opened.addressTag
|
|
621
876
|
} catch (error) {
|
|
622
877
|
onError(error)
|
|
623
878
|
socket.destroy()
|
|
@@ -628,6 +883,8 @@ export async function createXmppServer (options = {}) {
|
|
|
628
883
|
bare: null,
|
|
629
884
|
full: null,
|
|
630
885
|
closing: false,
|
|
886
|
+
lastHeard: Date.now(),
|
|
887
|
+
pingedAt: 0,
|
|
631
888
|
session: {
|
|
632
889
|
close () {
|
|
633
890
|
try {
|
|
@@ -640,14 +897,21 @@ export async function createXmppServer (options = {}) {
|
|
|
640
897
|
const got = wsCall('xmppWsDeliver', [handle, text], new Uint8Array(0))
|
|
641
898
|
if (got.envelope.delivered === true) { if (!socket.destroyed) socket.write(got.bytes); closeIfBacklogged(c); return true }
|
|
642
899
|
return false
|
|
900
|
+
},
|
|
901
|
+
ping () {
|
|
902
|
+
const got = wsCall('xmppWsPing', [handle], new Uint8Array(0))
|
|
903
|
+
if (got.envelope.sent === true && !socket.destroyed) { socket.write(got.bytes); closeIfBacklogged(c) }
|
|
643
904
|
}
|
|
644
905
|
}
|
|
645
906
|
connections.add(c)
|
|
646
907
|
startLoginTimer(c)
|
|
647
|
-
|
|
908
|
+
onLog(`WebSocket connection from ${tag === '' ? 'no address' : 'address ' + tag}`)
|
|
909
|
+
const onData = (chunk) => {
|
|
910
|
+
heard(c)
|
|
648
911
|
if (!connections.has(c) || c.closing) return
|
|
649
912
|
let got
|
|
650
913
|
try {
|
|
914
|
+
xmpp.tick()
|
|
651
915
|
got = wsCall('xmppWsFeed', [handle], new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
|
|
652
916
|
} catch (error) {
|
|
653
917
|
onError(error)
|
|
@@ -662,13 +926,16 @@ export async function createXmppServer (options = {}) {
|
|
|
662
926
|
socket.destroy()
|
|
663
927
|
endSession(c)
|
|
664
928
|
}
|
|
665
|
-
}
|
|
929
|
+
}
|
|
930
|
+
socket.on('data', onData)
|
|
666
931
|
socket.on('end', () => endSession(c))
|
|
667
932
|
socket.on('close', () => endSession(c))
|
|
668
933
|
socket.on('error', (error) => {
|
|
669
934
|
if (error && error.code !== 'ECONNRESET') onError(error)
|
|
670
935
|
endSession(c)
|
|
671
936
|
})
|
|
937
|
+
// The bytes that came in one read with a PROXY header.
|
|
938
|
+
if (rest !== null && rest.length > 0) onData(rest)
|
|
672
939
|
}
|
|
673
940
|
let wsServer = null
|
|
674
941
|
if (ws !== null) {
|
|
@@ -676,19 +943,34 @@ export async function createXmppServer (options = {}) {
|
|
|
676
943
|
xmpp.close()
|
|
677
944
|
throw new XmppHostError('the WebSocket listener needs both cert and key for TLS')
|
|
678
945
|
}
|
|
679
|
-
if (
|
|
946
|
+
if (proxyProtocol) {
|
|
947
|
+
const net = await import('node:net')
|
|
948
|
+
const tls = ws.cert !== undefined ? await import('node:tls') : null
|
|
949
|
+
wsServer = net.createServer((socket) => {
|
|
950
|
+
if (tls === null) readProxyHeader(socket, (address, rest) => onWsConnection(socket, address, rest))
|
|
951
|
+
else acceptTls(tls, socket, { cert: ws.cert, key: ws.key }, (secure, address) => onWsConnection(secure, address))
|
|
952
|
+
})
|
|
953
|
+
} else if (ws.cert !== undefined) {
|
|
680
954
|
const tls = await import('node:tls')
|
|
681
|
-
wsServer = tls.createServer({ cert: ws.cert, key: ws.key }, onWsConnection)
|
|
955
|
+
wsServer = tls.createServer({ cert: ws.cert, key: ws.key }, (socket) => onWsConnection(socket))
|
|
682
956
|
} else {
|
|
683
957
|
const net = await import('node:net')
|
|
684
|
-
wsServer = net.createServer(onWsConnection)
|
|
958
|
+
wsServer = net.createServer((socket) => onWsConnection(socket))
|
|
685
959
|
}
|
|
686
960
|
wsServer.on('connection', admit)
|
|
687
961
|
}
|
|
688
962
|
|
|
689
963
|
let server
|
|
690
964
|
try {
|
|
691
|
-
if (
|
|
965
|
+
if (proxyProtocol) {
|
|
966
|
+
// Every listener is TCP: the header comes before TLS.
|
|
967
|
+
const net = await import('node:net')
|
|
968
|
+
const tls = useTls ? await import('node:tls') : null
|
|
969
|
+
server = net.createServer((socket) => {
|
|
970
|
+
if (tls === null) readProxyHeader(socket, (address, rest) => onConnection(socket, false, address, rest))
|
|
971
|
+
else acceptTls(tls, socket, { cert: options.cert, key: options.key }, (secure, address) => onConnection(secure, false, address))
|
|
972
|
+
})
|
|
973
|
+
} else if (useTls) {
|
|
692
974
|
const tls = await import('node:tls')
|
|
693
975
|
server = tls.createServer({ cert: options.cert, key: options.key }, (socket) => onConnection(socket))
|
|
694
976
|
} else {
|
|
@@ -706,7 +988,13 @@ export async function createXmppServer (options = {}) {
|
|
|
706
988
|
let directServer = null
|
|
707
989
|
if (startTls && options.directTlsPort !== undefined) {
|
|
708
990
|
const tls = await import('node:tls')
|
|
709
|
-
|
|
991
|
+
if (proxyProtocol) {
|
|
992
|
+
const net = await import('node:net')
|
|
993
|
+
directServer = net.createServer((socket) =>
|
|
994
|
+
acceptTls(tls, socket, { cert: options.cert, key: options.key }, (secure, address) => onConnection(secure, true, address)))
|
|
995
|
+
} else {
|
|
996
|
+
directServer = tls.createServer({ cert: options.cert, key: options.key }, (socket) => onConnection(socket, true))
|
|
997
|
+
}
|
|
710
998
|
directServer.on('connection', admit)
|
|
711
999
|
}
|
|
712
1000
|
|
|
@@ -770,6 +1058,7 @@ export async function createXmppServer (options = {}) {
|
|
|
770
1058
|
if (closePromise !== null) return closePromise
|
|
771
1059
|
closePromise = (async () => {
|
|
772
1060
|
if (poller !== null) clearInterval(poller)
|
|
1061
|
+
if (pinger !== null) clearInterval(pinger)
|
|
773
1062
|
for (const c of Array.from(connections)) {
|
|
774
1063
|
endSession(c)
|
|
775
1064
|
c.socket.destroy()
|