@factoidal/core 0.33.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 CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.34.0 (2026-11-08)
4
+
5
+ - `foafnaut` command: `--url` and `--html` open a page or a file (`file:` URLs too), and other options are passed to the app.
6
+ - XMPP: `xmpp-serve --proxy-protocol` (`proxyProtocol` in `createXmppServer`): every connection, on every listener, must start with a PROXY protocol v2 header, read before TLS, and the failed-login limit counts the client address it names. So the limit per client address can stay on behind Fly's proxy, where `--no-address-limit` was needed. The header is decided in Lean (`L4Factoidal/Net/ProxyProtocol.lean`, new wasm op `proxyHeaderParse`, theorems `parse_signature`, `parse_more_bounded`, `parse_proxy_le`); a connection without a valid header in 10 seconds (`proxyHeaderTimeoutMs`) is closed. Without the flag no header is read.
7
+ - XMPP: the server keeps no client address. The limit counts an HMAC-SHA-256 tag of the address (IPv6 by its /64) under a random key that is in memory only and new at each start (`LoginLimit.addressTag`; `xmppOpen` takes `addressKey`; `xmppSessionOpen` and `xmppWsOpen` answer `addressTag`, its first 12 digits). The "connection from" log line shows the tag, not the address and port.
8
+ - XMPP: the server pings a bound session that has been silent, and closes it if it stays silent, so a dead connection frees its resource. A ping is XEP-0199 on TCP and a ping frame on a WebSocket; any byte from the client counts as an answer. `xmpp-serve --ping-interval SECONDS` (default 30, 0 disables) and `--ping-timeout SECONDS` (default 20); `pingIntervalMs` and `pingTimeoutMs` in `createXmppServer`. New ops `xmppSessionPing` and `xmppWsPing`.
9
+ - XMPP: a client that binds a resource a live session holds gets a server-generated one (RFC 6120 section 7.7.2.2), in the bind result. The live session stays. A client must use the JID in the bind result. A resource with no live session is free, and a room seat goes when its stream ends.
10
+ - XMPP: room moderation (XEP-0045 sections 8 to 10): kick (status 307), ban (status 301), grant and revoke voice, moderator, member, admin and owner, the admin and owner lists, and status 321. `xmpp-serve --muc-owners FILE` names the owners, as `room: a@x` or `*: a@x` for every room. With that flag, also with an empty file, the first occupant of a room gets no rights, XMPP cannot change a configured owner, and with `--state-key` bans, grants and revoked voices survive a restart. Without the flag the first occupant is the owner as before, and a room's bans go with the room.
4
11
 
5
12
  ## 0.33.0 (2026-11-06)
6
13
 
package/bin/factoidal.mjs CHANGED
@@ -772,9 +772,10 @@ Specification: https://solidproject.org/TR/protocol`,
772
772
  usage: factoidal xmpp-serve DIR [--domain NAME] [--port N] [--host ADDRESS]
773
773
  [--accounts FILE] [--cert FILE --key FILE [--starttls [--tls-port N]]]
774
774
  [--ws-port N [--ws-path PATH] [--ws-origin ORIGIN]...]
775
- [--muc DOMAIN [--muc-members FILE]] [--mix DOMAIN] [--gc3 DOMAIN]
775
+ [--muc DOMAIN [--muc-members FILE] [--muc-owners FILE]] [--mix DOMAIN] [--gc3 DOMAIN]
776
776
  [--state-key FILE] [--max-connections N]
777
- [--login-timeout SECONDS] [--no-address-limit]
777
+ [--login-timeout SECONDS] [--ping-interval SECONDS]
778
+ [--ping-timeout SECONDS] [--proxy-protocol] [--no-address-limit]
778
779
  [--dangerously-disable-ratelimits]
779
780
 
780
781
  Starts a Node TCP server that runs one engine session per connection and
@@ -817,11 +818,19 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
817
818
  --muc DOMAIN also serve XEP-0045 multi-user chat rooms at DOMAIN, e.g.
818
819
  conference.example.com: temporary rooms, made by the first
819
820
  join and gone with the last occupant; no history, no
820
- configuration, no persistence (L4Factoidal/XMPP/Muc.lean)
821
+ configuration form (L4Factoidal/XMPP/Muc.lean). Kick, ban, voice
822
+ and the admin and owner lists follow XEP-0045 sections 8 to 10
821
823
  --muc-members FILE members-only rooms: one room per line, as
822
824
  "room: alice@example.com bob@example.com". Only those accounts
823
825
  may join or see that room; a room not in the file is open to
824
826
  every account (L4Factoidal/XMPP/Muc.lean)
827
+ --muc-owners FILE room owners: one room per line, as "room: alice@example.com",
828
+ and "*: alice@example.com" for every room. XMPP cannot change
829
+ these names. With this flag, also with an empty file, nobody
830
+ becomes an owner by being first in a room, and with --state-key
831
+ bans and affiliations survive a restart. Without this flag, the
832
+ first occupant of a room with no owner is its owner, and a room's
833
+ bans and affiliations go with the room
825
834
  --mix DOMAIN also serve XEP-0369 MIX channels at DOMAIN, e.g. mix.example.com,
