@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/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` and
41
- // `maxQueuedBytes`. There is no limit per address: behind a proxy, every
42
- // client has the same one.
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 { mkdir, readFile, writeFile, readdir, unlink, rename } from 'node:fs/promises'
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] `user:password` lines, the harness's accounts-file format
123
- * @param {string} [options.saltB64] the SCRAM salt, base64
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
- * @param {{streamId?: string, nonce?: string, tlsCarrier?: boolean, startTls?: boolean, maxUnits?: number}} [sessionOptions]
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
- onLog(`connection from ${socket.remoteAddress}:${socket.remotePort}`)
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
- socket.on('data', (chunk) => {
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 (ws.cert !== undefined) {
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 (useTls) {
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
- directServer = tls.createServer({ cert: options.cert, key: options.key }, (socket) => onConnection(socket, true))
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()