@factoidal/core 0.20.0 → 0.21.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 +49 -0
- package/README.md +38 -2
- package/authz/server.mjs +23 -2
- package/bin/engine.mjs +22 -1
- package/bin/factoidal.mjs +710 -38
- package/bin/pack-host.mjs +5 -0
- package/bin/serve.mjs +19 -3
- package/browser.d.ts +10 -0
- package/browser.js +12 -0
- package/index.d.ts +248 -9
- package/index.mjs +15 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +236 -12
- package/lib/browser-engine.mjs +28 -1
- package/lib/cid-node.mjs +77 -0
- package/lib/cid.mjs +146 -0
- package/lib/cipher-host.mjs +126 -0
- package/lib/engine-integrity.mjs +56 -0
- package/lib/nodes.mjs +234 -11
- package/lib/sbm11.mjs +14 -1
- package/lib/version.mjs +1 -1
- package/lws/README.md +9 -0
- package/lws/client.mjs +19 -2
- package/lws/persist.mjs +169 -0
- package/lws/server.mjs +82 -29
- package/package.json +2 -1
- package/solid/server/index.mjs +79 -13
- package/webdav/server.mjs +154 -25
- package/xmpp/client.mjs +332 -0
- package/xmpp/proxy.mjs +114 -0
package/webdav/server.mjs
CHANGED
|
@@ -15,11 +15,31 @@
|
|
|
15
15
|
// `webdavStep`, then carry what the engine answers: an `lws` request to
|
|
16
16
|
// the local storage it names (no socket: the storage handle's own step),
|
|
17
17
|
// a `fetch` to the URL it names, until it answers a `response`, which
|
|
18
|
-
// this file writes back
|
|
18
|
+
// this file writes back; when the response carries `write` (the state
|
|
19
|
+
// file `.webdav-state.json`: dead properties and locks), replace that file
|
|
20
|
+
// first (a new file in the same directory, mode 0600, then a rename);
|
|
21
|
+
// * with a users file (`--users`, https://github.com/danbri/factoidal/issues/1108),
|
|
22
|
+
// give its path to `webdavOpen` and the bytes the engine names; when the
|
|
23
|
+
// engine answers `need` (a login: one PBKDF2-HMAC-SHA256 derivation), run
|
|
24
|
+
// exactly that WebCrypto call (`answerCipherRequest`, lib/cipher-host.mjs)
|
|
25
|
+
// and give the answer to `webdavResume`; give each request's client
|
|
26
|
+
// address (the socket's `remoteAddress`) to `webdavStep`, for the
|
|
27
|
+
// failed-login limit the engine keeps
|
|
28
|
+
// (https://github.com/danbri/factoidal/issues/1119); pass the limit
|
|
29
|
+
// options unread and print the `warnings` of `ready`;
|
|
30
|
+
// * with `--cert` and `--key`, listen with Node `https` (TLS) instead of
|
|
31
|
+
// `http`;
|
|
32
|
+
// * with `--guest NAME` (https://github.com/danbri/factoidal/issues/1147),
|
|
33
|
+
// pass the name unread: the engine decides that a request with no
|
|
34
|
+
// credentials acts as that user, and which mounts each user reaches.
|
|
35
|
+
//
|
|
36
|
+
// Expert review of the login: https://github.com/danbri/factoidal/issues/1117
|
|
19
37
|
//
|
|
20
38
|
// It makes NO protocol decision. Which files are mounts, what a mount file
|
|
21
39
|
// says, every status code, header field, XML body, lock, property and path
|
|
22
|
-
// mapping
|
|
40
|
+
// mapping, every login decision (which users file lines count, whether a
|
|
41
|
+
// password matches, the login cache, whether Basic may run on this address)
|
|
42
|
+
// is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
|
|
23
43
|
// CLAUDE.md). The one status code written here is 500, for a failure of
|
|
24
44
|
// this host itself. Requests are served one at a time (a queue), because
|
|
25
45
|
// the engine keeps one request in flight per handle.
|
|
@@ -27,14 +47,17 @@
|
|
|
27
47
|
// THE WASM CONTRACT (formal/lean4/Wasm/Ops/WebDAV.lean)
|
|
28
48
|
// webdavOpen [configJson] -> { ok, handle, need }
|
|
29
49
|
// webdavLoad [handle, filesJson] -> { ok, need } | { ok, ready }
|
|
30
|
-
// webdavStep [handle, requestJson] + body -> { ok, response | lws | fetch } + body
|
|
31
|
-
// webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch } + body
|
|
50
|
+
// webdavStep [handle, requestJson] + body -> { ok, response [+ write] | lws | fetch | need } + body
|
|
51
|
+
// webdavResume [handle, answerJson] + body -> { ok, response | lws | fetch | need } + body
|
|
32
52
|
// webdavClose [handle] -> { ok }
|
|
33
53
|
|
|
34
|
-
import {
|
|
35
|
-
|
|
54
|
+
import {
|
|
55
|
+
readFileSync, readdirSync, writeFileSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync
|
|
56
|
+
} from 'node:fs'
|
|
57
|
+
import { resolve as resolvePath, dirname, basename, join } from 'node:path'
|
|
36
58
|
import { loadEngine } from '../bin/engine.mjs'
|
|
37
59
|
import { openLwsStorage } from '../lws/server.mjs'
|
|
60
|
+
import { answerCipherRequest } from '../lib/cipher-host.mjs'
|
|
38
61
|
|
|
39
62
|
/** The ops this host needs from the engine. */
|
|
40
63
|
export const WEBDAV_OPS = ['webdavOpen', 'webdavLoad', 'webdavStep', 'webdavResume', 'webdavClose']
|
|
@@ -107,12 +130,45 @@ function gather (configDir, need) {
|
|
|
107
130
|
return { files, exists }
|
|
108
131
|
}
|
|
109
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Replace a file the engine named (`write` on a response) atomically: write
|
|
135
|
+
* a new file in the same directory, flush it, then rename it over the old
|
|
136
|
+
* one, so a crash leaves the old text or the new text. Pure I/O.
|
|
137
|
+
*/
|
|
138
|
+
function replaceFile (configDir, write) {
|
|
139
|
+
const target = resolvePath(configDir, write.path)
|
|
140
|
+
const suffix = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(6))).toString('hex')
|
|
141
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${suffix}.tmp`)
|
|
142
|
+
const fd = openSync(temp, 'wx', parseInt(write.mode, 8))
|
|
143
|
+
try {
|
|
144
|
+
writeSync(fd, write.text)
|
|
145
|
+
fsyncSync(fd)
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(fd)
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
renameSync(temp, target)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
try { unlinkSync(temp) } catch (_error) { /* already gone */ }
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
110
157
|
/**
|
|
111
158
|
* Open a WebDAV server handle over the mount files of `configDir`.
|
|
112
159
|
*
|
|
113
160
|
* @param {object} options
|
|
114
161
|
* @param {string} options.configDir the directory of mount files
|
|
115
162
|
* @param {object} [options.engine] an engine handle to reuse
|
|
163
|
+
* @param {string} [options.users] the users file (an absolute path, or one
|
|
164
|
+
* relative to configDir): the listener then needs a login
|
|
165
|
+
* @param {boolean} [options.tls] the listener uses TLS
|
|
166
|
+
* @param {string} [options.host] the address the listener binds
|
|
167
|
+
* @param {string} [options.loginLimit] `--login-limit`, passed unread
|
|
168
|
+
* @param {string} [options.loginWindow] `--login-window`, passed unread
|
|
169
|
+
* @param {boolean} [options.disableRateLimits] `--dangerously-disable-ratelimits`
|
|
170
|
+
* @param {string} [options.guest] `--guest NAME`: the user a request with no
|
|
171
|
+
* credentials acts as (passed unread)
|
|
116
172
|
* @returns {Promise<{handle: string, mounts: object[], step: Function, close: Function}>}
|
|
117
173
|
*/
|
|
118
174
|
export async function openWebdav (options = {}) {
|
|
@@ -120,7 +176,22 @@ export async function openWebdav (options = {}) {
|
|
|
120
176
|
const configDir = options.configDir
|
|
121
177
|
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
122
178
|
const listing = readdirSync(configDir)
|
|
123
|
-
|
|
179
|
+
const login = {
|
|
180
|
+
users: typeof options.users === 'string' ? options.users : null,
|
|
181
|
+
tls: options.tls === true,
|
|
182
|
+
host: typeof options.host === 'string' ? options.host : '127.0.0.1',
|
|
183
|
+
limit: typeof options.loginLimit === 'string' ? options.loginLimit : null,
|
|
184
|
+
window: typeof options.loginWindow === 'string' ? options.loginWindow : null,
|
|
185
|
+
disableRateLimits: options.disableRateLimits === true
|
|
186
|
+
}
|
|
187
|
+
// The guest user (issue 1147), passed unread; the engine checks it.
|
|
188
|
+
if (typeof options.guest === 'string') login.guest = options.guest
|
|
189
|
+
const config = { listing, entropy: randomBase64(32), now: nowSeconds(), login }
|
|
190
|
+
// The Host and Origin guard of the HTTP listener (issue 1124): the bound
|
|
191
|
+
// address and the operator's names and origins, passed unread
|
|
192
|
+
// (L4Factoidal/HTTP/LocalGuard.lean decides).
|
|
193
|
+
if (options.localGuard !== undefined && options.localGuard !== null) config.localGuard = options.localGuard
|
|
194
|
+
let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify(config)])
|
|
124
195
|
if (envelope.ok === false) throw new WebdavHostError(envelope.error, { envelope })
|
|
125
196
|
const handle = envelope.handle
|
|
126
197
|
let loads = 0
|
|
@@ -137,10 +208,25 @@ export async function openWebdav (options = {}) {
|
|
|
137
208
|
}
|
|
138
209
|
// Each local storage, opened as lws-serve opens it; the config is the
|
|
139
210
|
// engine's, passed unread.
|
|
211
|
+
// Each takes the lock of its directory (one writing process per storage
|
|
212
|
+
// directory, https://github.com/danbri/factoidal/issues/1135): a directory
|
|
213
|
+
// that lws-serve or another webdav-serve holds stops the start, and the
|
|
214
|
+
// locks of the mounts opened before it are released.
|
|
140
215
|
const storages = new Map()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
216
|
+
const storageNotices = []
|
|
217
|
+
try {
|
|
218
|
+
for (const l of ready.local || []) {
|
|
219
|
+
const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true, lockCommand: 'webdav-serve' })
|
|
220
|
+
storages.set(l.name, storage)
|
|
221
|
+
for (const n of storage.notices || []) storageNotices.push(`mount ${l.name}: ${n}`)
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
for (const s of storages.values()) s.close()
|
|
225
|
+
try { engine.call('webdavClose', [handle]) } catch (_error) { /* already gone */ }
|
|
226
|
+
if (error !== null && typeof error === 'object' && error.envelope !== undefined && !(error instanceof WebdavHostError)) {
|
|
227
|
+
throw new WebdavHostError(error.envelope.error, { envelope: error.envelope })
|
|
228
|
+
}
|
|
229
|
+
throw error
|
|
144
230
|
}
|
|
145
231
|
|
|
146
232
|
/** Carry one `fetch` the engine named. Pure I/O. */
|
|
@@ -168,12 +254,14 @@ export async function openWebdav (options = {}) {
|
|
|
168
254
|
target: request.target,
|
|
169
255
|
headers: request.headers,
|
|
170
256
|
now: nowSeconds(),
|
|
171
|
-
randomBase64: randomBase64(16)
|
|
257
|
+
randomBase64: randomBase64(16),
|
|
258
|
+
remoteAddress: typeof request.remoteAddress === 'string' ? request.remoteAddress : ''
|
|
172
259
|
}
|
|
173
260
|
let got = blobCall(engine, 'webdavStep', [handle, JSON.stringify(record)], request.body || new Uint8Array(0))
|
|
174
261
|
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
175
262
|
const env = got.envelope
|
|
176
263
|
if (env.response !== undefined) {
|
|
264
|
+
if (env.write !== undefined) replaceFile(configDir, env.write)
|
|
177
265
|
return { status: env.response.status, headers: env.response.headers || [], body: got.bytes }
|
|
178
266
|
}
|
|
179
267
|
let answer
|
|
@@ -189,8 +277,12 @@ export async function openWebdav (options = {}) {
|
|
|
189
277
|
const x = await exchange(env.fetch, got.bytes)
|
|
190
278
|
answer = x.answer
|
|
191
279
|
bytes = x.bytes
|
|
280
|
+
} else if (env.need !== undefined) {
|
|
281
|
+
// A login: the one WebCrypto derivation the engine named.
|
|
282
|
+
answer = await answerCipherRequest(env.need)
|
|
283
|
+
bytes = new Uint8Array(0)
|
|
192
284
|
} else {
|
|
193
|
-
throw new WebdavHostError('the engine answered neither response, lws nor
|
|
285
|
+
throw new WebdavHostError('the engine answered neither response, lws, fetch nor need')
|
|
194
286
|
}
|
|
195
287
|
got = blobCall(engine, 'webdavResume', [handle, JSON.stringify(answer)], bytes)
|
|
196
288
|
}
|
|
@@ -201,9 +293,13 @@ export async function openWebdav (options = {}) {
|
|
|
201
293
|
handle,
|
|
202
294
|
engine,
|
|
203
295
|
mounts: ready.mounts || [],
|
|
296
|
+
warnings: ready.warnings || [],
|
|
297
|
+
/** The start notices of the local mounts (a stale lock taken over,
|
|
298
|
+
* encryption at rest on or off), for standard error. */
|
|
299
|
+
storageNotices,
|
|
204
300
|
/**
|
|
205
301
|
* One request, served after the requests before it.
|
|
206
|
-
* @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array}} request
|
|
302
|
+
* @param {{method: string, target: string, headers: Array<[string, string]>, body: Uint8Array, remoteAddress?: string}} request
|
|
207
303
|
*/
|
|
208
304
|
step (request) {
|
|
209
305
|
const run = queue.then(() => serve(request))
|
|
@@ -217,6 +313,16 @@ export async function openWebdav (options = {}) {
|
|
|
217
313
|
}
|
|
218
314
|
}
|
|
219
315
|
|
|
316
|
+
/** The addresses of this machine's interfaces. Pure I/O. */
|
|
317
|
+
async function interfaceAddresses () {
|
|
318
|
+
try {
|
|
319
|
+
const os = await import('node:os')
|
|
320
|
+
return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
|
|
321
|
+
} catch (_error) {
|
|
322
|
+
return []
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
220
326
|
function readBody (request) {
|
|
221
327
|
return new Promise((resolve, reject) => {
|
|
222
328
|
const chunks = []
|
|
@@ -246,20 +352,36 @@ function writeHeaders (response, pairs) {
|
|
|
246
352
|
* Start the server: open the handle, then listen.
|
|
247
353
|
*
|
|
248
354
|
* @param {object} options `configDir`, `port` (default 8090), `host`
|
|
249
|
-
* (default 127.0.0.1), `engine`, `onError`
|
|
355
|
+
* (default 127.0.0.1), `engine`, `onError`, `users` (a users file),
|
|
356
|
+
* `cert` and `key` (PEM bytes: listen with TLS), `loginLimit`,
|
|
357
|
+
* `loginWindow`, `disableRateLimits` (the failed-login limit)
|
|
250
358
|
* @returns {Promise<{server: object, port: number, origin: string, mounts: object[], close: Function}>}
|
|
251
359
|
*/
|
|
252
360
|
export async function listen (options = {}) {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
361
|
+
const tls = options.cert !== undefined && options.key !== undefined
|
|
362
|
+
const http = tls ? await import('node:https') : await import('node:http')
|
|
363
|
+
const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
|
|
364
|
+
const localGuard = {
|
|
365
|
+
host,
|
|
366
|
+
addresses: await interfaceAddresses(),
|
|
367
|
+
...(Array.isArray(options.allowHosts) ? { allowHosts: options.allowHosts } : {}),
|
|
368
|
+
...(Array.isArray(options.allowOrigins) ? { allowOrigins: options.allowOrigins } : {})
|
|
369
|
+
}
|
|
370
|
+
const dav = await openWebdav({ ...options, tls, host, localGuard })
|
|
255
371
|
const onError = typeof options.onError === 'function'
|
|
256
372
|
? options.onError
|
|
257
373
|
: (error) => { console.error(`webdav-serve: ${error.message}`) }
|
|
258
|
-
const
|
|
374
|
+
const handler = async (request, response) => {
|
|
259
375
|
let answer
|
|
260
376
|
try {
|
|
261
377
|
const body = await readBody(request)
|
|
262
|
-
answer = await dav.step({
|
|
378
|
+
answer = await dav.step({
|
|
379
|
+
method: request.method,
|
|
380
|
+
target: request.url,
|
|
381
|
+
headers: headerPairs(request.rawHeaders),
|
|
382
|
+
body,
|
|
383
|
+
remoteAddress: request.socket.remoteAddress
|
|
384
|
+
})
|
|
263
385
|
} catch (error) {
|
|
264
386
|
onError(error)
|
|
265
387
|
response.writeHead(500, { 'content-type': 'text/plain' })
|
|
@@ -269,20 +391,27 @@ export async function listen (options = {}) {
|
|
|
269
391
|
writeHeaders(response, answer.headers)
|
|
270
392
|
response.statusCode = answer.status
|
|
271
393
|
response.end(answer.body)
|
|
272
|
-
}
|
|
394
|
+
}
|
|
395
|
+
const server = tls ? http.createServer({ cert: options.cert, key: options.key }, handler) : http.createServer(handler)
|
|
273
396
|
const port = typeof options.port === 'number' ? options.port : 8090
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
397
|
+
try {
|
|
398
|
+
await new Promise((resolve, reject) => {
|
|
399
|
+
server.once('error', reject)
|
|
400
|
+
server.listen(port, host, resolve)
|
|
401
|
+
})
|
|
402
|
+
} catch (error) {
|
|
403
|
+
dav.close()
|
|
404
|
+
throw error
|
|
405
|
+
}
|
|
279
406
|
const bound = server.address()
|
|
280
|
-
const origin =
|
|
407
|
+
const origin = `${tls ? 'https' : 'http'}://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
|
|
281
408
|
return {
|
|
282
409
|
server,
|
|
283
410
|
port: bound.port,
|
|
284
411
|
origin,
|
|
285
412
|
mounts: dav.mounts,
|
|
413
|
+
warnings: dav.warnings,
|
|
414
|
+
storageNotices: dav.storageNotices,
|
|
286
415
|
close () {
|
|
287
416
|
return new Promise((resolve) => {
|
|
288
417
|
server.close(() => { dav.close(); resolve() })
|
package/xmpp/client.mjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// factoidal/xmpp/client.mjs — an OUTGOING XMPP client connection that
|
|
2
|
+
// carries HTTP over XMPP (XEP-0332), in one of two modes.
|
|
3
|
+
// https://github.com/danbri/factoidal/issues/1107. Design record:
|
|
4
|
+
// docs/designissues/2026-10-28-http-over-xmpp.md.
|
|
5
|
+
//
|
|
6
|
+
// connectHttpTarget (step 1, `factoidal webdav-serve CONFIGDIR --xmpp JID`)
|
|
7
|
+
// serves XEP-0332 requests with a local `step` function
|
|
8
|
+
// connectHttpRequester (step 2, `factoidal xmpp-http-proxy`) sends XEP-0332
|
|
9
|
+
// requests to ONE target JID and gives back responses
|
|
10
|
+
//
|
|
11
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
12
|
+
// Open one TCP socket, or one direct-TLS socket (XEP-0368), to an XMPP
|
|
13
|
+
// server; give every text it reads to the engine (`xmppClientFeed`); write
|
|
14
|
+
// every text the engine answers (`send`); carry each HTTP request the engine
|
|
15
|
+
// names (`requests`) to the caller's `step` function and give the response
|
|
16
|
+
// back (`xmppClientAnswer`); in requester mode, give each HTTP request to the
|
|
17
|
+
// engine (`xmppClientRequest`), give it the clock (`xmppClientTick`) and
|
|
18
|
+
// resolve each response the engine names (`responses`); close the socket when
|
|
19
|
+
// the engine says `close`.
|
|
20
|
+
//
|
|
21
|
+
// It makes NO protocol decision. The stream negotiation, SASL SCRAM-SHA-256,
|
|
22
|
+
// the check of the server signature, resource binding, which stanza is an
|
|
23
|
+
// HTTP request or response, who may send one (the allow list), the target,
|
|
24
|
+
// the refusals and limits, timeouts, chunked bodies and every status are
|
|
25
|
+
// decided in formal/lean4/L4Factoidal/XMPP/ (ClientStream, HttpOverXmpp,
|
|
26
|
+
// HttpRequester) and formal/lean4/Wasm/Ops/XmppClient.lean (iron rule 7 of
|
|
27
|
+
// CLAUDE.md). The host supplies the SCRAM client nonce (random bytes) and the
|
|
28
|
+
// clock, and moves bytes.
|
|
29
|
+
//
|
|
30
|
+
// THE WASM CONTRACT (formal/lean4/Wasm/Ops/XmppClient.lean)
|
|
31
|
+
// xmppClientOpen [configJson] -> { ok, handle, send, ... }
|
|
32
|
+
// xmppClientFeed [handle, text] -> { ok, send, requests, responses, bound, close, reason, stage, bufferedBytes }
|
|
33
|
+
// xmppClientAnswer [handle, id, responseJson] -> { ok, send }
|
|
34
|
+
// xmppClientRequest [handle, nowMs, requestJson] -> { ok, id, send, responses }
|
|
35
|
+
// xmppClientTick [handle, nowMs] -> { ok, send, responses }
|
|
36
|
+
// xmppClientClose [handle] -> { ok, send, responses }
|
|
37
|
+
|
|
38
|
+
import { randomBytes } from 'node:crypto'
|
|
39
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
40
|
+
import { loadEngine } from '../bin/engine.mjs'
|
|
41
|
+
|
|
42
|
+
/** The ops this host needs from the engine. */
|
|
43
|
+
export const XMPP_CLIENT_OPS = ['xmppClientOpen', 'xmppClientFeed', 'xmppClientAnswer', 'xmppClientClose',
|
|
44
|
+
'xmppClientRequest', 'xmppClientTick']
|
|
45
|
+
|
|
46
|
+
/** An error of this host, as opposed to one the engine reported. */
|
|
47
|
+
export class XmppClientError extends Error {
|
|
48
|
+
constructor (message, detail = {}) {
|
|
49
|
+
super(message)
|
|
50
|
+
this.name = 'XmppClientError'
|
|
51
|
+
this.unknownOp = detail.unknownOp === true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function call (engine, op, args) {
|
|
56
|
+
let envelope
|
|
57
|
+
try {
|
|
58
|
+
envelope = engine.call(op, args)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const text = error && error.message ? String(error.message) : String(error)
|
|
61
|
+
if (text.indexOf('unknown op') >= 0) {
|
|
62
|
+
throw new XmppClientError(`the loaded l4factoidal WebAssembly module does not carry ${op}`, { unknownOp: true })
|
|
63
|
+
}
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
if (envelope === null || typeof envelope !== 'object') throw new XmppClientError(`${op} answered with no envelope`)
|
|
67
|
+
if (envelope.ok === false) throw new XmppClientError(`${op}: ${envelope.error}`)
|
|
68
|
+
return envelope
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The configuration fields both modes share; values go to the engine unread. */
|
|
72
|
+
function baseConfig (options) {
|
|
73
|
+
const config = {
|
|
74
|
+
jid: options.jid,
|
|
75
|
+
password: typeof options.password === 'string' ? options.password : '',
|
|
76
|
+
clientNonce: randomBytes(24).toString('base64url'),
|
|
77
|
+
tls: options.tls === true
|
|
78
|
+
}
|
|
79
|
+
if (typeof options.host === 'string') config.host = options.host
|
|
80
|
+
if (typeof options.resource === 'string') config.resource = options.resource
|
|
81
|
+
if (typeof options.minIterations === 'number') config.minIterations = options.minIterations
|
|
82
|
+
return config
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Open the engine handle and the socket; run the stream until it is bound.
|
|
87
|
+
* `onFeed(answer)` gets every `xmppClientFeed` envelope after the bytes it
|
|
88
|
+
* names are written. Pure byte moving.
|
|
89
|
+
*/
|
|
90
|
+
async function openStream (engine, config, options, onFeed, onClosed) {
|
|
91
|
+
const onError = typeof options.onError === 'function' ? options.onError : () => {}
|
|
92
|
+
const maxBuffered = typeof options.maxBuffered === 'number' ? options.maxBuffered : 16 * 1024 * 1024
|
|
93
|
+
const opened = call(engine, 'xmppClientOpen', [JSON.stringify(config)])
|
|
94
|
+
const handle = opened.handle
|
|
95
|
+
const domain = opened.domain
|
|
96
|
+
if (typeof domain !== 'string') throw new XmppClientError('xmppClientOpen answered with no domain; rebuild the engine')
|
|
97
|
+
const host = opened.host
|
|
98
|
+
const port = typeof options.port === 'number' ? options.port : (options.tls === true ? 5223 : 5222)
|
|
99
|
+
|
|
100
|
+
let socket
|
|
101
|
+
if (options.tls === true) {
|
|
102
|
+
const tls = await import('node:tls')
|
|
103
|
+
socket = tls.connect({ host, port, servername: domain, ...(options.tlsOptions || {}) })
|
|
104
|
+
} else {
|
|
105
|
+
const net = await import('node:net')
|
|
106
|
+
socket = net.connect({ host, port })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let open = true
|
|
110
|
+
let boundJid = null
|
|
111
|
+
let resolveBound
|
|
112
|
+
let rejectBound
|
|
113
|
+
const bound = new Promise((resolve, reject) => { resolveBound = resolve; rejectBound = reject })
|
|
114
|
+
let resolveClosed
|
|
115
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve })
|
|
116
|
+
|
|
117
|
+
function write (texts) {
|
|
118
|
+
for (const t of texts || []) if (!socket.destroyed) socket.write(t)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function finish (reason) {
|
|
122
|
+
if (!open) return
|
|
123
|
+
open = false
|
|
124
|
+
let closing = null
|
|
125
|
+
try { closing = call(engine, 'xmppClientClose', [handle]) } catch (_error) { /* already gone */ }
|
|
126
|
+
if (!socket.destroyed) socket.end()
|
|
127
|
+
if (boundJid === null) rejectBound(new XmppClientError(reason || 'the XMPP connection closed before binding'))
|
|
128
|
+
if (closing !== null && typeof onClosed === 'function') onClosed(closing)
|
|
129
|
+
resolveClosed(reason || '')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const decoder = new StringDecoder('utf8')
|
|
133
|
+
socket.on('connect', () => write(opened.send))
|
|
134
|
+
socket.on('secureConnect', () => {})
|
|
135
|
+
socket.on('data', (chunk) => {
|
|
136
|
+
if (!open) return
|
|
137
|
+
const text = decoder.write(chunk)
|
|
138
|
+
if (text.length === 0) return
|
|
139
|
+
let answer
|
|
140
|
+
try {
|
|
141
|
+
answer = call(engine, 'xmppClientFeed', [handle, text])
|
|
142
|
+
} catch (error) {
|
|
143
|
+
onError(error)
|
|
144
|
+
socket.destroy()
|
|
145
|
+
finish(error.message)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
write(answer.send)
|
|
149
|
+
if (typeof answer.bound === 'string' && boundJid === null) {
|
|
150
|
+
boundJid = answer.bound
|
|
151
|
+
resolveBound(boundJid)
|
|
152
|
+
}
|
|
153
|
+
onFeed(answer)
|
|
154
|
+
if (answer.close === true) finish(answer.reason)
|
|
155
|
+
else if (answer.bufferedBytes > maxBuffered) {
|
|
156
|
+
onError(new XmppClientError(`the server sent more than ${maxBuffered} bytes without completing a stanza`))
|
|
157
|
+
finish('buffer limit')
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
socket.on('error', (error) => { onError(error); finish(error.message) })
|
|
161
|
+
socket.on('close', () => finish('the server closed the connection'))
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
opened,
|
|
165
|
+
handle,
|
|
166
|
+
bound,
|
|
167
|
+
closed,
|
|
168
|
+
write,
|
|
169
|
+
isOpen: () => open,
|
|
170
|
+
close () {
|
|
171
|
+
if (open && !socket.destroyed) {
|
|
172
|
+
try {
|
|
173
|
+
const closing = call(engine, 'xmppClientClose', [handle])
|
|
174
|
+
write(closing.send)
|
|
175
|
+
if (typeof onClosed === 'function') onClosed(closing)
|
|
176
|
+
} catch (_error) { /* gone */ }
|
|
177
|
+
}
|
|
178
|
+
finish('')
|
|
179
|
+
return closed
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Connect to an XMPP server as `jid` and serve HTTP over XMPP with `step`.
|
|
186
|
+
* Resolves once the resource is bound (or rejects with the engine's reason).
|
|
187
|
+
*
|
|
188
|
+
* @param {object} options
|
|
189
|
+
* @param {string} options.jid the account, `user@domain` (a resource may follow `/`)
|
|
190
|
+
* @param {string} options.password
|
|
191
|
+
* @param {string} [options.host] the XMPP server's address (default: the JID's domain; no SRV lookup)
|
|
192
|
+
* @param {number} [options.port] default 5222, or 5223 with `tls`
|
|
193
|
+
* @param {boolean} [options.tls] direct TLS (XEP-0368); without it the engine allows plain TCP to a loopback address only
|
|
194
|
+
* @param {object} [options.tlsOptions] passed to `tls.connect` (for example `ca`)
|
|
195
|
+
* @param {string[]} [options.allow] bare JIDs that may send requests
|
|
196
|
+
* @param {string} [options.resource]
|
|
197
|
+
* @param {object[]} [options.mounts] the WebDAV handle's mounts, passed unread (the engine refuses writable local mounts without the opt-in)
|
|
198
|
+
* @param {boolean} [options.dangerouslyAllowWritableWithoutLogin]
|
|
199
|
+
* @param {Function} options.step `async ({method, target, headers, body}) => ({status, headers, body})`
|
|
200
|
+
* @param {object} [options.engine]
|
|
201
|
+
* @param {Function} [options.onError]
|
|
202
|
+
* @param {number} [options.maxBuffered] residual bytes the engine may hold before the socket is closed (default 16 MiB)
|
|
203
|
+
* @returns {Promise<{jid: string, closed: Promise<string>, close: Function}>}
|
|
204
|
+
*/
|
|
205
|
+
export async function connectHttpTarget (options = {}) {
|
|
206
|
+
if (typeof options.step !== 'function') throw new XmppClientError('connectHttpTarget needs step')
|
|
207
|
+
if (typeof options.jid !== 'string') throw new XmppClientError('connectHttpTarget needs jid')
|
|
208
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
209
|
+
const onError = typeof options.onError === 'function' ? options.onError : () => {}
|
|
210
|
+
const config = baseConfig(options)
|
|
211
|
+
config.allow = Array.isArray(options.allow) ? options.allow : []
|
|
212
|
+
if (Array.isArray(options.mounts)) config.mounts = options.mounts
|
|
213
|
+
if (options.dangerouslyAllowWritableWithoutLogin === true) config.dangerouslyAllowWritableWithoutLogin = true
|
|
214
|
+
|
|
215
|
+
let stream = null
|
|
216
|
+
/** One request the engine named: carried to `step`, answered. */
|
|
217
|
+
async function carry (request) {
|
|
218
|
+
let answer
|
|
219
|
+
try {
|
|
220
|
+
const response = await options.step({
|
|
221
|
+
method: request.method,
|
|
222
|
+
target: request.target,
|
|
223
|
+
headers: request.headers || [],
|
|
224
|
+
body: new Uint8Array(Buffer.from(request.bodyBase64 || '', 'base64'))
|
|
225
|
+
})
|
|
226
|
+
answer = {
|
|
227
|
+
status: response.status,
|
|
228
|
+
headers: response.headers || [],
|
|
229
|
+
bodyBase64: Buffer.from(response.body || new Uint8Array(0)).toString('base64')
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
onError(error)
|
|
233
|
+
answer = { status: 500, headers: [['content-type', 'text/plain']], bodyBase64: Buffer.from(`host error: ${error.message}\n`).toString('base64') }
|
|
234
|
+
}
|
|
235
|
+
if (stream === null || !stream.isOpen()) return
|
|
236
|
+
try {
|
|
237
|
+
stream.write(call(engine, 'xmppClientAnswer', [stream.handle, String(request.id), JSON.stringify(answer)]).send)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
onError(error)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
stream = await openStream(engine, config, options, (answer) => {
|
|
243
|
+
for (const r of answer.requests || []) carry(r)
|
|
244
|
+
})
|
|
245
|
+
const jid = await stream.bound
|
|
246
|
+
return {
|
|
247
|
+
jid,
|
|
248
|
+
writableWithoutLogin: stream.opened.writableWithoutLogin || [],
|
|
249
|
+
closed: stream.closed,
|
|
250
|
+
close: () => stream.close()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Connect to an XMPP server as `jid` and send HTTP requests over XMPP to
|
|
256
|
+
* `target` (a full JID) and to no other entity. Resolves once the resource is
|
|
257
|
+
* bound. `request()` resolves with the response the engine names: from the
|
|
258
|
+
* target, or one the engine made (a refusal, 502, 504).
|
|
259
|
+
*
|
|
260
|
+
* @param {object} options the connection options of `connectHttpTarget`, without `allow`, `mounts` and `step`
|
|
261
|
+
* @param {string} options.target the full JID of the serving side
|
|
262
|
+
* @param {string} options.listenHost the address the caller will listen on (the engine accepts 127.0.0.1 only)
|
|
263
|
+
* @param {number} [options.timeoutSeconds] how long a request waits (the engine accepts 1 to 3600; default 60)
|
|
264
|
+
* @param {number} [options.tickMs] how often the clock is given to the engine (default 500)
|
|
265
|
+
* @returns {Promise<{jid: string, target: string, listenHost: string, maxBodyBytes: number,
|
|
266
|
+
* request: Function, closed: Promise<string>, close: Function}>}
|
|
267
|
+
*/
|
|
268
|
+
export async function connectHttpRequester (options = {}) {
|
|
269
|
+
if (typeof options.jid !== 'string') throw new XmppClientError('connectHttpRequester needs jid')
|
|
270
|
+
if (typeof options.target !== 'string') throw new XmppClientError('connectHttpRequester needs target')
|
|
271
|
+
const engine = options.engine !== undefined ? options.engine : await loadEngine()
|
|
272
|
+
const config = baseConfig(options)
|
|
273
|
+
config.proxyTarget = options.target
|
|
274
|
+
config.listenHost = typeof options.listenHost === 'string' ? options.listenHost : ''
|
|
275
|
+
if (typeof options.timeoutSeconds === 'number') config.timeoutSeconds = options.timeoutSeconds
|
|
276
|
+
|
|
277
|
+
const waiting = new Map()
|
|
278
|
+
function deliver (responses) {
|
|
279
|
+
for (const r of responses || []) {
|
|
280
|
+
const resolve = waiting.get(r.id)
|
|
281
|
+
if (resolve === undefined) continue
|
|
282
|
+
waiting.delete(r.id)
|
|
283
|
+
resolve({
|
|
284
|
+
status: r.status,
|
|
285
|
+
headers: r.headers || [],
|
|
286
|
+
body: new Uint8Array(Buffer.from(r.bodyBase64 || '', 'base64'))
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const stream = await openStream(engine, config, options,
|
|
291
|
+
(answer) => deliver(answer.responses),
|
|
292
|
+
(closing) => deliver(closing.responses))
|
|
293
|
+
const jid = await stream.bound
|
|
294
|
+
const timer = setInterval(() => {
|
|
295
|
+
if (!stream.isOpen()) return
|
|
296
|
+
try {
|
|
297
|
+
const t = call(engine, 'xmppClientTick', [stream.handle, String(Date.now())])
|
|
298
|
+
stream.write(t.send)
|
|
299
|
+
deliver(t.responses)
|
|
300
|
+
} catch (_error) { /* handle gone */ }
|
|
301
|
+
}, typeof options.tickMs === 'number' ? options.tickMs : 500)
|
|
302
|
+
stream.closed.then(() => clearInterval(timer))
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
jid,
|
|
306
|
+
target: stream.opened.target,
|
|
307
|
+
listenHost: stream.opened.listenHost,
|
|
308
|
+
maxBodyBytes: Number(stream.opened.maxBodyBytes),
|
|
309
|
+
closed: stream.closed,
|
|
310
|
+
/** `{method, target, headers: [[n, v]...], body: Uint8Array}` -> `{status, headers, body}` */
|
|
311
|
+
request (req) {
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
let answer
|
|
314
|
+
try {
|
|
315
|
+
answer = call(engine, 'xmppClientRequest', [stream.handle, String(Date.now()), JSON.stringify({
|
|
316
|
+
method: req.method,
|
|
317
|
+
target: req.target,
|
|
318
|
+
headers: req.headers || [],
|
|
319
|
+
bodyBase64: Buffer.from(req.body || new Uint8Array(0)).toString('base64')
|
|
320
|
+
})])
|
|
321
|
+
} catch (error) {
|
|
322
|
+
reject(error)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
waiting.set(answer.id, resolve)
|
|
326
|
+
stream.write(answer.send)
|
|
327
|
+
deliver(answer.responses)
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
close: () => { clearInterval(timer); return stream.close() }
|
|
331
|
+
}
|
|
332
|
+
}
|