@factoidal/core 0.20.0 → 0.22.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/lws/server.mjs CHANGED
@@ -84,11 +84,11 @@
84
84
  // produced and this code keeps working unchanged.
85
85
 
86
86
  import { loadEngine } from '../bin/engine.mjs'
87
- import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies } from './persist.mjs'
87
+ import { readSnapshot, persistIfChanged, storageKeyText, storageModeText, loadFiles, persistFilesIfChanged, readStepBodies, acquireDirLock, atRestNotice } from './persist.mjs'
88
88
 
89
89
  /** The ops this host needs from the engine. */
90
90
  export const LWS_OPS = ['lwsOpen', 'lwsStep', 'lwsClose', 'lwsSnapshot', 'lwsFetched', 'lwsDelivered',
91
- 'lwsLoad', 'lwsPersist', 'lwsRead']
91
+ 'lwsLoad', 'lwsPersist', 'lwsRead', 'lwsDirLock', 'lwsAtRestNotice']
92
92
 
93
93
  /** How long this host waits for the answer to one delivery request before
94
94
  * it reports "no answer" (status 0) to the engine. */
@@ -254,8 +254,11 @@ function contentBytes (request) {
254
254
  * file) or "files" (one file per body and a journal, issue 918); passed unread
255
255
  * @param {boolean|string[]} [options.cors] CORS (issue 923): false for none, a list of
256
256
  * origins to answer; default: echo any valid request Origin. Passed unread