826
835
  joined through this server (XEP-0405 MIX-PAM); channels are
827
836
  made by an IQ <create/>, kept in memory only, JID-hidden; no
@@ -831,6 +840,11 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
831
840
  behind a proxy, every client has the same one
832
841
  --login-timeout SECONDS how long a connection may stay without a bound
833
842
  resource before it is closed (default 30; 0 disables)
843
+ --ping-interval SECONDS how long a bound session may be silent before the
844
+ server pings it: XEP-0199 on TCP, a ping frame on a WebSocket
845
+ (default 30; 0 disables pings)
846
+ --ping-timeout SECONDS how long after a ping a session that sends no byte is
847
+ closed, which frees its resource (default 20)
834
848
  --state-key FILE keep the server's state across restarts: rosters, messages held
835
849
  for offline accounts, MIX channels and GC3 groups, in an LWS
836
850
  storage in DIR/state, sealed (ChaCha20-Poly1305) under the key
@@ -841,10 +855,21 @@ DIR/spool/<bare jid>/ is delivered to that account's bound session.
841
855
  membership, stable participant ids, optional events. There is
842
856
  no published GC3 spec; every wire format is invented and listed
843
857
  in L4Factoidal/XMPP/Gc3.lean and danbri/factoidal issue 1236
858
+ --proxy-protocol every connection, on every listener (--port, --tls-port,
859
+ --ws-port), must start with a PROXY protocol version 2 header
860
+ (HAProxy's spec), read before TLS. The client address in it is
861
+ the one the failed-login limit counts. A connection with no
862
+ valid header in 10 seconds is closed; a version 1 (text)
863
+ header is refused. A LOCAL header (a health check of the
864
+ proxy) is accepted with the socket's address. Use it only
865
+ behind a proxy that always sends the header, such as Fly with
866
+ handlers = ["proxy_proto"] and proxy_proto_options =
867
+ { version = "v2" }; without it no header is ever read, so a
868
+ client cannot claim an address
844
869
  --no-address-limit
845
870
  count failed logins per account name only. For a listener
846
- behind a proxy, where every client has the proxy's address
847
- and a limit on it would stop them all
871
+ behind a proxy that sends no PROXY header, where every client
872
+ has the proxy's address and a limit on it would stop them all
848
873
  --dangerously-disable-ratelimits
849
874
  no failed SASL login limit and no sender rate on held
850
875
  messages (a warning is printed at start); any client can
@@ -858,6 +883,9 @@ the SASL failure temporary-auth-failure, and no password is checked, also
858
883
  the right one, until that window ends. A client address also has a slower
859
884
  limit of 20 failed logins in an hour. A malformed SASL payload checks no
860
885
  password and does not count. A stream closes after 3 failed attempts.
886
+ The server keeps no client address: the counters hold an HMAC-SHA-256
887
+ tag of it (of its /64 for IPv6) under a random key that is in memory
888
+ only and new at each start, and logs show the first 12 digits of the tag.
861
889
 
862
890
  The server makes no protocol decision. Stream negotiation, SASL PLAIN and
863
891
  SCRAM-SHA-256, resource binding, the roster, presence subscription and
@@ -1283,7 +1311,7 @@ const VALUE_OPTIONS = {
1283
1311
  'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
1284
1312
  'cors-origin', 'saml-idp', 'saml-decrypt-key']),
1285
1313
  'authz-keys': new Set([]),
1286
- 'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key', 'tls-port', 'ws-port', 'ws-path', 'ws-origin', 'muc', 'muc-members', 'mix', 'gc3', 'state-key', 'max-connections', 'login-timeout', 'verifier', 'iterations']),
1314
+ 'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key', 'tls-port', 'ws-port', 'ws-path', 'ws-origin', 'muc', 'muc-members', 'muc-owners', 'mix', 'gc3', 'state-key', 'max-connections', 'login-timeout', 'ping-interval', 'ping-timeout', 'verifier', 'iterations']),
1287
1315
  'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
1288
1316
  'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
1289
1317
  'webdav-user': new Set(['iterations']),
@@ -2983,6 +3011,7 @@ async function commandXmppServe (positional, options) {
2983
3011
  settings.disableRateLimits = options['dangerously-disable-ratelimits'] === true
2984
3012
  }
2985
3013
  if (options['no-address-limit'] === true) settings.addressLimit = false
3014
+ if (options['proxy-protocol'] === true) settings.proxyProtocol = true
2986
3015
  if (useTls) {
2987
3016
  settings.cert = await fs.readFile(options.cert)
2988
3017
  settings.key = await fs.readFile(options.key)
@@ -3019,6 +3048,20 @@ async function commandXmppServe (positional, options) {
3019
3048
  settings.mucMembers.push({ room, members })
3020
3049
  }
3021
3050
  }
