@factoidal/core 0.33.0 → 0.35.1
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 +31 -1
- package/bin/factoidal.mjs +64 -7
- package/bin/foafnaut.mjs +68 -12
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/version.mjs +1 -1
- package/package.json +3 -2
- package/xmpp/README.md +42 -1
- package/xmpp/server.mjs +238 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.35.1 (2026-11-11)
|
|
4
|
+
|
|
5
|
+
- Foafnaut (macOS): a heavy page no longer freezes the app. The host keeps one frame of GPU work in flight for heavy frames (three for light ones); after three frames over 250 ms it slows the page's animation and shows "SLOWED: HEAVY PAGE". Quitting or closing the window stops GPU work, so the app quits within seconds (before, up to 209 s on the Spinal Tap page at 2x). `--gpu-frames-in-flight N` sets the limit.
|
|
6
|
+
- Foafnaut: Arabic letters join again on a page that loaded Noto Sans Math first; a font whose GSUB covers the script is preferred. Chinese, Japanese and Korean text breaks between characters, so long runs wrap instead of pushing right-to-left lines out of their cells.
|
|
7
|
+
- BubblePopJS: conversions between byte arrays and the engine's values are faster (Pako about 14% faster on the native engine), with proofs that the fast forms give the same results.
|
|
8
|
+
- XMPP: xmpp-serve logs each connection by its address tag, never the address (--quiet stops it).
|
|
9
|
+
|
|
10
|
+
## 0.35.0 (2026-11-10)
|
|
11
|
+
|
|
12
|
+
- Foafnaut: partial frames. A frame compiles again only the primitives that changed, keeps a damage rectangle, and redraws only that part of a layer. On the Spinal Tap page, one animated frame went from 351 ms to 3.9 ms of GPU and JS time at 1x (1225 ms to 4.0 ms at 2x); engine time per frame 28.5 ms to 10.9 ms. `retained:false` still gives the full path, and tests check that both give the same bytes.
|
|
13
|
+
- Foafnaut: BubblePopJS, the built-in JavaScript engine, runs page scripts by default everywhere, also on the Hub. QuickJS-NG stays in the builds for comparison. Fixes: `r.exec(s)` in a loop no longer pays for the length of `s` on each call; a block with `let` or `const` may hold an `await`.
|
|
14
|
+
- Foafnaut: a shader gallery page (WebGPU, WGSL), the same results on both JavaScript engines.
|
|
15
|
+
- Foafnaut: the Hub starts faster, and layout is faster on large pages.
|
|
16
|
+
- Foafnaut: on a Retina Mac the page is drawn at full resolution.
|
|
17
|
+
- Foafnaut: iframes; form submission in the page's encoding; colour emoji (COLR/CPAL) in text, SVG text and canvas text; more fonts and SMIL animation.
|
|
18
|
+
- Foafnaut: a click is no longer lost when the engine is busy; pointer input runs before queued engine work, and script hit tests use the frame on screen.
|
|
19
|
+
- Foafnaut (macOS): the app links Lean's runtime without GNU MP. The releases that still contain GNU MP carry its notice.
|
|
20
|
+
- `foafnaut` command: with no channel, it starts the newer of the installed release and the local bleeding-edge build, and says which. `--bleeding-edge` says the commit and age of the local build and warns when it is more than 6 hours old.
|
|
21
|
+
- LWS client: `verifyNotification` accepts a notification whose `Signature-Input` covers the derived component `@query` (RFC 9421 section 2.2.7; https://github.com/danbri/factoidal-core/issues/379). The value comes from the inbox URL: `?` and the query, or `?` alone when the URL has no query. Before, the client read `@query` as a header field and refused the notification with "a covered component is missing", so `createNotificationReceiver` answered 401 to every notification from a storage that signs `@query` (sparq-lws-core; seen with 0.26.0 and 0.34.0). The client derives `@method`, `@scheme`, `@authority`, `@path` and `@query` only. Another name that starts with `@` (`@target-uri`, `@request-target`, `@query-param`) is refused and is not read as a header field. The decision is in Lean (`inboxBase` and `urlQuery`, `L4Factoidal/LWS/Client/Machine.lean`), through the wasm op `lwsClientStep`.
|
|
22
|
+
- `foafnaut` command: `--version` prints the commit and the build time of the app that would run. The app gives the answer, from the record that its build wrote. An app that is too old to answer gives the line "this build is too old to answer --version".
|
|
23
|
+
- `foafnaut` command: a page given as `file:name.html`, with no `//`, is a path relative to the current directory. A `file://` URL is read as a URL, so percent-encoded characters in its path are decoded.
|
|
24
|
+
- XMPP: every IQ result the server sends has a `from` (RFC 6120 section 8.1.2.1): the address asked, or the account's bare JID when the request had no `to`. Theorems `handleIq_send_has_from` and `handleIq_domain_from`.
|
|
25
|
+
|
|
26
|
+
## 0.34.0 (2026-11-08)
|
|
27
|
+
|
|
28
|
+
- `foafnaut` command: `--url` and `--html` open a page or a file (`file:` URLs too), and other options are passed to the app.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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`.
|
|
32
|
+
- 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.
|
|
33
|
+
- 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
34
|
|
|
5
35
|
## 0.33.0 (2026-11-06)
|
|
6
36
|
|
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] [--
|
|
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
|
|
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
|
|
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']),
|
|
@@ -2970,7 +2998,9 @@ async function commandXmppServe (positional, options) {
|
|
|
2970
2998
|
dir: positional[0],
|
|
2971
2999
|
port: servePort(options, useTls && !startTls ? 5223 : 5222),
|
|
2972
3000
|
host: serveHost(options),
|
|
2973
|
-
onError: (error) => { if (!quiet) err(`xmpp-serve: ${error.message}`) }
|
|
3001
|
+
onError: (error) => { if (!quiet) err(`xmpp-serve: ${error.message}`) },
|
|
3002
|
+
// Connection events: an address tag (never an address), closes at a timeout.
|
|
3003
|
+
onLog: (line) => { if (!quiet) err(`xmpp-serve: ${line}`) }
|
|
2974
3004
|
}
|
|
2975
3005
|
if (typeof options.domain === 'string') settings.domain = options.domain
|
|
2976
3006
|
if (typeof options.accounts === 'string') {
|
|
@@ -2983,6 +3013,7 @@ async function commandXmppServe (positional, options) {
|
|
|
2983
3013
|
settings.disableRateLimits = options['dangerously-disable-ratelimits'] === true
|
|
2984
3014
|
}
|
|
2985
3015
|
if (options['no-address-limit'] === true) settings.addressLimit = false
|
|
3016
|
+
if (options['proxy-protocol'] === true) settings.proxyProtocol = true
|
|
2986
3017
|
if (useTls) {
|
|
2987
3018
|
settings.cert = await fs.readFile(options.cert)
|
|
2988
3019
|
settings.key = await fs.readFile(options.key)
|
|
@@ -3019,6 +3050,20 @@ async function commandXmppServe (positional, options) {
|
|
|
3019
3050
|
settings.mucMembers.push({ room, members })
|
|
3020
3051
|
}
|
|
3021
3052
|
}
|
|
3053
|
+
if (options['muc-owners'] !== undefined) {
|
|
3054
|
+
if (typeof options['muc-owners'] !== 'string') throw new UsageError('--muc-owners needs a FILE')
|
|
3055
|
+
if (settings.mucDomain === undefined) throw new UsageError('--muc-owners needs --muc')
|
|
3056
|
+
settings.mucOwners = []
|
|
3057
|
+
for (const line of (await fs.readFile(options['muc-owners'], 'utf8')).split('\n')) {
|
|
3058
|
+
if (line.trim() === '') continue
|
|
3059
|
+
const [, room, list] = /^([^:\s@/]+):(.*)$/.exec(line.trim()) ?? []
|
|
3060
|
+
const owners = (list ?? '').split(/\s+/).filter(Boolean)
|
|
3061
|
+
if (room === undefined || owners.length === 0 || owners.some((m) => !/^[^\s@/]+@[^\s@/]+$/.test(m))) {
|
|
3062
|
+
throw new UsageError(`--muc-owners: not "room: bare@jid ...": ${line}`)
|
|
3063
|
+
}
|
|
3064
|
+
settings.mucOwners.push({ room, owners })
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3022
3067
|
if (options.mix !== undefined) {
|
|
3023
3068
|
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
3069
|
throw new UsageError('--mix needs a domain name, e.g. mix.example.com')
|
|
@@ -3043,6 +3088,16 @@ async function commandXmppServe (positional, options) {
|
|
|
3043
3088
|
if (!Number.isSafeInteger(n) || n < 0) throw new UsageError('--login-timeout needs a number of seconds')
|
|
3044
3089
|
settings.loginTimeoutMs = n * 1000
|
|
3045
3090
|
}
|
|
3091
|
+
if (options['ping-interval'] !== undefined) {
|
|
3092
|
+
const n = Number(options['ping-interval'])
|
|
3093
|
+
if (!Number.isSafeInteger(n) || n < 0) throw new UsageError('--ping-interval needs a number of seconds')
|
|
3094
|
+
settings.pingIntervalMs = n * 1000
|
|
3095
|
+
}
|
|
3096
|
+
if (options['ping-timeout'] !== undefined) {
|
|
3097
|
+
const n = Number(options['ping-timeout'])
|
|
3098
|
+
if (!Number.isSafeInteger(n) || n < 1) throw new UsageError('--ping-timeout needs a positive number of seconds')
|
|
3099
|
+
settings.pingTimeoutMs = n * 1000
|
|
3100
|
+
}
|
|
3046
3101
|
if (options['ws-port'] !== undefined) {
|
|
3047
3102
|
const wsPort = Number(options['ws-port'])
|
|
3048
3103
|
if (!Number.isInteger(wsPort) || wsPort < 0 || wsPort > 65535) throw new UsageError('--ws-port needs a port number')
|
|
@@ -3078,6 +3133,8 @@ async function commandXmppServe (positional, options) {
|
|
|
3078
3133
|
err(`xmpp-serve: listening on ${running.origin} for domain ${running.xmpp.domain}` +
|
|
3079
3134
|
` (${running.xmpp.accounts} accounts, state ${positional[0]}` +
|
|
3080
3135
|
(startTls ? ', STARTTLS required)' : useTls ? ', direct TLS)' : ', PLAINTEXT: no TLS on this listener)'))
|
|
3136
|
+
if (settings.proxyProtocol) err('xmpp-serve: every connection must start with a PROXY protocol v2 header')
|
|
3137
|
+
else if (settings.addressLimit === false) err('xmpp-serve: failed logins count per account name only (--no-address-limit)')
|
|
3081
3138
|
if (settings.mucDomain) err(`xmpp-serve: multi-user chat (XEP-0045) at ${settings.mucDomain}`)
|
|
3082
3139
|
if (settings.mixDomain) err(`xmpp-serve: MIX channels (XEP-0369, joined by XEP-0405) at ${settings.mixDomain}`)
|
|
3083
3140
|
if (settings.gc3Domain) err(`xmpp-serve: GC3 PROTOTYPE groups at ${settings.gc3Domain} (no published spec; invented wire format)`)
|
package/bin/foafnaut.mjs
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
// current -> versions/<commit>, config.json {"bleedingEdge":"/path/to/App.app"}.
|
|
6
6
|
import { spawn, spawnSync } from 'node:child_process';
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
|
-
import { basename, join, resolve } from 'node:path';
|
|
11
|
-
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath, 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)
|
|
@@ -21,8 +23,21 @@ const HELP = `foafnaut [--latest|--bleeding-edge] [PAGE] [-- APP_ARGS]
|
|
|
21
23
|
--install-latest install the newest published build and stop
|
|
22
24
|
--list list installed versions
|
|
23
25
|
--which print the app that would run
|
|
26
|
+
--version print the commit and build time of that app (the app answers)
|
|
24
27
|
--help this text
|
|
25
28
|
|
|
29
|
+
App options, passed to the app as they are:
|
|
30
|
+
--perf-summary print frame and input latency figures when the window closes
|
|
31
|
+
--check-pixels read back and check every frame (slow)
|
|
32
|
+
--wasm-engine use the Wasm engine, not the native library
|
|
33
|
+
--native-engine PATH use this native engine library
|
|
34
|
+
--font FILE load this font file
|
|
35
|
+
--bitmap-font use the baked-in ASCII font only
|
|
36
|
+
--follow-links open links that a page asks for at load
|
|
37
|
+
--be-liberal-in-what-we-accept draw a page without the features this browser may skip
|
|
38
|
+
(module scripts, unreadable media conditions, stylesheets that did not
|
|
39
|
+
load) and list them in the console (Option-Command-J); the default refuses
|
|
40
|
+
|
|
26
41
|
Also: foafnaut-latest, foafnaut-bleedingedge. Environment: FOAFNAUT_HOME, and
|
|
27
42
|
FOAFNAUT_APP, which chooses the app outright. Page data goes to the app. Its trace
|
|
28
43
|
is in ~/Library/Logs/foafnaut/trace.jsonl. The command stays open until the window
|
|
@@ -96,9 +111,16 @@ async function installLatest() {
|
|
|
96
111
|
return versionApp(dir);
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
// App options that take no value, and those that take one.
|
|
115
|
+
const APP_FLAGS = ['--perf-summary', '--check-pixels', '--wasm-engine', '--bitmap-font', '--follow-links', '--idle-checks-only', '--trace-input', '--be-liberal-in-what-we-accept'];
|
|
116
|
+
const APP_VALUES = ['--native-engine', '--font'];
|
|
117
|
+
|
|
99
118
|
function pageArgs(page) {
|
|
100
119
|
if (page === undefined) return [];
|
|
101
120
|
if (/^https?:\/\//i.test(page)) return ['--url', page];
|
|
121
|
+
// file:///abs/path is a URL; file:name.html has no // and is relative to here.
|
|
122
|
+
if (/^file:\/\//i.test(page)) page = fileURLToPath(page);
|
|
123
|
+
else if (/^file:/i.test(page)) page = decodeURIComponent(page.slice(5));
|
|
102
124
|
const path = resolve(page);
|
|
103
125
|
if (!existsSync(path)) fail(`no such file: ${page}`);
|
|
104
126
|
return ['--html', path];
|
|
@@ -108,15 +130,19 @@ export async function main(argv, channel = 'current') {
|
|
|
108
130
|
const split = argv.indexOf('--');
|
|
109
131
|
const options = split < 0 ? argv : argv.slice(0, split);
|
|
110
132
|
const extra = split < 0 ? [] : argv.slice(split + 1);
|
|
111
|
-
let page, which = false, verbose = false, detach = false;
|
|
133
|
+
let page, which = false, verbose = false, detach = false, version = false;
|
|
134
|
+
const app_ = [];
|
|
135
|
+
const setPage = (value) => { if (page !== undefined) fail('give one page'); if (value === undefined) fail('a page option needs a value'); page = value; };
|
|
112
136
|
// Each of these picks the app or the action. Two of them have no one meaning.
|
|
113
137
|
const exclusive = options.filter((arg) => ['--latest', '--bleeding-edge', '--install-latest', '--list'].includes(arg));
|
|
114
138
|
if (new Set(exclusive).size > 1) fail(`${exclusive.join(' and ')} can not be used together; see --help`);
|
|
115
|
-
for (
|
|
139
|
+
for (let i = 0; i < options.length; i++) {
|
|
140
|
+
const arg = options[i];
|
|
116
141
|
if (arg === '--help' || arg === '-h') { process.stdout.write(HELP); return; }
|
|
117
142
|
else if (arg === '--latest') channel = 'latest';
|
|
118
143
|
else if (arg === '--bleeding-edge') channel = 'bleeding-edge';
|
|
119
144
|
else if (arg === '--which') which = true;
|
|
145
|
+
else if (arg === '--version') version = true;
|
|
120
146
|
else if (arg === '--verbose' || arg === '-v') verbose = true;
|
|
121
147
|
else if (arg === '--detach') detach = true;
|
|
122
148
|
else if (arg === '--install-latest') { await installLatest(); return; }
|
|
@@ -127,18 +153,48 @@ export async function main(argv, channel = 'current') {
|
|
|
127
153
|
console.log(`${name === current ? '*' : ' '} ${name}`);
|
|
128
154
|
console.log(` bleeding edge: ${bleedingEdgeApp()}${existsSync(bleedingEdgeApp()) ? '' : ' (not found)'}`);
|
|
129
155
|
return;
|
|
130
|
-
} else if (arg
|
|
131
|
-
else if (
|
|
132
|
-
else
|
|
156
|
+
} else if (arg === '--url' || arg === '--html') setPage(options[++i]);
|
|
157
|
+
else if (arg.startsWith('--url=') || arg.startsWith('--html=')) setPage(arg.slice(arg.indexOf('=') + 1));
|
|
158
|
+
else if (APP_FLAGS.includes(arg)) app_.push(arg);
|
|
159
|
+
else if (APP_VALUES.includes(arg)) { if (options[i + 1] === undefined) fail(`${arg} needs a value`); app_.push(arg, options[++i]); }
|
|
160
|
+
else if (arg.startsWith('-')) fail(`unknown option ${arg}; see --help`);
|
|
161
|
+
else setPage(arg);
|
|
133
162
|
}
|
|
134
163
|
if (process.platform !== 'darwin') fail('the native window is macOS only; use the Hub page in a WebGPU browser');
|
|
135
164
|
let app = process.env.FOAFNAUT_APP;
|
|
136
165
|
if (!app && channel === 'latest') app = await installLatest();
|
|
137
166
|
if (!app && channel === 'bleeding-edge') app = bleedingEdgeApp();
|
|
138
|
-
|
|
167
|
+
// With no channel named: the newer of the installed release and the bleeding-edge build
|
|
168
|
+
// (owner, 2026-11-09: plain foafnaut started a release of 6 November, older than the
|
|
169
|
+
// local build, with nothing to say so). Each is dated by its files on disk.
|
|
170
|
+
if (!app) {
|
|
171
|
+
const release = currentApp(), local = existsSync(bleedingEdgeApp()) ? bleedingEdgeApp() : undefined;
|
|
172
|
+
const age = (path) => statSync(path).mtimeMs;
|
|
173
|
+
app = release && local ? (age(local) >= age(release) ? local : release) : (release ?? local ?? bleedingEdgeApp());
|
|
174
|
+
if (release && app === release)
|
|
175
|
+
console.error(`foafnaut: the installed release ${basename(realpathSync(join(home(), 'current')))} (newer than the bleeding-edge build)`);
|
|
176
|
+
else if (release) console.error('foafnaut: the bleeding-edge build (newer than the installed release; --latest for the release)');
|
|
177
|
+
}
|
|
139
178
|
if (!existsSync(app)) fail(`no app at ${app}. Run foafnaut --install-latest.`);
|
|
179
|
+
// The bleeding-edge build says what it was built from (BUILD-INFO.json beside the app,
|
|
180
|
+
// written when the build is swapped in); an old build is named, so it is not taken for main.
|
|
181
|
+
if (app === bleedingEdgeApp()) {
|
|
182
|
+
try {
|
|
183
|
+
const info = JSON.parse(readFileSync(join(dirname(app), 'BUILD-INFO.json'), 'utf8'));
|
|
184
|
+
const hours = (Date.now() - Date.parse(info.builtAt)) / 3.6e6;
|
|
185
|
+
console.error(`foafnaut: bleeding edge built from ${info.commit}${info.dirty ? " with uncommitted changes" : ""} at ${info.builtAt} (${hours.toFixed(1)} h ago)` +
|
|
186
|
+
(hours > 6 ? '; it may lack newer work on main' : ''));
|
|
187
|
+
} catch { console.error('foafnaut: bleeding edge has no BUILD-INFO.json; its commit is not known'); }
|
|
188
|
+
}
|
|
140
189
|
if (which) { console.log(app); return; }
|
|
141
|
-
|
|
190
|
+
// The app answers for itself. `open` shows no output, so run its program directly.
|
|
191
|
+
if (version) {
|
|
192
|
+
const run = spawnSync(join(dirname(app), 'native-webgpu-html-darwin-arm64'), ['--version'], { encoding: 'utf8' });
|
|
193
|
+
if (run.status === 0) process.stdout.write(run.stdout);
|
|
194
|
+
else console.log(`foafnaut: this build is too old to answer --version (${app})`);
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
const args = ['-n', ...(detach ? [] : ['-W']), app, '--args', ...pageArgs(page), ...(verbose ? ['--trace-input'] : []), ...app_, ...extra];
|
|
142
198
|
const log = join(homedir(), 'Library', 'Logs', 'foafnaut', 'trace.jsonl');
|
|
143
199
|
if (verbose) console.error(`foafnaut: app ${app}\nfoafnaut: open ${args.join(' ')}\nfoafnaut: trace ${log}`);
|
|
144
200
|
// Follow only what this run appends.
|
package/l4-assets/l4factoidal.js
CHANGED
|
Binary file
|
package/l4-assets/version.json
CHANGED
|
@@ -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-
|
|
6
|
-
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/
|
|
7
|
-
"gitSha": "
|
|
8
|
-
"builtAt": "2026-11-
|
|
5
|
+
"release": "danbri/factoidal-builds build-6f57afba14e0-web",
|
|
6
|
+
"releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/42968513371",
|
|
7
|
+
"gitSha": "6f57afba14e04ed648358e4693790edff69a6cd3",
|
|
8
|
+
"builtAt": "2026-11-11T07:07:29Z",
|
|
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": "
|
|
15
|
-
"wasmBytes":
|
|
14
|
+
"wasmSha256": "2e241365bd7906474320610a65af99641474534fa0fe0e744ed3d9edeef4ee41",
|
|
15
|
+
"wasmBytes": 32703824,
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"author": "Dan Brickley <danbri@danbri.org>",
|
|
29
29
|
"repository": {
|
|
30
30
|
"type": "git",
|
|
31
|
-
"url": "git+https://github.com/danbri/factoidal
|
|
31
|
+
"url": "git+https://github.com/danbri/factoidal.git",
|
|
32
|
+
"directory": "npm/factoidal"
|
|
32
33
|
},
|
|
33
34
|
"main": "./index.js",
|
|
34
35
|
"module": "./index.mjs",
|
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
|
|
41
|
-
// `maxQueuedBytes
|
|
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 {
|
|
319
|
-
*
|
|
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
|
|
482
|
-
//
|
|
483
|
-
const addressOf = (
|
|
484
|
-
options.addressLimit !== false && typeof
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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()
|