257
+ * @param {string} [options.lockCommand] the command named in the lock file of DIR
258
+ * (default "lws-serve"; https://github.com/danbri/factoidal/issues/1135)
257
259
  * @param {object} [options.engine] an engine handle to reuse
258
- * @returns {Promise<{step: Function, close: Function, handle: string, engine: object}>}
260
+ * @returns {Promise<{step: Function, close: Function, handle: string, engine: object,
261
+ * lockFile: string|null, notices: string[]}>}
259
262
  */
260
263
  export async function openLwsStorage (options = {}) {
261
264
  const engine = options.engine !== undefined ? options.engine : await loadEngine()
@@ -315,8 +318,14 @@ export async function openLwsStorage (options = {}) {
315
318
  if (typeof options.controllerInbox === 'string') config.controllerInbox = options.controllerInbox
316
319
  // CORS (https://github.com/danbri/factoidal/issues/923): `cors` is false or
317
320
  // a list of origins, passed unread; absent, the engine echoes the request
318
- // Origin. L4Factoidal/LWS/Cors.lean decides every CORS field.
321
+ // Origin under authorization and sends no CORS field with insecureNoAuth
322
+ // (issue 1124). L4Factoidal/LWS/Cors.lean decides every CORS field.
319
323
  if (options.cors !== undefined) config.cors = options.cors
324
+ // The Host and Origin guard of a storage with insecureNoAuth
325
+ // (https://github.com/danbri/factoidal/issues/1124): extra host names and
326
+ // origins, passed unread; L4Factoidal/HTTP/LocalGuard.lean decides.
327
+ if (Array.isArray(options.allowHosts)) config.allowHosts = options.allowHosts
328
+ if (Array.isArray(options.allowOrigins)) config.allowOrigins = options.allowOrigins
320
329
  // What this host bound: data for the engine's inbox check (issue 915).
321
330
  if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
322
331
  config.entropy = entropyBase64()
@@ -335,36 +344,69 @@ export async function openLwsStorage (options = {}) {
335
344
  const dir = typeof options.dir === 'string' ? options.dir : null
336
345
  const storage = dir !== null ? storageModeText(options.storage) : options.storage
337
346
  if (typeof storage === 'string') config.storage = storage
338
- // `dir` names the storage directory. The snapshot found there, if any,
339
- // is the storage to continue from; the engine checks it.
340
- if (dir !== null && storage !== 'files') {
341
- const snapshot = readSnapshot(dir)
342
- if (snapshot !== null) config.snapshot = snapshot
347
+ // One writing process per storage directory
348
+ // (https://github.com/danbri/factoidal/issues/1135): the lock is taken
349
+ // before anything in DIR is read, and released by `close` and at process
350
+ // exit. The engine decides the file, its contents and every refusal
351
+ // (L4Factoidal/LWS/DirLock.lean); a refusal is thrown with its message.
352
+ let lock = null
353
+ if (dir !== null) {
354
+ try {
355
+ lock = acquireDirLock(engine, dir, typeof options.lockCommand === 'string' ? options.lockCommand : 'lws-serve')
356
+ } catch (error) {
357
+ if (isUnknownOp(error)) {
358
+ throw new LwsHostError(
359
+ 'the loaded l4factoidal WebAssembly module does not carry lwsDirLock',
360
+ { unknownOp: true })
361
+ }
362
+ throw error
363
+ }
343
364
  }
344
365
  let opened
366
+ let handle
367
+ let filesMode
368
+ let notices = []
345
369
  try {
346
- opened = engine.call('lwsOpen', [JSON.stringify(config)])
347
- } catch (error) {
348
- if (isUnknownOp(error)) {
349
- throw new LwsHostError(
350
- 'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
351
- { unknownOp: true })
370
+ // `dir` names the storage directory. The snapshot found there, if any,
371
+ // is the storage to continue from; the engine checks it.
372
+ if (dir !== null && storage !== 'files') {
373
+ const snapshot = readSnapshot(dir)
374
+ if (snapshot !== null) config.snapshot = snapshot
352
375
  }
376
+ try {
377
+ opened = engine.call('lwsOpen', [JSON.stringify(config)])
378
+ } catch (error) {
379
+ if (isUnknownOp(error)) {
380
+ throw new LwsHostError(
381
+ 'the loaded l4factoidal WebAssembly module does not carry lwsOpen',
382
+ { unknownOp: true })
383
+ }
384
+ throw error
385
+ }
386
+ handle = handleOf(opened)
387
+ if (handle === null) {
388
+ throw new LwsHostError('lwsOpen answered with no handle')
389
+ }
390
+ // A storage kept in files: read the files the engine names until it is
391
+ // ready, and perform the plan of the start. Without a DIR, there are no
392
+ // files: the engine is told none is present.
393
+ filesMode = opened !== null && typeof opened === 'object' &&
394
+ opened.load !== undefined && opened.load !== null
395
+ if (filesMode) {
396
+ if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
397
+ loadFiles(engine, handle, opened, dir)
398
+ }
399
+ // What a server prints at start, after the experimental code notice:
400
+ // a take-over of a stale lock, and whether the storage on disk is
401
+ // sealed (encryption at rest; the engine words both).
402
+ if (dir !== null) {
403
+ if (lock.warning !== null) notices.push(`WARNING: ${lock.warning}`)
404
+ notices.push(atRestNotice(engine, dir, storageKey).notice)
405
+ }
406
+ } catch (error) {
407
+ if (lock !== null) lock.release()
353
408
  throw error
354
409
  }
355
- const handle = handleOf(opened)
356
- if (handle === null) {
357
- throw new LwsHostError('lwsOpen answered with no handle')
358
- }
359
- // A storage kept in files: read the files the engine names until it is
360
- // ready, and perform the plan of the start. Without a DIR, there are no
361
- // files: the engine is told none is present.
362
- const filesMode = opened !== null && typeof opened === 'object' &&
363
- opened.load !== undefined && opened.load !== null
364
- if (filesMode) {
365
- if (dir === null) throw new LwsHostError('a storage kept in files needs a storage directory (dir)')
366
- loadFiles(engine, handle, opened, dir)
367
- }
368
410
  /** After a call whose envelope may say `changed`: write what the engine
369
411
  * gives (the snapshot, or the plan of a storage kept in files). */
370
412
  const persist = (envelope) => {
@@ -448,6 +490,10 @@ export async function openLwsStorage (options = {}) {
448
490
  return {
449
491
  engine,
450
492
  handle,
493
+ /** The lock file of the storage directory, or null with no DIR. */
494
+ lockFile: lock !== null ? lock.file : null,
495
+ /** The start notices, for the server to print on standard error. */
496
+ notices,
451
497
  /**
452
498
  * One protocol step.
453
499
  * @param {{method: string, target: string,
@@ -538,6 +584,7 @@ export async function openLwsStorage (options = {}) {
538
584
  closed = true
539
585
  if (flushTimer !== null) clearTimeout(flushTimer)
540
586
  try { engine.call('lwsClose', [handle]) } catch (_error) { /* already gone */ }
587
+ if (lock !== null) lock.release()
541
588
  }
542
589
  }
543
590
  }
@@ -593,6 +640,12 @@ export async function createLwsServer (options = {}) {
593
640
  * (L4Factoidal/Authz/Mount.lean). */
594
641
  function storage () {
595
642
  if (opening === null) {
643
+ // What the socket bound, when the caller listened itself and did not
644
+ // say (data for the engine's Host check, issue 1124).
645
+ const bound = server.address()
646
+ if (settings.listen === undefined && bound !== null && typeof bound === 'object') {
647
+ settings = { ...settings, listen: { address: bound.address, port: bound.port, addresses: [] } }
648
+ }
596
649
  opening = (async () => {
597
650
  let storageSettings = settings
598
651
  if (settings.authz !== undefined && settings.authz !== null) {
@@ -720,7 +773,7 @@ export async function listen (options = {}) {
720
773
  const bound = created.server.address()
721
774
  const origin = `http://${host}:${bound.port}`
722
775
  if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
723
- created.setListen({ address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
776
+ created.setListen({ host, address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
724
777
  try {
725
778
  await created.storage()
726
779
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "EXPERIMENTAL UNREVIEWED 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",
@@ -70,6 +70,7 @@
70
70
  "./store-host/paths": "./store-host/paths.mjs",
71
71
  "./xmpp": "./xmpp/index.mjs",
72
72
  "./xmpp/server": "./xmpp/server.mjs",
73
+ "./cipher-host": "./lib/cipher-host.mjs",
73
74
  "./store": {
74
75
  "default": "./bin/store.mjs"
75
76
  },
@@ -55,8 +55,9 @@
55
55
  // loader, by the design record's rule that the two conformance classes
56
56
  // stay distinct.
57
57
 
58
+ import { existsSync, readdirSync } from 'node:fs'
58
59
  import { loadEngine } from '../../bin/engine.mjs'
59
- import { readSnapshot, persistIfChanged, storageKeyText } from '../../lws/persist.mjs'
60
+ import { readSnapshot, persistIfChanged, storageKeyText, acquireDirLock, atRestNotice } from '../../lws/persist.mjs'
60
61
 
61
62
  /** The ops this host needs from the engine. */
62
63
  export const SOLID_SERVER_OPS = ['solidOpen', 'solidStep', 'solidClose', 'solidSnapshot',
@@ -187,6 +188,11 @@ export async function openSolidStorage (options = {}) {
187
188
  if (options.auth !== undefined && options.auth !== null) config.auth = options.auth
188
189
  if (options.insecureNoAuth === true) config.insecureNoAuth = true
189
190
  if (typeof options.enforceWac === 'boolean') config.enforceWac = options.enforceWac
191
+ // The Host and Origin guard with insecureNoAuth (issue 1124): what the
192
+ // socket bound and the operator's names and origins, passed unread.
193
+ if (options.listen !== undefined && options.listen !== null) config.listen = options.listen
194
+ if (Array.isArray(options.allowHosts)) config.allowHosts = options.allowHosts
195
+ if (Array.isArray(options.allowOrigins)) config.allowOrigins = options.allowOrigins
190
196
  if (typeof options.rootAcl === 'string') config.rootAcl = options.rootAcl
191
197
  // Encryption at rest (issue 921), as the LWS host: the key text from
192
198
  // `storageKey` or FACTOIDAL_LWS_KEY, passed unread; the engine decides.
@@ -195,28 +201,65 @@ export async function openSolidStorage (options = {}) {
195
201
  // `dir` names the storage directory. The snapshot found there, if any,
196
202
  // is the storage to continue from; the engine checks it.
197
203
  const dir = typeof options.dir === 'string' ? options.dir : null
204
+ // One writing process per storage directory
205
+ // (https://github.com/danbri/factoidal/issues/1135), as the LWS host: the
206
+ // engine decides; the lock is taken before DIR is read.
207
+ let lock = null
198
208
  if (dir !== null) {
199
- const snapshot = readSnapshot(dir)
200
- if (snapshot !== null) config.snapshot = snapshot
209
+ try {
210
+ lock = acquireDirLock(engine, dir, 'solid-serve')
211
+ } catch (error) {
212
+ if (isUnknownOp(error)) {
213
+ throw new SolidServerHostError(
214
+ 'the loaded l4factoidal WebAssembly module does not carry lwsDirLock',
215
+ { unknownOp: true })
216
+ }
217
+ throw error
218
+ }
201
219
  }
202
220
  let opened
221
+ let handle
222
+ const notices = []
203
223
  try {
204
- opened = engine.call('solidOpen', [JSON.stringify(config)])
205
- } catch (error) {
206
- if (isUnknownOp(error)) {
207
- throw new SolidServerHostError(
208
- 'the loaded l4factoidal WebAssembly module does not carry solidOpen',
209
- { unknownOp: true })
224
+ if (dir !== null) {
225
+ const snapshot = readSnapshot(dir)
226
+ if (snapshot !== null) config.snapshot = snapshot
227
+ // The names in DIR, unread: the engine refuses a storage that LWS
228
+ // keeps there (`solidRefusesLws`, Wasm/Ops/Solid.lean); only lws-serve
229
+ // serves an LWS storage.
230
+ config.dirFiles = existsSync(dir) ? readdirSync(dir) : []
231
+ }
232
+ try {
233
+ opened = engine.call('solidOpen', [JSON.stringify(config)])
234
+ } catch (error) {
235
+ if (isUnknownOp(error)) {
236
+ throw new SolidServerHostError(
237
+ 'the loaded l4factoidal WebAssembly module does not carry solidOpen',
238
+ { unknownOp: true })
239
+ }
240
+ throw error
241
+ }
242
+ handle = handleOf(opened)
243
+ if (handle === null) {
244
+ throw new SolidServerHostError('solidOpen answered with no handle')
210
245
  }
246
+ // The start notices: a take-over of a stale lock, and whether the
247
+ // storage on disk is sealed (encryption at rest).
248
+ if (dir !== null) {
249
+ if (lock.warning !== null) notices.push(`WARNING: ${lock.warning}`)
250
+ notices.push(atRestNotice(engine, dir, storageKey).notice)
251
+ }
252
+ } catch (error) {
253
+ if (lock !== null) lock.release()
211
254
  throw error
212
255
  }
213
- const handle = handleOf(opened)
214
- if (handle === null) {
215
- throw new SolidServerHostError('solidOpen answered with no handle')
216
- }
217
256
  return {
218
257
  engine,
219
258
  handle,
259
+ /** The lock file of the storage directory, or null with no DIR. */
260
+ lockFile: lock !== null ? lock.file : null,
261
+ /** The start notices, for the server to print on standard error. */
262
+ notices,
220
263
  storageIri: typeof opened.storage === 'string' ? opened.storage : null,
221
264
  /**
222
265
  * One protocol step.
@@ -258,6 +301,7 @@ export async function openSolidStorage (options = {}) {
258
301
  },
259
302
  close () {
260
303
  try { engine.call('solidClose', [handle]) } catch (_error) { /* already gone */ }
304
+ if (lock !== null) lock.release()
261
305
  }
262
306
  }
263
307
  }
@@ -302,6 +346,10 @@ export async function createSolidServer (options = {}) {
302
346
 
303
347
  function storage () {
304
348
  if (opening === null) {
349
+ const bound = server.address()
350
+ if (settings.listen === undefined && bound !== null && typeof bound === 'object') {
351
+ settings = { ...settings, listen: { address: bound.address, port: bound.port, addresses: [] } }
352
+ }
305
353
  opening = openSolidStorage(settings).then((value) => { opened = value; return value })
306
354
  }
307
355
  return opening
@@ -345,6 +393,13 @@ export async function createSolidServer (options = {}) {
345
393
  }
346
394
  settings = { ...settings, baseIri: value }
347
395
  },
396
+ /** Pass what the socket bound, before the first request (issue 1124). */
397
+ setListen (value) {
398
+ if (opening !== null) {
399
+ throw new SolidServerHostError('the storage is already open; set listen first')
400
+ }
401
+ settings = { ...settings, listen: value }
402
+ },
348
403
  close () {
349
404
  return new Promise((resolve) => {
350
405
  server.close(() => {
@@ -380,6 +435,16 @@ function writeHeaders (response, pairs) {
380
435
  * @returns {Promise<{server: object, storage: Function, port: number,
381
436
  * origin: string, close: Function}>}
382
437
  */
438
+ /** The addresses of this machine's interfaces. Pure I/O. */
439
+ async function interfaceAddresses () {
440
+ try {
441
+ const os = await import('node:os')
442
+ return Object.values(os.networkInterfaces()).flat().filter(Boolean).map((i) => i.address)
443
+ } catch (_error) {
444
+ return []
445
+ }
446
+ }
447
+
383
448
  export async function listen (options = {}) {
384
449
  const port = typeof options.port === 'number' ? options.port : 0
385
450
  const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
@@ -391,6 +456,7 @@ export async function listen (options = {}) {
391
456
  const bound = created.server.address()
392
457
  const origin = `http://${host}:${bound.port}`
393
458
  if (typeof options.baseIri !== 'string') created.setBaseIri(`${origin}/`)
459
+ created.setListen({ host, address: bound.address, port: bound.port, addresses: await interfaceAddresses() })
394
460
  try {
395
461
  await created.storage()
396
462
  } catch (error) {
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 is decided in formal/lean4/L4Factoidal/WebDAV/ (iron rule 7 of
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 { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'
35
- import { resolve as resolvePath } from 'node:path'
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
- let envelope = callEngine(engine, 'webdavOpen', [JSON.stringify({ listing, entropy: randomBase64(32) })])
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
- for (const l of ready.local || []) {
142
- const storage = await openLwsStorage({ engine, dir: resolvePath(configDir, l.dir), ...l.config, realClock: true })
143
- storages.set(l.name, storage)
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 fetch')
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 http = await import('node:http')
254
- const dav = await openWebdav(options)
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 server = http.createServer(async (request, response) => {
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({ method: request.method, target: request.url, headers: headerPairs(request.rawHeaders), body })
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
- const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
275
- await new Promise((resolve, reject) => {
276
- server.once('error', reject)
277
- server.listen(port, host, resolve)
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 = `http://${host.includes(':') ? `[${host}]` : host}:${bound.port}`
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() })