3051
+ if (options['muc-owners'] !== undefined) {
3052
+ if (typeof options['muc-owners'] !== 'string') throw new UsageError('--muc-owners needs a FILE')
3053
+ if (settings.mucDomain === undefined) throw new UsageError('--muc-owners needs --muc')
3054
+ settings.mucOwners = []
3055
+ for (const line of (await fs.readFile(options['muc-owners'], 'utf8')).split('\n')) {
3056
+ if (line.trim() === '') continue
3057
+ const [, room, list] = /^([^:\s@/]+):(.*)$/.exec(line.trim()) ?? []
3058
+ const owners = (list ?? '').split(/\s+/).filter(Boolean)
3059
+ if (room === undefined || owners.length === 0 || owners.some((m) => !/^[^\s@/]+@[^\s@/]+$/.test(m))) {
3060
+ throw new UsageError(`--muc-owners: not "room: bare@jid ...": ${line}`)
3061
+ }
3062
+ settings.mucOwners.push({ room, owners })
3063
+ }
3064
+ }
3022
3065
  if (options.mix !== undefined) {
3023
3066
  if (typeof options.mix !== 'string' || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(options.mix)) {
3024
3067
  throw new UsageError('--mix needs a domain name, e.g. mix.example.com')
@@ -3043,6 +3086,16 @@ async function commandXmppServe (positional, options) {
3043
3086
  if (!Number.isSafeInteger(n) || n < 0) throw new UsageError('--login-timeout needs a number of seconds')
3044
3087
  settings.loginTimeoutMs = n * 1000
3045
3088
  }
3089
+ if (options['ping-interval'] !== undefined) {
3090
+ const n = Number(options['ping-interval'])
3091
+ if (!Number.isSafeInteger(n) || n < 0) throw new UsageError('--ping-interval needs a number of seconds')
3092
+ settings.pingIntervalMs = n * 1000
3093
+ }
3094
+ if (options['ping-timeout'] !== undefined) {
3095
+ const n = Number(options['ping-timeout'])
3096
+ if (!Number.isSafeInteger(n) || n < 1) throw new UsageError('--ping-timeout needs a positive number of seconds')
3097
+ settings.pingTimeoutMs = n * 1000
3098
+ }
3046
3099
  if (options['ws-port'] !== undefined) {
3047
3100
  const wsPort = Number(options['ws-port'])
3048
3101
  if (!Number.isInteger(wsPort) || wsPort < 0 || wsPort > 65535) throw new UsageError('--ws-port needs a port number')
@@ -3078,6 +3131,8 @@ async function commandXmppServe (positional, options) {
3078
3131
  err(`xmpp-serve: listening on ${running.origin} for domain ${running.xmpp.domain}` +
3079
3132
  ` (${running.xmpp.accounts} accounts, state ${positional[0]}` +
3080
3133
  (startTls ? ', STARTTLS required)' : useTls ? ', direct TLS)' : ', PLAINTEXT: no TLS on this listener)'))
3134
+ if (settings.proxyProtocol) err('xmpp-serve: every connection must start with a PROXY protocol v2 header')
3135
+ else if (settings.addressLimit === false) err('xmpp-serve: failed logins count per account name only (--no-address-limit)')
3081
3136
  if (settings.mucDomain) err(`xmpp-serve: multi-user chat (XEP-0045) at ${settings.mucDomain}`)
3082
3137
  if (settings.mixDomain) err(`xmpp-serve: MIX channels (XEP-0369, joined by XEP-0405) at ${settings.mixDomain}`)
3083
3138
  if (settings.gc3Domain) err(`xmpp-serve: GC3 PROTOTYPE groups at ${settings.gc3Domain} (no published spec; invented wire format)`)
package/bin/foafnaut.mjs CHANGED
@@ -10,9 +10,11 @@ import { homedir } from 'node:os';
10
10
  import { basename, join, resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
 
13
- const HELP = `foafnaut [--latest|--bleeding-edge] [PAGE] [-- APP_ARGS]
13
+ const HELP = `foafnaut [--latest|--bleeding-edge] [PAGE | --url URL | --html FILE] [APP_OPTIONS] [-- APP_ARGS]
14
14
 
15
- PAGE an http(s) URL or an HTML file; none opens the default page
15
+ PAGE an http(s) or file: URL, or an HTML file; none opens the default page
16
+ --url URL the page as a URL (http, https or file)
17
+ --html FILE the page as a local HTML file
16
18
  --latest install the newest published macOS build, then use it
17
19
  --bleeding-edge use the local build (config.json "bleedingEdge", or
18
20
  ~/Applications/foafnaut.app)
@@ -23,6 +25,15 @@ const HELP = `foafnaut [--latest|--bleeding-edge] [PAGE] [-- APP_ARGS]
23
25
  --which print the app that would run
24
26
  --help this text
25
27
 
28
+ App options, passed to the app as they are:
29
+ --perf-summary print frame and input latency figures when the window closes
30
+ --check-pixels read back and check every frame (slow)
31
+ --wasm-engine use the Wasm engine, not the native library
32
+ --native-engine PATH use this native engine library
33
+ --font FILE load this font file
34
+ --bitmap-font use the baked-in ASCII font only
35
+ --follow-links open links that a page asks for at load
36
+
26
37
  Also: foafnaut-latest, foafnaut-bleedingedge. Environment: FOAFNAUT_HOME, and
27
38
  FOAFNAUT_APP, which chooses the app outright. Page data goes to the app. Its trace
28
39
  is in ~/Library/Logs/foafnaut/trace.jsonl. The command stays open until the window
@@ -96,9 +107,14 @@ async function installLatest() {
96
107
  return versionApp(dir);
97
108
  }
98
109
 
110
+ // App options that take no value, and those that take one.
111
+ const APP_FLAGS = ['--perf-summary', '--check-pixels', '--wasm-engine', '--bitmap-font', '--follow-links', '--idle-checks-only', '--trace-input'];
112
+ const APP_VALUES = ['--native-engine', '--font'];
113
+
99
114
  function pageArgs(page) {
100
115
  if (page === undefined) return [];
101
116
  if (/^https?:\/\//i.test(page)) return ['--url', page];
117
+ if (/^file:/i.test(page)) page = new URL(page).pathname;
102
118
  const path = resolve(page);
103
119
  if (!existsSync(path)) fail(`no such file: ${page}`);
104
120
  return ['--html', path];
@@ -109,10 +125,13 @@ export async function main(argv, channel = 'current') {
109
125
  const options = split < 0 ? argv : argv.slice(0, split);
110
126
  const extra = split < 0 ? [] : argv.slice(split + 1);
111
127
  let page, which = false, verbose = false, detach = false;
128
+ const app_ = [];
129
+ const setPage = (value) => { if (page !== undefined) fail('give one page'); if (value === undefined) fail('a page option needs a value'); page = value; };
112
130
  // Each of these picks the app or the action. Two of them have no one meaning.
113
131
  const exclusive = options.filter((arg) => ['--latest', '--bleeding-edge', '--install-latest', '--list'].includes(arg));
114
132
  if (new Set(exclusive).size > 1) fail(`${exclusive.join(' and ')} can not be used together; see --help`);
115
- for (const arg of options) {
133
+ for (let i = 0; i < options.length; i++) {
134
+ const arg = options[i];
116
135
  if (arg === '--help' || arg === '-h') { process.stdout.write(HELP); return; }
117
136
  else if (arg === '--latest') channel = 'latest';
118
137
  else if (arg === '--bleeding-edge') channel = 'bleeding-edge';
@@ -127,9 +146,12 @@ export async function main(argv, channel = 'current') {
127
146
  console.log(`${name === current ? '*' : ' '} ${name}`);
128
147
  console.log(` bleeding edge: ${bleedingEdgeApp()}${existsSync(bleedingEdgeApp()) ? '' : ' (not found)'}`);
129
148
  return;
130
- } else if (arg.startsWith('-')) fail(`unknown option ${arg}; see --help`);
131
- else if (page !== undefined) fail('give one page');
132
- else page = arg;
149
+ } else if (arg === '--url' || arg === '--html') setPage(options[++i]);
150
+ else if (arg.startsWith('--url=') || arg.startsWith('--html=')) setPage(arg.slice(arg.indexOf('=') + 1));
151
+ else if (APP_FLAGS.includes(arg)) app_.push(arg);
152
+ else if (APP_VALUES.includes(arg)) { if (options[i + 1] === undefined) fail(`${arg} needs a value`); app_.push(arg, options[++i]); }
153
+ else if (arg.startsWith('-')) fail(`unknown option ${arg}; see --help`);
154
+ else setPage(arg);
133
155
  }
134
156
  if (process.platform !== 'darwin') fail('the native window is macOS only; use the Hub page in a WebGPU browser');
135
157
  let app = process.env.FOAFNAUT_APP;
@@ -138,7 +160,7 @@ export async function main(argv, channel = 'current') {
138
160
  if (!app) app = currentApp() ?? bleedingEdgeApp();
139
161
  if (!existsSync(app)) fail(`no app at ${app}. Run foafnaut --install-latest.`);
140
162
  if (which) { console.log(app); return; }
141
- const args = ['-n', ...(detach ? [] : ['-W']), app, '--args', ...pageArgs(page), ...(verbose ? ['--trace-input'] : []), ...extra];
163
+ const args = ['-n', ...(detach ? [] : ['-W']), app, '--args', ...pageArgs(page), ...(verbose ? ['--trace-input'] : []), ...app_, ...extra];
142
164
  const log = join(homedir(), 'Library', 'Logs', 'foafnaut', 'trace.jsonl');
143
165
  if (verbose) console.error(`foafnaut: app ${app}\nfoafnaut: open ${args.join(' ')}\nfoafnaut: trace ${log}`);
144
166
  // Follow only what this run appends.
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "7f617da4ecbe";
40
+ const WASM_VERSION = "a92a94475fa0";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -2,17 +2,17 @@
2
2
  "engine": "lean4",
3
3
  "note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
4
4
  "stamped": true,
5
- "release": "danbri/factoidal-builds build-31be9833264a-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42304532396",
7
- "gitSha": "31be9833264a2289526a8c9fcc1d6b67610cf746",
8
- "builtAt": "2026-11-06T23:08:01Z",
5
+ "release": "danbri/factoidal-builds build-20ff52ea9005-web",
6
+ "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42520417546",
7
+ "gitSha": "20ff52ea90050c8db7bb7135aa0c20ee118183e5",
8
+ "builtAt": "2026-11-08T21:35:07Z",
9
9
  "leanToolchain": "leanprover/lean4:v4.33.1",
10
10
  "emscripten": "6.0.8",
11
11
  "abiVersion": "1",
12
12
  "engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
13
13
  "version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
14
- "wasmSha256": "7f617da4ecbe7352b94f361802e12bb785f401aafbf78f92b6031c8e920011f2",
15
- "wasmBytes": 32611669,
14
+ "wasmSha256": "a92a94475fa0cfc6fe9aeb28b197e81c3c3162806ecbba562f200fbaea51416b",
15
+ "wasmBytes": 32702060,
16
16
  "claims": {
17
17
  "source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
18
18
  "suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.33.0';
5
+ export const version = '0.34.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "EXPERIMENTAL UNDER-REVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
5
5
  "keywords": [
6
6
  "sparql",
package/xmpp/README.md CHANGED
@@ -95,6 +95,7 @@ process per connection behind socat) serves; the wasm session ops are
95
95
  | `xmppSessionFeed` | `[session, text]` | `{ ok, send: [...], route: [{to, text}], presence: [...], rostersSaved: [...], close, stage, bare, full, bufferedBytes }` |
96
96
  | `xmppSessionDeliver` | `[session, text]` | `{ ok, delivered, send }` — `delivered:false` for an unbound session; nothing is lost |
97
97
  | `xmppSessionClose` | `[session]` | `{ ok, send }` |
98
+ | `xmppSessionPing` | `[session, id]` | `{ ok, sent, send }` — an XEP-0199 ping for a bound session; `sent:false` otherwise. On a WebSocket, `xmppWsPing` writes a ping frame instead |
98
99
  | `xmppSessionTlsUp` | `[session]` | `{ ok, stage }` — after a feed answered `startTls: true` and the host finished the TLS handshake; refused otherwise |
99
100
  | `xmppRosterGet` / `xmppRosterSet` | `[server, bare]` / `[server, bare, encoded]` | the roster in the engine's own on-disk encoding |
100
101
  | `xmppClose` | `[server]` | `{ ok, sessions }` |
@@ -135,6 +136,12 @@ Each name has its own SCRAM salt, from a seed the host gives the engine
135
136
  (`saltSeed`; derived from the state key when there is one). The native
136
137
  harness and a fixed `saltB64` keep one salt for all.
137
138
 
139
+ A live session keeps its full JID. A client that binds a resource a live session holds gets a
140
+ server-generated one (RFC 6120 section 7.7.2.2), so a client must use the JID in the bind result.
141
+ A resource is free as soon as the stream that held it ends. A connection lost with no notice to the
142
+ host stays live until the host sees that. The native harness has no shared session table and does
143
+ not make this check.
144
+
138
145
  The shared engine currently reduces routed destinations to bare JIDs and
139
146
  the Node host delivers to every bound resource. It does not implement full
140
147
  resource-selection/priority semantics. Binding alone permits delivery; this
@@ -205,6 +212,7 @@ The host closes a socket when a limit is reached. These are limits on bytes and
205
212
  | `loginTimeoutMs` | `--login-timeout SECONDS` | 30 s | Time to bind a resource |
206
213
  | `maxQueuedBytes` | | 8 MiB | Bytes waiting for a client that is not reading |
207
214
  | `maxBuffered` | | 1 MiB | Bytes of an unfinished unit |
215
+ | `proxyHeaderTimeoutMs` | | 10 s | With `proxyProtocol`: time to send the PROXY header and, on a TLS listener, to finish the TLS handshake |
208
216
 
209
217
  There is no connection limit per address: behind a proxy, every client has the same one.
210
218
 
@@ -215,6 +223,39 @@ There is no connection limit per address: behind a proxy, every client has the s
215
223
  | `heldLimits.perAccount`, `.perAccountBytes` | | 256 stanzas, 4 MiB | Held for one offline account |
216
224
  | `heldLimits.total`, `.totalBytes` | | 10,000 stanzas, 64 MiB | Held on the server |
217
225
  | `heldLimits.senderPerWindow`, `.senderWindow` | | 60 in 60 s | Stanzas one account may have held; roster writes of one account |
218
- | `addressLimit: false` | `--no-address-limit` | on | Failed logins per client address (5 in 60 s, 20 in an hour). Turn it off behind a proxy, where every client has one address |
226
+ | `addressLimit: false` | `--no-address-limit` | on | Failed logins per client address (5 in 60 s, 20 in an hour). Turn it off only behind a proxy that sends no PROXY header, where every client has one address |
219
227
 
220
228
  Fixed in the engine: 5 failed logins in 60 s per account name, 3 failed attempts on a stream, 1,000 items in a roster, 64 levels and 8,192 elements in a stanza, and room nicks of at most 64 bytes.
229
+
230
+ ## Client addresses and the PROXY protocol
231
+
232
+ The server keeps no client address. At start the host makes a random 32-byte key (`addressKey` of `xmppOpen`) that is in
233
+ memory only and new at each start. The engine keeps, for each session, the tag HMAC-SHA-256(key, prefix), where the
234
+ prefix is the IPv4 address or the /64 of an IPv6 address (`LoginLimit.clientPrefix`, the one place that groups addresses;
235
+ `LoginLimit.addressTag`, theorem `addressTag_prefix`). The failed-login counters hold `address:` and the tag. A log line
236
+ shows the first 12 hexadecimal digits of the tag (`addressTag` of `xmppSessionOpen` and `xmppWsOpen`). After a restart
237
+ the tags change, and so do the counters, which are in memory too.
238
+
239
+ `proxyProtocol: true` (`xmpp-serve --proxy-protocol`) is for a listener behind a TCP proxy that ends no TLS, such as Fly
240
+ with `handlers = ["proxy_proto"]` and `proxy_proto_options = { version = "v2" }`. Every connection on every listener
241
+ (the main port, `--tls-port`, `--ws-port`) must start with a PROXY protocol version 2 header (HAProxy,
242
+ https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt section 2.2), and the client address in it is the one the
243
+ limit counts. The host buffers the first bytes and calls `proxyHeaderParse` (IN region: the bytes so far) until the
244
+ engine decides (`L4Factoidal/Net/ProxyProtocol.lean`, `parse`):
245
+
246
+ | Decision | Host |
247
+ |---|---|
248
+ | `more` | Waits. Theorem `parse_more_bounded`: only while fewer than 1,040 bytes are held. `proxyHeaderTimeoutMs` closes a connection that never completes a header |
249
+ | `refuse`, with `reason` `signature`, `version`, `command`, `family` or `length` | Closes the connection. No XMPP and no TLS byte is read. A connection that starts with anything else (an XMPP stream header, a TLS ClientHello) is refused at its first byte |
250
+ | `local` | A connection the proxy made itself (a health check). Spec: the receiver uses the real endpoints, so the address is the socket's |
251
+ | `proxy`, with `address` and `port` | The address goes to the session as `remoteAddress` |
252
+
253
+ The bytes after the header, in the same read, go to TLS or to the XMPP session (theorems `parse_proxy_le`,
254
+ `parse_local_le`). PROXY is accepted for TCP over IPv4 (`0x11`) and IPv6 (`0x21`) only; UNSPEC, UNIX and UDP name no
255
+ client address for the limit and are refused. TLVs are skipped. A length field above 1,024 is refused. An IPv4-mapped IPv6
256
+ source is counted as its IPv4 address. Version 1 (the text form) is not read: Fly sends version 2 when asked, and a second
257
+ parser would only be one more input path.
258
+
259
+ Without `proxyProtocol` no header is read, so no client can claim an address; the socket's address is tagged the same way.
260
+ `proxyProtocol` on a listener that is reachable without the proxy lets a client choose its address for the limit: use it
261
+ only where every connection comes through the proxy.
package/xmpp/server.mjs CHANGED
@@ -37,12 +37,25 @@
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 connection limit per address: behind a
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
42
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
56
  import { createHash, randomBytes } from 'node:crypto'
45
57
  import { StringDecoder } from 'node:string_decoder'
58
+ import { Duplex } from 'node:stream'
46
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'
@@ -51,7 +64,7 @@ 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',
67
+ 'xmppSessionOpen', 'xmppSessionFeed', 'xmppSessionDeliver', 'xmppSessionPing', 'xmppSessionClose',
55
68
  'xmppSessionTlsUp', 'xmppRosterGet', 'xmppRosterSet', 'xmppWsOpen', 'xmppStateLoad', 'xmppClock'
56
69
  ]
57
70
 
@@ -154,6 +167,9 @@ export async function openXmppServer (options = {}) {
154
167
  if (typeof options.mucDomain === 'string') config.mucDomain = options.mucDomain
155
168
  // Members-only rooms: [{room, members: [bare JID]}]; no list, an open room.
156
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
157
173
  // Nicks reserved for a JID: [{nick, jid}]. Each account's name is reserved for it anyway.
158
174
  if (Array.isArray(options.mucReservedNicks)) config.mucReservedNicks = options.mucReservedNicks
159
175
  // XEP-0369 channels (L4Factoidal/XMPP/Mix.lean), joined through MIX-PAM; off unless named.
@@ -167,6 +183,9 @@ export async function openXmppServer (options = {}) {
167
183
  // (https://github.com/danbri/factoidal/issues/1128); the engine decides.
168
184
  if (options.disableRateLimits === true) config.disableRateLimits = true
169
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)
170
189
  let opened
171
190
  try {
172
191
  opened = engine.call('xmppOpen', [JSON.stringify(config)])
@@ -200,6 +219,11 @@ export async function openXmppServer (options = {}) {
200
219
  engine.call('xmppClose', [handle])
201
220
  throw new XmppHostError('this engine does not carry members-only rooms; rebuild the engine')
202
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
+ }
203
227
  return {
204
228
  engine,
205
229
  handle,
@@ -230,6 +254,8 @@ export async function openXmppServer (options = {}) {
230
254
  let open = true
231
255
  return {
232
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 : '',
233
259
  /** Feed received text; returns the engine's answer document. */
234
260
  feed (text) {
235
261
  tick()
@@ -243,6 +269,10 @@ export async function openXmppServer (options = {}) {
243
269
  deliver (text) {
244
270
  return engine.call('xmppSessionDeliver', [session, text])
245
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
+ },
246
276
  /** End of input: returns `{send: string[]}` and releases the handle. */
247
277
  close () {
248
278
  if (!open) return { send: [], routeFull: [] }
@@ -315,8 +345,18 @@ async function accountsFileWarnings (path) {
315
345
  * without a bound resource before it is closed (default 30000; 0 disables)
316
346
  * @param {number} [options.maxQueuedBytes] bytes waiting to be written to a
317
347
  * client that is not reading, before the socket is closed (default 8 MiB)
318
- * @param {boolean} [options.addressLimit] false: count failed logins per account name only, for a
319
- * listener behind a proxy, where every client has one address (default true)
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)
320
360
  * @param {Function} [options.onError]
321
361
  * @param {Function} [options.onLog]
322
362
  * @returns {Promise<{server: object, xmpp: object, close: Function, sessions: Function}>}
@@ -337,6 +377,11 @@ export async function createXmppServer (options = {}) {
337
377
  const maxConnections = limit('maxConnections', 1024)
338
378
  const loginTimeoutMs = limit('loginTimeoutMs', 30000)
339
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')
340
385
  if (!Number.isSafeInteger(maxBuffered) || maxBuffered < 0) {
341
386
  throw new XmppHostError('maxBuffered must be a nonnegative safe integer')
342
387
  }
@@ -369,6 +414,17 @@ export async function createXmppServer (options = {}) {
369
414
  if (dir !== null && options.stateKey !== undefined) serverOptions.persist = true
370
415
  const xmpp = await openXmppServer(serverOptions)
371
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
+ }
372
428
 
373
429
  // Rosters the previous run persisted.
374
430
  try {
@@ -415,7 +471,7 @@ export async function createXmppServer (options = {}) {
415
471
  ' acl:mode acl:Read, acl:Write, acl:Control .\n'
416
472
  })
417
473
  const get = (target, accept) => state.step({ method: 'GET', target, headers: accept ? [['Accept', accept]] : [], body: new Uint8Array(0) })
418
- for (const kind of ['roster', 'held', 'mix', 'gc3']) {
474
+ for (const kind of ['roster', 'held', 'mix', 'gc3', 'muc']) {
419
475
  const listing = await get(`/xmpp/${kind}/`, 'application/lws+json')
420
476
  if (listing.status === 404) continue
421
477
  if (listing.status !== 200) throw new XmppHostError(`state: listing /xmpp/${kind}/ answered ${listing.status}`)
@@ -478,10 +534,95 @@ export async function createXmppServer (options = {}) {
478
534
  // connection callback and therefore have no engine session yet.
479
535
  const transports = new Set()
480
536
 
481
- // The client's address for the login limit. Behind a proxy every client has
482
- // the proxy's address, and a limit on it would stop them all: `addressLimit: false`.
483
- const addressOf = (socket) =>
484
- options.addressLimit !== false && typeof socket.remoteAddress === 'string' ? socket.remoteAddress : ''
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
+ }
485
626
 
486
627
  // Transport limits, like `maxBuffered`: bytes and time, not protocol.
487
628
  function closeIfBacklogged (c) {
@@ -507,6 +648,35 @@ export async function createXmppServer (options = {}) {
507
648
  c.loginTimer.unref()
508
649
  }
509
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
+
510
680
  /** Count a new socket, or close it at once if the server is full. */
511
681
  function admit (socket) {
512
682
  if (transports.size >= maxConnections) {
@@ -612,10 +782,10 @@ export async function createXmppServer (options = {}) {
612
782
  }
613
783
  }
614
784
 
615
- function onConnection (socket, direct = false) {
785
+ function onConnection (socket, direct = false, address = socket.remoteAddress, rest = null) {
616
786
  let session
617
787
  try {
618
- session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct, remoteAddress: addressOf(socket) })
788
+ session = xmpp.openSession({ tlsCarrier: useTls || direct, startTls: startTls && !direct, remoteAddress: addressOf(address) })
619
789
  } catch (error) {
620
790
  onError(error)
621
791
  socket.destroy()
@@ -628,16 +798,25 @@ export async function createXmppServer (options = {}) {
628
798
  full: null,
629
799
  closing: false,
630
800
  decoder: new StringDecoder('utf8'),
801
+ lastHeard: Date.now(),
802
+ pingedAt: 0,
803
+ pings: 0,
631
804
  deliver (text) {
632
805
  const answer = c.session.deliver(text)
633
806
  if (answer.delivered === true) { writeAll(c, answer.send); return true }
634
807
  return false
808
+ },
809
+ ping () {
810
+ const answer = c.session.ping(`ping-${++c.pings}`)
811
+ if (answer.sent === true) writeAll(c, answer.send)
635
812
  }
636
813
  }
637
814
  connections.add(c)
638
815
  startLoginTimer(c)
639
- 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}`)
640
818
  c.onData = (chunk) => {
819
+ heard(c)
641
820
  if (!connections.has(c) || c.closing || c.upgrading) return
642
821
  const text = c.decoder.write(chunk)
643
822
  if (text.length === 0) return
@@ -669,6 +848,8 @@ export async function createXmppServer (options = {}) {
669
848
  if (error && error.code !== 'ECONNRESET') onError(error)
670
849
  endSession(c)
671
850
  })
851
+ // The bytes that came in one read with a PROXY header.
852
+ if (rest !== null && rest.length > 0) c.onData(rest)
672
853
  }
673
854
 
674
855
  // XMPP over WebSocket (RFC 7395): every byte goes to xmppWsFeed, which
@@ -679,17 +860,19 @@ export async function createXmppServer (options = {}) {
679
860
  const got = xmpp.engine.callBlobIO(op, args, bytes)
680
861
  return { envelope: got.envelope, bytes: got.bytes instanceof Uint8Array ? got.bytes : new Uint8Array(0) }
681
862
  }
682
- function onWsConnection (socket) {
863
+ function onWsConnection (socket, address = socket.remoteAddress, rest = null) {
683
864
  let handle
865
+ let tag = ''
684
866
  try {
685
867
  const opened = xmpp.engine.call('xmppWsOpen', [xmpp.handle, JSON.stringify({
686
- streamId: randomToken(), nonce: randomToken(), remoteAddress: addressOf(socket),
868
+ streamId: randomToken(), nonce: randomToken(), remoteAddress: addressOf(address),
687
869
  path: typeof ws.path === 'string' ? ws.path : '/xmpp-websocket',
688
870
  allowedOrigins: Array.isArray(ws.allowedOrigins) ? ws.allowedOrigins : [],
689
871
  allowNoOrigin: ws.allowNoOrigin === true,
690
872
  tlsCarrier: ws.cert !== undefined && ws.key !== undefined
691
873
  })])
692
874
  handle = opened.handle
875
+ if (typeof opened.addressTag === 'string') tag = opened.addressTag
693
876
  } catch (error) {
694
877
  onError(error)
695
878
  socket.destroy()
@@ -700,6 +883,8 @@ export async function createXmppServer (options = {}) {
700
883
  bare: null,
701
884
  full: null,
702
885
  closing: false,
886
+ lastHeard: Date.now(),
887
+ pingedAt: 0,
703
888
  session: {
704
889
  close () {
705
890
  try {
@@ -712,11 +897,17 @@ export async function createXmppServer (options = {}) {
712
897
  const got = wsCall('xmppWsDeliver', [handle, text], new Uint8Array(0))
713
898
  if (got.envelope.delivered === true) { if (!socket.destroyed) socket.write(got.bytes); closeIfBacklogged(c); return true }
714
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) }
715
904
  }
716
905
  }
717
906
  connections.add(c)
718
907
  startLoginTimer(c)
719
- socket.on('data', (chunk) => {
908
+ onLog(`WebSocket connection from ${tag === '' ? 'no address' : 'address ' + tag}`)
909
+ const onData = (chunk) => {
910
+ heard(c)
720
911
  if (!connections.has(c) || c.closing) return
721
912
  let got
722
913
  try {
@@ -735,13 +926,16 @@ export async function createXmppServer (options = {}) {
735
926
  socket.destroy()
736
927
  endSession(c)
737
928
  }
738
- })
929
+ }
930
+ socket.on('data', onData)
739
931
  socket.on('end', () => endSession(c))
740
932
  socket.on('close', () => endSession(c))
741
933
  socket.on('error', (error) => {
742
934
  if (error && error.code !== 'ECONNRESET') onError(error)
743
935
  endSession(c)
744
936
  })
937
+ // The bytes that came in one read with a PROXY header.
938
+ if (rest !== null && rest.length > 0) onData(rest)
745
939
  }
746
940
  let wsServer = null
747
941
  if (ws !== null) {
@@ -749,19 +943,34 @@ export async function createXmppServer (options = {}) {
749
943
  xmpp.close()
750
944
  throw new XmppHostError('the WebSocket listener needs both cert and key for TLS')
751
945
  }
752
- 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) {
753
954
  const tls = await import('node:tls')
754
- wsServer = tls.createServer({ cert: ws.cert, key: ws.key }, onWsConnection)
955
+ wsServer = tls.createServer({ cert: ws.cert, key: ws.key }, (socket) => onWsConnection(socket))
755
956
  } else {
756
957
  const net = await import('node:net')
757
- wsServer = net.createServer(onWsConnection)
958
+ wsServer = net.createServer((socket) => onWsConnection(socket))
758
959
  }
759
960
  wsServer.on('connection', admit)
760
961
  }
761
962
 
762
963
  let server
763
964
  try {
764
- 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) {
765
974
  const tls = await import('node:tls')
766
975
  server = tls.createServer({ cert: options.cert, key: options.key }, (socket) => onConnection(socket))
767
976
  } else {
@@ -779,7 +988,13 @@ export async function createXmppServer (options = {}) {
779
988
  let directServer = null
780
989
  if (startTls && options.directTlsPort !== undefined) {
781
990
  const tls = await import('node:tls')
782
- 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
+ }
783
998
  directServer.on('connection', admit)
784
999
  }
785
1000
 
@@ -843,6 +1058,7 @@ export async function createXmppServer (options = {}) {
843
1058
  if (closePromise !== null) return closePromise
844
1059
  closePromise = (async () => {
845
1060
  if (poller !== null) clearInterval(poller)
1061
+ if (pinger !== null) clearInterval(pinger)
846
1062
  for (const c of Array.from(connections)) {
847
1063
  endSession(c)
848
1064
  c.socket.destroy()