@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/bin/factoidal.mjs
CHANGED
|
@@ -97,7 +97,7 @@ const REVIEW_WARNING = 'EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CR
|
|
|
97
97
|
|
|
98
98
|
/** The commands that serve, speak a protocol, or handle keys and tokens. */
|
|
99
99
|
const WARNED_COMMANDS = new Set(['serve', 'lws-serve', 'solid-serve', 'solid-client', 'lws-client',
|
|
100
|
-
'authz-serve', 'authz-keys', 'xmpp-serve', 'webdav-serve'])
|
|
100
|
+
'authz-serve', 'authz-keys', 'xmpp-serve', 'webdav-serve', 'webdav-user', 'xmpp-http-proxy', 'lws-keygen'])
|
|
101
101
|
|
|
102
102
|
const USAGE = `factoidal - the Factoidal persisted store, from Node or Deno
|
|
103
103
|
|
|
@@ -116,6 +116,9 @@ commands:
|
|
|
116
116
|
Service Description over HTTP
|
|
117
117
|
lws-serve DIR serve Linked Web Storage 1.0 over HTTP
|
|
118
118
|
solid-serve DIR serve the Solid Protocol over HTTP
|
|
119
|
+
lws-keygen print a new storage key for encryption at rest
|
|
120
|
+
(FACTOIDAL_LWS_KEY of lws-serve, solid-serve and
|
|
121
|
+
webdav-serve local mounts)
|
|
119
122
|
solid-client OP URL run one Solid client operation
|
|
120
123
|
lws-client OP URL run one Linked Web Storage client operation
|
|
121
124
|
authz-serve DIR serve an OAuth 2.0 authorization server for LWS
|
|
@@ -124,6 +127,10 @@ commands:
|
|
|
124
127
|
xmpp-serve DIR serve XMPP (RFC 6120/6121) over TCP or direct TLS
|
|
125
128
|
webdav-serve CONFIGDIR serve local and remote LWS storages to file
|
|
126
129
|
browsers over WebDAV (RFC 4918)
|
|
130
|
+
webdav-user FILE NAME add or replace a user in a webdav-serve users
|
|
131
|
+
file (the password is read from standard input)
|
|
132
|
+
xmpp-http-proxy a local HTTP listener (127.0.0.1) whose requests
|
|
133
|
+
go over XMPP (XEP-0332) to one target JID
|
|
127
134
|
|
|
128
135
|
global options:
|
|
129
136
|
-h, --help print this text, or a command's own help
|
|
@@ -337,6 +344,24 @@ options:
|
|
|
337
344
|
--no-worker serve on this thread instead of a worker thread with a
|
|
338
345
|
raised call stack (large collections need the worker)
|
|
339
346
|
--quiet print the origin only
|
|
347
|
+
--allow-host NAME
|
|
348
|
+
also answer requests whose Host field names NAME (a reverse
|
|
349
|
+
proxy or a Tailscale name); give it once per name, with no
|
|
350
|
+
port. Without it the listener answers only localhost,
|
|
351
|
+
127.0.0.1, [::1], the --host address and the addresses of
|
|
352
|
+
its interfaces, and gives 421 Misdirected Request to any
|
|
353
|
+
other Host (DNS rebinding)
|
|
354
|
+
--allow-origin ORIGIN
|
|
355
|
+
also accept a request that changes state (any method but
|
|
356
|
+
GET, HEAD and OPTIONS) from this origin, written
|
|
357
|
+
scheme://host[:port]; give it once per origin. Without it
|
|
358
|
+
such a request with an Origin field of another origin gets
|
|
359
|
+
403 (a cross-site form POST)
|
|
360
|
+
https://github.com/danbri/factoidal/issues/1124
|
|
361
|
+
|
|
362
|
+
serve has no authentication: anyone who reaches the port can query and
|
|
363
|
+
update the store. The Host and Origin checks above stop a web page in the
|
|
364
|
+
user's browser from doing so (formal/lean4/L4Factoidal/HTTP/LocalGuard.lean).
|
|
340
365
|
|
|
341
366
|
An update, or a Graph Store PUT, POST or DELETE, is appended to the
|
|
342
367
|
activated generation's delta log as one batch before the response is sent,
|
|
@@ -364,7 +389,7 @@ usage: factoidal authz-serve DIR --resource IRI[,IRI...] [--issuer IRI]
|
|
|
364
389
|
[--token-lifetime SECONDS] [--rotate-after SECONDS]
|
|
365
390
|
[--allow-http] [--allow-private-fetch]
|
|
366
391
|
[--cors-origin ORIGIN]... [--no-cors]
|
|
367
|
-
[--saml-idp FILE]...
|
|
392
|
+
[--saml-idp FILE]... [--saml-decrypt-key FILE]...
|
|
368
393
|
|
|
369
394
|
An authorization server as a component (https://github.com/danbri/factoidal/issues/916),
|
|
370
395
|
for lws-serve and any resource server that reads RFC 8414 metadata:
|
|
@@ -382,8 +407,15 @@ JSON: {"entityId": "<the saml:Issuer value>", "jwk": <its RSA public JWK>}.
|
|
|
382
407
|
An assertion must carry one enveloped XML Signature (exclusive
|
|
383
408
|
canonicalization, rsa-sha256) that verifies under that key, an audience that
|
|
384
409
|
names the issuer of this server, and bearer subject confirmation; its NameID
|
|
385
|
-
is the subject and its Recipient the client.
|
|
386
|
-
|
|
410
|
+
is the subject and its Recipient the client. Without --saml-idp the token
|
|
411
|
+
type is refused and not advertised.
|
|
412
|
+
|
|
413
|
+
--saml-decrypt-key FILE (repeatable) is one private JWK (RSA, EC P-256, OKP
|
|
414
|
+
X25519 or oct) that decrypts saml:EncryptedAssertion and saml:EncryptedID
|
|
415
|
+
(XML Encryption 1.1: RSA-OAEP, ECDH-ES with AES Key Wrap, or AES Key Wrap;
|
|
416
|
+
content AES-GCM). AES runs through WebCrypto. The decrypted assertion must
|
|
417
|
+
be signed as above. Without it, encrypted assertions are refused
|
|
418
|
+
(https://github.com/danbri/factoidal/issues/1073).
|
|
387
419
|
|
|
388
420
|
--resource names the resource IRIs (for lws-serve: BASE.well-known/lws-storage)
|
|
389
421
|
tokens may be issued for; any other is refused with invalid_target.
|
|
@@ -454,6 +486,17 @@ new random nonce for every write, and authenticated when the storage is
|
|
|
454
486
|
opened. A wrong key, a changed or truncated file, a clear-text file with the
|
|
455
487
|
variable set, or a sealed file without it stops the start with a message.
|
|
456
488
|
It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
|
|
489
|
+
Make a key with \`factoidal lws-keygen\`; keep a copy, a lost key is lost data.
|
|
490
|
+
At start the server says on standard error whether the storage is sealed
|
|
491
|
+
(and the key id) or written in clear text.
|
|
492
|
+
|
|
493
|
+
One process writes DIR (https://github.com/danbri/factoidal/issues/1135):
|
|
494
|
+
the server holds the lock file DIR/lws.lock while it runs and removes it at
|
|
495
|
+
a clean exit. A start refuses a DIR whose lock names a process that runs
|
|
496
|
+
(another lws-serve, solid-serve or webdav-serve local mount). A lock left by
|
|
497
|
+
a process that does not run (after a crash) is taken over with a warning.
|
|
498
|
+
The engine decides the file, its contents and every refusal
|
|
499
|
+
(formal/lean4/L4Factoidal/LWS/DirLock.lean).
|
|
457
500
|
|
|
458
501
|
--port N the port to bind; 0 takes an ephemeral one (default 3000)
|
|
459
502
|
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
@@ -520,9 +563,13 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
|
|
|
520
563
|
--cors-origin ORIGIN
|
|
521
564
|
answer CORS (Fetch Standard) only for this origin, written
|
|
522
565
|
scheme://host[:port] (for example https://danbri.github.io);
|
|
523
|
-
give it once per origin. Default: echo
|
|
524
|
-
Origin, as the Solid Protocol section 8.1
|
|
525
|
-
servers.
|
|
566
|
+
give it once per origin. Default under authorization: echo
|
|
567
|
+
any valid request Origin, as the Solid Protocol section 8.1
|
|
568
|
+
requires of Solid servers. Default with --insecure-no-auth:
|
|
569
|
+
no CORS fields, because every request is served there and an
|
|
570
|
+
echo would let any web page read and write the storage
|
|
571
|
+
(https://github.com/danbri/factoidal/issues/1124). Never
|
|
572
|
+
Access-Control-Allow-Credentials: the token
|
|
526
573
|
travels in Authorization. A preflight (OPTIONS with
|
|
527
574
|
Access-Control-Request-Method) is answered 204 before any
|
|
528
575
|
authorization decision.
|
|
@@ -539,8 +586,8 @@ is required):
|
|
|
539
586
|
BASE.well-known/lws-token, the JWK Set at
|
|
540
587
|
BASE.well-known/lws-jwks, signing keys in DIR (as
|
|
541
588
|
authz-serve; --alg, --token-lifetime, --rotate-after,
|
|
542
|
-
--allow-http, --allow-private-fetch
|
|
543
|
-
there). The
|
|
589
|
+
--allow-http, --allow-private-fetch, --saml-idp and
|
|
590
|
+
--saml-decrypt-key as there). The
|
|
544
591
|
storage trusts it, and any --issuer too. Each request
|
|
545
592
|
is the authorization server's when the engine says so
|
|
546
593
|
(L4Factoidal/Authz/Mount.lean); every other is the
|
|
@@ -555,7 +602,25 @@ is required):
|
|
|
555
602
|
"audience":IRI?, "leeway":SECONDS?,
|
|
556
603
|
"refreshInterval":SECONDS?}
|
|
557
604
|
--insecure-no-auth development only: no authorization at all; every
|
|
558
|
-
request is served. Never the default.
|
|
605
|
+
request is served. Never the default. The storage
|
|
606
|
+
then answers only its own host names and refuses
|
|
607
|
+
writes from other origins (see --allow-host and
|
|
608
|
+
--allow-origin), and sends no CORS fields unless
|
|
609
|
+
--cors-origin names an origin.
|
|
610
|
+
--allow-host NAME with --insecure-no-auth:
|
|
611
|
+
also answer requests whose Host field names NAME (a reverse
|
|
612
|
+
proxy or a Tailscale name); give it once per name, with no
|
|
613
|
+
port. Without it the listener answers only localhost,
|
|
614
|
+
127.0.0.1, [::1], the --host address and the addresses of
|
|
615
|
+
its interfaces, and gives 421 Misdirected Request to any
|
|
616
|
+
other Host (DNS rebinding)
|
|
617
|
+
--allow-origin ORIGIN with --insecure-no-auth:
|
|
618
|
+
also accept a request that changes state (any method but
|
|
619
|
+
GET, HEAD and OPTIONS) from this origin, written
|
|
620
|
+
scheme://host[:port]; give it once per origin. Without it
|
|
621
|
+
such a request with an Origin field of another origin gets
|
|
622
|
+
403 (a cross-site form POST)
|
|
623
|
+
https://github.com/danbri/factoidal/issues/1124
|
|
559
624
|
--owner WEBID the storage owner: a new storage gets a root ACL
|
|
560
625
|
that gives this agent Read, Write and Control
|
|
561
626
|
--root-acl FILE a Turtle root ACL for a new storage instead
|
|
@@ -591,7 +656,9 @@ handle and written to DIR/lws-state.json after every request that
|
|
|
591
656
|
changed it (temporary file, fsync, rename), and read back on the next
|
|
592
657
|
start. The engine decides the snapshot format and refuses a snapshot
|
|
593
658
|
path with a dot segment or other traversal form
|
|
594
|
-
(formal/lean4/L4Factoidal/LWS/Snapshot.lean).
|
|
659
|
+
(formal/lean4/L4Factoidal/LWS/Snapshot.lean). A DIR that holds a Linked
|
|
660
|
+
Web Storage storage (written by lws-serve) is refused: only lws-serve
|
|
661
|
+
serves it, through LWS authorization (Wasm/Ops/Solid.lean, solidRefusesLws).
|
|
595
662
|
|
|
596
663
|
Encryption at rest (off by default): when the environment variable
|
|
597
664
|
FACTOIDAL_LWS_KEY holds a 32-byte key (64 hex digits, or 43 characters of
|
|
@@ -600,6 +667,17 @@ new random nonce for every write, and authenticated when the storage is
|
|
|
600
667
|
opened. A wrong key, a changed or truncated file, a clear-text file with the
|
|
601
668
|
variable set, or a sealed file without it stops the start with a message.
|
|
602
669
|
It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lean).
|
|
670
|
+
Make a key with \`factoidal lws-keygen\`; keep a copy, a lost key is lost data.
|
|
671
|
+
At start the server says on standard error whether the storage is sealed
|
|
672
|
+
(and the key id) or written in clear text.
|
|
673
|
+
|
|
674
|
+
One process writes DIR (https://github.com/danbri/factoidal/issues/1135):
|
|
675
|
+
the server holds the lock file DIR/lws.lock while it runs and removes it at
|
|
676
|
+
a clean exit. A start refuses a DIR whose lock names a process that runs
|
|
677
|
+
(another lws-serve, solid-serve or webdav-serve local mount). A lock left by
|
|
678
|
+
a process that does not run (after a crash) is taken over with a warning.
|
|
679
|
+
The engine decides the file, its contents and every refusal
|
|
680
|
+
(formal/lean4/L4Factoidal/LWS/DirLock.lean).
|
|
603
681
|
|
|
604
682
|
--port N the port to bind; 0 takes an ephemeral one (default 3000)
|
|
605
683
|
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
@@ -619,7 +697,22 @@ It protects the file on disk only (formal/lean4/L4Factoidal/LWS/SnapshotSeal.lea
|
|
|
619
697
|
"jwks":{...}?}], "leeway"?, "iatWindow"?,
|
|
620
698
|
"refreshInterval"?, "checkProfileIssuer"?}
|
|
621
699
|
--insecure-no-auth development only: no authentication; Web Access
|
|
622
|
-
Control is not enforced
|
|
700
|
+
Control is not enforced. The server then answers only its
|
|
701
|
+
own host names and refuses writes from other origins.
|
|
702
|
+
--allow-host NAME with --insecure-no-auth:
|
|
703
|
+
also answer requests whose Host field names NAME (a reverse
|
|
704
|
+
proxy or a Tailscale name); give it once per name, with no
|
|
705
|
+
port. Without it the listener answers only localhost,
|
|
706
|
+
127.0.0.1, [::1], the --host address and the addresses of
|
|
707
|
+
its interfaces, and gives 421 Misdirected Request to any
|
|
708
|
+
other Host (DNS rebinding)
|
|
709
|
+
--allow-origin ORIGIN with --insecure-no-auth:
|
|
710
|
+
also accept a request that changes state (any method but
|
|
711
|
+
GET, HEAD and OPTIONS) from this origin, written
|
|
712
|
+
scheme://host[:port]; give it once per origin. Without it
|
|
713
|
+
such a request with an Origin field of another origin gets
|
|
714
|
+
403 (a cross-site form POST)
|
|
715
|
+
https://github.com/danbri/factoidal/issues/1124
|
|
623
716
|
|
|
624
717
|
The server makes no protocol decision. Storage discovery, containment
|
|
625
718
|
triples, slash semantics, the Allow and Accept-* headers, the PUT/POST/
|
|
@@ -666,23 +759,96 @@ MUC/MIX/GC3 rooms.
|
|
|
666
759
|
Specifications: https://www.rfc-editor.org/rfc/rfc6120 and
|
|
667
760
|
https://www.rfc-editor.org/rfc/rfc6121`,
|
|
668
761
|
|
|
762
|
+
'xmpp-http-proxy': `factoidal xmpp-http-proxy - reach an HTTP server over XMPP (XEP-0332) from 127.0.0.1
|
|
763
|
+
|
|
764
|
+
usage: factoidal xmpp-http-proxy --xmpp JID --xmpp-password-file FILE
|
|
765
|
+
--target JID/RESOURCE [--port N] [--host 127.0.0.1]
|
|
766
|
+
[--xmpp-host H] [--xmpp-port P] [--xmpp-tls] [--xmpp-ca FILE]
|
|
767
|
+
[--xmpp-resource R] [--timeout SECONDS]
|
|
768
|
+
|
|
769
|
+
Connects OUT to an XMPP server as JID (SASL SCRAM-SHA-256), binds a resource,
|
|
770
|
+
and opens an HTTP listener on 127.0.0.1. Every HTTP request to that listener
|
|
771
|
+
is sent as a XEP-0332 request to --target, and the answer is written back.
|
|
772
|
+
So a file browser, rclone, curl or "factoidal lws-client" uses
|
|
773
|
+
http://127.0.0.1:PORT/ to reach "factoidal webdav-serve CONFIGDIR --xmpp"
|
|
774
|
+
behind NAT. JID must be in that server's --xmpp-allow list, or every
|
|
775
|
+
request gets 403.
|
|
776
|
+
|
|
777
|
+
--xmpp JID the account (user@domain[/resource])
|
|
778
|
+
--xmpp-password-file FILE the account's password (first line)
|
|
779
|
+
--target JID/RESOURCE the full JID of the server (as webdav-serve
|
|
780
|
+
--xmpp prints it). Requests go to this JID and
|
|
781
|
+
to no other: this is not an open proxy.
|
|
782
|
+
--port N the port to listen on (default 8091; 0 takes an
|
|
783
|
+
ephemeral one)
|
|
784
|
+
--host ADDRESS the address to listen on: 127.0.0.1 only. Any
|
|
785
|
+
other address is refused (exit 1).
|
|
786
|
+
--xmpp-host H the XMPP server's address (default: the JID's
|
|
787
|
+
domain; no DNS SRV lookup)
|
|
788
|
+
--xmpp-port P default 5222, or 5223 with --xmpp-tls
|
|
789
|
+
--xmpp-tls direct TLS (XEP-0368). Without it, only a
|
|
790
|
+
loopback --xmpp-host (127.0.0.0/8, ::1) is
|
|
791
|
+
accepted.
|
|
792
|
+
--xmpp-ca FILE PEM certificates to trust for --xmpp-tls
|
|
793
|
+
--xmpp-resource R the resource to bind (default proxy)
|
|
794
|
+
--timeout SECONDS how long a request waits for its answer
|
|
795
|
+
(default 60, 1 to 3600); then 504
|
|
796
|
+
|
|
797
|
+
It prints the listener's origin (http://127.0.0.1:PORT/) on standard output.
|
|
798
|
+
|
|
799
|
+
The listener has no login of its own. Every process on this computer that
|
|
800
|
+
can open a connection to 127.0.0.1 acts as JID, and a web page in a browser
|
|
801
|
+
on this computer can send requests to it (the Host and Origin check of
|
|
802
|
+
https://github.com/danbri/factoidal/issues/1124 is not in this command yet).
|
|
803
|
+
Authorization headers pass to the target unchanged; the proxy adds no
|
|
804
|
+
credential. Proxy-Authorization and the other hop-by-hop headers (RFC 9110
|
|
805
|
+
section 7.6.1) are not forwarded. A request body above 16 MiB gets 413, more
|
|
806
|
+
than 16 waiting requests 503, an absolute-form request target (a forward
|
|
807
|
+
proxy request) 400, CONNECT 405.
|
|
808
|
+
|
|
809
|
+
Every decision is in formal/lean4/L4Factoidal/XMPP/HttpRequester.lean and
|
|
810
|
+
formal/lean4/Wasm/Ops/XmppClient.lean. Design:
|
|
811
|
+
docs/designissues/2026-10-28-http-over-xmpp.md
|
|
812
|
+
(https://github.com/danbri/factoidal/issues/1107).`,
|
|
813
|
+
|
|
669
814
|
'webdav-serve': `factoidal webdav-serve - serve LWS storages to file browsers over WebDAV
|
|
670
815
|
|
|
671
816
|
usage: factoidal webdav-serve CONFIGDIR [--port N] [--host ADDRESS]
|
|
672
|
-
|
|
673
|
-
|
|
817
|
+
[--users FILE] [--guest NAME] [--cert FILE --key FILE]
|
|
818
|
+
[--allow-host NAME]... [--allow-origin ORIGIN]...
|
|
819
|
+
[--login-limit N] [--login-window SECONDS]
|
|
820
|
+
[--dangerously-disable-ratelimits]
|
|
821
|
+
factoidal webdav-serve CONFIGDIR --xmpp JID --xmpp-password-file FILE
|
|
822
|
+
--xmpp-allow JID[,JID...] [--guest NAME] [--xmpp-host H] [--xmpp-port P] [--xmpp-tls]
|
|
823
|
+
|
|
824
|
+
EMERGENCY MODE (https://github.com/danbri/factoidal/issues/1147): the server
|
|
825
|
+
is READ ONLY. Every write method (PUT, DELETE, MKCOL, COPY, MOVE, PROPPATCH,
|
|
826
|
+
LOCK, UNLOCK) answers 403 on every mount; "writable:" in a mount file is
|
|
827
|
+
ignored. Every request acts as one named user: a request with no credentials
|
|
828
|
+
acts as the --guest user (with no --guest it gets 401); a request with
|
|
829
|
+
credentials must log in through --users (with no --users it gets 401). Each
|
|
830
|
+
mount file names its users ("- users: alice, bob", required); any other user
|
|
831
|
+
gets 404 for that mount, and the root collection does not list it. Every
|
|
832
|
+
"kind: local" mount is refused at start: use kind: lws with an agent key.
|
|
833
|
+
"auth: bearer" is refused unless the mount file says
|
|
834
|
+
"allow-external-token: true".
|
|
835
|
+
|
|
836
|
+
Starts a WebDAV server (RFC 4918, class 1) that macOS Finder,
|
|
674
837
|
Windows Explorer, GNOME Files and KDE Dolphin can connect to. Its root
|
|
675
838
|
collection holds one folder per mount; a mount is one Markdown file
|
|
676
839
|
CONFIGDIR/NAME.md (README.md is not a mount):
|
|
677
840
|
|
|
678
841
|
# Display name
|
|
679
|
-
- kind: local
|
|
842
|
+
- kind: lws (local: refused, issue 1147)
|
|
843
|
+
- users: alice, bob (required: the users who may use it)
|
|
680
844
|
- path: ./alice-storage (local: an lws-serve storage directory)
|
|
681
845
|
- url: https://storage.example/alice/ (lws: the storage root)
|
|
682
846
|
- auth: none | did-key | bearer
|
|
683
847
|
- key-file: ./alice.jwk (did-key: a P-256 private JWK; made when absent)
|
|
684
848
|
- token-file: ./alice.token (bearer: an access token)
|
|
685
|
-
-
|
|
849
|
+
- allow-external-token: true (bearer: required; a credential from
|
|
850
|
+
outside this installation)
|
|
851
|
+
- writable: true | false (read, and ignored: read only, issue 1147)
|
|
686
852
|
- storage: snapshot | files (local: default what the directory holds)
|
|
687
853
|
- base: IRI (local: the storage root's IRI, default http://localhost/)
|
|
688
854
|
|
|
@@ -690,12 +856,89 @@ Only the first "# " heading and the first "- key: value" list are read; the
|
|
|
690
856
|
words in brackets above are notes, not part of a value. A malformed file
|
|
691
857
|
stops the start with its name and line number.
|
|
692
858
|
|
|
693
|
-
A local mount
|
|
694
|
-
|
|
695
|
-
|
|
859
|
+
A local mount opened the directory with no authorization, as the storage's
|
|
860
|
+
owner; it is refused at start until the server can tell that a storage has
|
|
861
|
+
no access control (https://github.com/danbri/factoidal/issues/1147). An lws
|
|
862
|
+
mount reaches the storage through the LWS client, with the mount's key or
|
|
863
|
+
token: the LWS storage decides each request with its own access control.
|
|
696
864
|
|
|
697
865
|
--port N the port to bind (default 8090; 0 takes an ephemeral one)
|
|
698
866
|
--host ADDRESS the address to bind (default 127.0.0.1)
|
|
867
|
+
--users FILE every request needs a user name and password (HTTP Basic,
|
|
868
|
+
RFC 7617) from FILE, made with "factoidal webdav-user";
|
|
869
|
+
without TLS only on a loopback address (127.0.0.1, ::1),
|
|
870
|
+
because Basic sends the password in clear text
|
|
871
|
+
--guest NAME a request with no credentials acts as the user NAME (list
|
|
872
|
+
it in the "users:" of the mounts it may read). NAME may
|
|
873
|
+
not be a user of --users. Without --guest such a request
|
|
874
|
+
gets 401 (https://github.com/danbri/factoidal/issues/1147)
|
|
875
|
+
--cert FILE a PEM certificate chain: serve HTTPS (TLS)
|
|
876
|
+
--key FILE the PEM private key of --cert
|
|
877
|
+
--login-limit N with --users: failed logins allowed per user name and per
|
|
878
|
+
client address in one window (default 5, 1 to 1000)
|
|
879
|
+
--login-window SECONDS
|
|
880
|
+
with --users: the window of --login-limit (default 60,
|
|
881
|
+
1 to 86400)
|
|
882
|
+
--dangerously-disable-ratelimits
|
|
883
|
+
with --users: no failed-login limit (a warning is printed
|
|
884
|
+
at start); any client can then try passwords without end
|
|
885
|
+
--allow-host NAME
|
|
886
|
+
also answer requests whose Host field names NAME (a reverse
|
|
887
|
+
proxy or a Tailscale name); give it once per name, with no
|
|
888
|
+
port. Without it the listener answers only localhost,
|
|
889
|
+
127.0.0.1, [::1], the --host address and the addresses of
|
|
890
|
+
its interfaces, and gives 421 Misdirected Request to any
|
|
891
|
+
other Host (DNS rebinding)
|
|
892
|
+
--allow-origin ORIGIN
|
|
893
|
+
also accept a request that changes state (any method but
|
|
894
|
+
GET, HEAD and OPTIONS) from this origin, written
|
|
895
|
+
scheme://host[:port]; give it once per origin. Without it
|
|
896
|
+
such a request with an Origin field of another origin gets
|
|
897
|
+
403 (a cross-site form POST)
|
|
898
|
+
https://github.com/danbri/factoidal/issues/1124
|
|
899
|
+
|
|
900
|
+
Without --users the listener has no login: only the --guest user is
|
|
901
|
+
served, and anyone who can reach the port reads the mounts that list it.
|
|
902
|
+
Keep it on 127.0.0.1, or give --users and --cert/--key. A users file holds PBKDF2-HMAC-SHA256 password hashes, one
|
|
903
|
+
user per line, NAME:pbkdf2-sha256:ITERATIONS:SALT_B64:HASH_B64; a malformed
|
|
904
|
+
line stops the start with the file name and line number. Expert review of the
|
|
905
|
+
login: https://github.com/danbri/factoidal/issues/1117
|
|
906
|
+
|
|
907
|
+
Failed logins are limited by default
|
|
908
|
+
(https://github.com/danbri/factoidal/issues/1119): after 5 failed logins in 60
|
|
909
|
+
seconds for one user name (known or not) or from one client address (an IPv6
|
|
910
|
+
client by its /64), a request with credentials gets 429 Too Many Requests
|
|
911
|
+
with Retry-After, and no password is checked, also the right one, until that
|
|
912
|
+
window ends. A request with no credentials is a 401 and does not count.
|
|
913
|
+
|
|
914
|
+
HTTP over XMPP (XEP-0332; https://github.com/danbri/factoidal/issues/1107):
|
|
915
|
+
with --xmpp the server opens NO port. It connects OUT to an XMPP server as
|
|
916
|
+
JID (SASL SCRAM-SHA-256), binds a resource, and answers XEP-0332 requests
|
|
917
|
+
from the bare JIDs in --xmpp-allow (others get "forbidden"). A server behind
|
|
918
|
+
NAT is reached this way with no inbound port.
|
|
919
|
+
|
|
920
|
+
--xmpp JID the account (user@domain[/resource])
|
|
921
|
+
--xmpp-password-file FILE the account's password (first line)
|
|
922
|
+
--xmpp-allow JID[,JID] bare JIDs that may send requests (required)
|
|
923
|
+
--xmpp-host H the XMPP server's address (default: the JID's
|
|
924
|
+
domain; no DNS SRV lookup)
|
|
925
|
+
--xmpp-port P default 5222, or 5223 with --xmpp-tls
|
|
926
|
+
--xmpp-tls direct TLS (XEP-0368); STARTTLS is not
|
|
927
|
+
implemented. Without it, only a loopback
|
|
928
|
+
--xmpp-host (127.0.0.0/8, ::1) is accepted.
|
|
929
|
+
--xmpp-ca FILE PEM certificates to trust for --xmpp-tls
|
|
930
|
+
--dangerously-allow-writable-without-login
|
|
931
|
+
start although a local mount is writable:
|
|
932
|
+
there is no login on the XMPP path yet
|
|
933
|
+
(https://github.com/danbri/factoidal/issues/1125),
|
|
934
|
+
so every allowed JID writes as the owner.
|
|
935
|
+
Without it such a start is refused.
|
|
936
|
+
--xmpp-resource R the resource to bind (default dav)
|
|
937
|
+
|
|
938
|
+
It prints the bound JID (xmpp:user@domain/resource) on standard output.
|
|
939
|
+
XEP-0332 is Deferred, and no WebDAV client speaks it: a reader runs
|
|
940
|
+
"factoidal xmpp-http-proxy --target JID/RESOURCE" (as an allowed JID) and
|
|
941
|
+
points the file browser at the http://127.0.0.1:PORT/ that it prints.
|
|
699
942
|
|
|
700
943
|
Connect: macOS Finder, Go > Connect to Server, http://127.0.0.1:8090/ ;
|
|
701
944
|
Windows, Map network drive, http://127.0.0.1:8090/ ; GNOME Files,
|
|
@@ -707,6 +950,47 @@ module built before the WebDAV operations landed makes this command exit 3
|
|
|
707
950
|
and say so. Design: docs/designissues/2026-10-27-webdav.md
|
|
708
951
|
(https://github.com/danbri/factoidal/issues/1066).`,
|
|
709
952
|
|
|
953
|
+
'lws-keygen': `factoidal lws-keygen - print a new storage key for encryption at rest
|
|
954
|
+
|
|
955
|
+
usage: factoidal lws-keygen [--json]
|
|
956
|
+
|
|
957
|
+
Prints a new 32-byte storage key as 64 hex digits on standard output, and its
|
|
958
|
+
key id (the first 8 bytes of SHA-256 of the key) on standard error. Set the
|
|
959
|
+
environment variable FACTOIDAL_LWS_KEY to it before lws-serve DIR,
|
|
960
|
+
solid-serve DIR or webdav-serve with a local mount, on an empty storage
|
|
961
|
+
directory:
|
|
962
|
+
|
|
963
|
+
export FACTOIDAL_LWS_KEY="$(factoidal lws-keygen)"
|
|
964
|
+
|
|
965
|
+
Keep a copy of the key in a safe place. Without the key the storage cannot be
|
|
966
|
+
read: there is no recovery. Changing the key of an existing storage is not
|
|
967
|
+
possible (https://github.com/danbri/factoidal/issues/1136).
|
|
968
|
+
--json prints {"key": ..., "keyId": ...}.
|
|
969
|
+
|
|
970
|
+
The random bytes come from the host (WebCrypto getRandomValues); the engine
|
|
971
|
+
makes the key text and the key id (formal/lean4/L4Factoidal/LWS/DirLock.lean,
|
|
972
|
+
newStorageKey).`,
|
|
973
|
+
|
|
974
|
+
'webdav-user': `factoidal webdav-user - add or replace a user in a webdav-serve users file
|
|
975
|
+
|
|
976
|
+
usage: factoidal webdav-user FILE NAME [--iterations N]
|
|
977
|
+
|
|
978
|
+
Reads the password from standard input (on a terminal it asks, and does not
|
|
979
|
+
show what you type; else it reads all of standard input, without one final
|
|
980
|
+
line end). Makes a 16-byte salt from the host's random source, derives
|
|
981
|
+
PBKDF2-HMAC-SHA256 with WebCrypto, and writes the line
|
|
982
|
+
NAME:pbkdf2-sha256:ITERATIONS:SALT_B64:HASH_B64 in FILE: in place of NAME's
|
|
983
|
+
line, or at the end. FILE is made when it does not exist, and is always
|
|
984
|
+
written with mode 0600 (a new file renamed over the old one).
|
|
985
|
+
|
|
986
|
+
--iterations N the PBKDF2 iteration count (default 600000, the OWASP
|
|
987
|
+
Password Storage Cheat Sheet figure of 2023 for
|
|
988
|
+
PBKDF2-HMAC-SHA256; at least 100000)
|
|
989
|
+
|
|
990
|
+
Every decision (the name rules, the password rules, the count, the line) is
|
|
991
|
+
in formal/lean4/L4Factoidal/WebDAV/Login.lean. Expert review:
|
|
992
|
+
https://github.com/danbri/factoidal/issues/1117 (https://github.com/danbri/factoidal/issues/1108).`,
|
|
993
|
+
|
|
710
994
|
'solid-client': `factoidal solid-client - run one Solid client operation
|
|
711
995
|
|
|
712
996
|
usage: factoidal solid-client <get|put|post|delete|discover> URL [options]
|
|
@@ -753,14 +1037,24 @@ operations:
|
|
|
753
1037
|
list CONTAINER every page, following rel="next"
|
|
754
1038
|
typeIndex URL | search URL --query JSON
|
|
755
1039
|
subscribe URL --inbox URL [--topic URL]
|
|
1040
|
+
--endpoint URL typeIndex, search, subscribe: the service endpoint;
|
|
1041
|
+
the storage description is not read (for subscribe,
|
|
1042
|
+
URL is the default topic)
|
|
756
1043
|
unsubscribe URL | revokeAccess URL
|
|
757
1044
|
requestAccess URL | grantAccess URL
|
|
758
1045
|
--action A[,A] --assignee URI [--target URL[,URL]]
|
|
759
|
-
[--inbox URL]
|
|
1046
|
+
[--inbox URL]; or --policies JSON, an array of
|
|
1047
|
+
{actions, assignee, targets, constraint?}, one
|
|
1048
|
+
member per policy of the document
|
|
760
1049
|
|
|
761
1050
|
preconditions (update, patch, delete, updateLinkset, move):
|
|
762
1051
|
--if-match TAG | --if-match-current | --if-none-match-star |
|
|
763
1052
|
--if-unmodified-since DATE | --no-precondition
|
|
1053
|
+
--if-none-match TAG[,TAG] If-None-Match with entity tags (RFC 9110
|
|
1054
|
+
§13.1.2), alone or with --if-match TAG,
|
|
1055
|
+
--if-match-current or --if-unmodified-since;
|
|
1056
|
+
for read, the condition of the GET (304 is
|
|
1057
|
+
notModified)
|
|
764
1058
|
(default: --if-match-current for update, patch, updateLinkset and move)
|
|
765
1059
|
--retry-412 N read the tag again and resend, at most N times
|
|
766
1060
|
|
|
@@ -770,6 +1064,14 @@ authorization:
|
|
|
770
1064
|
urn:ietf:params:oauth:token-type:saml2
|
|
771
1065
|
--access-token TOKEN a token to present to the storage of URL
|
|
772
1066
|
(with --storage IRI and --root URL)
|
|
1067
|
+
--legacy-challenge follow a 401 with only the challenge
|
|
1068
|
+
Bearer as_uri="AS", realm="STORAGE" of the
|
|
1069
|
+
drafts before 2026-10-19 (off by default: it
|
|
1070
|
+
skips two discovery checks of lws10-core)
|
|
1071
|
+
--require-https send no request to a URL that is not https
|
|
1072
|
+
(the operation, discovery, metadata, the
|
|
1073
|
+
authorization server, the token endpoint);
|
|
1074
|
+
such an operation fails with insecureTransport
|
|
773
1075
|
|
|
774
1076
|
Draft: https://w3c.github.io/lws-protocol/lws10-core/`
|
|
775
1077
|
}
|
|
@@ -827,7 +1129,7 @@ function parseArguments (args, valueOptions) {
|
|
|
827
1129
|
class UsageError extends Error {}
|
|
828
1130
|
|
|
829
1131
|
/** Options that may be given more than once; their value is the list. */
|
|
830
|
-
const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp'])
|
|
1132
|
+
const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin'])
|
|
831
1133
|
|
|
832
1134
|
const VALUE_OPTIONS = {
|
|
833
1135
|
version: new Set([]),
|
|
@@ -838,22 +1140,27 @@ const VALUE_OPTIONS = {
|
|
|
838
1140
|
activate: new Set([]),
|
|
839
1141
|
update: new Set(['update', 'file']),
|
|
840
1142
|
compact: new Set([]),
|
|
841
|
-
serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
|
|
1143
|
+
serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2', 'allow-host', 'allow-origin']),
|
|
842
1144
|
'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
|
|
843
1145
|
'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
|
|
844
1146
|
'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after',
|
|
845
|
-
'saml-idp']),
|
|
846
|
-
'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
|
|
1147
|
+
'saml-idp', 'saml-decrypt-key', 'allow-host', 'allow-origin']),
|
|
1148
|
+
'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config', 'allow-host', 'allow-origin']),
|
|
847
1149
|
'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
|
|
848
1150
|
'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
|
|
849
1151
|
'if-unmodified-since', 'retry-412', 'destination', 'query', 'topic', 'action', 'assignee',
|
|
850
1152
|
'target', 'subject-token-file', 'subject-token-type', 'access-token', 'storage', 'root',
|
|
851
|
-
'inbox']),
|
|
1153
|
+
'inbox', 'endpoint', 'if-none-match', 'policies']),
|
|
852
1154
|
'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
|
|
853
|
-
'cors-origin', 'saml-idp']),
|
|
1155
|
+
'cors-origin', 'saml-idp', 'saml-decrypt-key']),
|
|
854
1156
|
'authz-keys': new Set([]),
|
|
855
1157
|
'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
|
|
856
|
-
'webdav-serve': new Set(['port', 'host'
|
|
1158
|
+
'webdav-serve': new Set(['port', 'host', 'users', 'guest', 'cert', 'key', 'login-limit', 'login-window', 'xmpp', 'xmpp-password-file',
|
|
1159
|
+
'xmpp-host', 'xmpp-port', 'xmpp-allow', 'xmpp-resource', 'xmpp-ca', 'allow-host', 'allow-origin']),
|
|
1160
|
+
'webdav-user': new Set(['iterations']),
|
|
1161
|
+
'xmpp-http-proxy': new Set(['port', 'host', 'xmpp', 'xmpp-password-file', 'target', 'xmpp-host', 'xmpp-port',
|
|
1162
|
+
'xmpp-resource', 'xmpp-ca', 'timeout']),
|
|
1163
|
+
'lws-keygen': new Set([])
|
|
857
1164
|
}
|
|
858
1165
|
|
|
859
1166
|
// ------------------------------------------------------------- commands
|
|
@@ -1765,14 +2072,35 @@ function corsConfig (options) {
|
|
|
1765
2072
|
return {}
|
|
1766
2073
|
}
|
|
1767
2074
|
|
|
2075
|
+
// --allow-host NAME and --allow-origin ORIGIN (repeatable), as the config
|
|
2076
|
+
// members `allowHosts` and `allowOrigins` of the Host and Origin guard of a
|
|
2077
|
+
// listener without authentication. Passed unread; the engine checks each
|
|
2078
|
+
// value and decides (L4Factoidal/HTTP/LocalGuard.lean,
|
|
2079
|
+
// https://github.com/danbri/factoidal/issues/1124).
|
|
2080
|
+
function localGuardConfig (options) {
|
|
2081
|
+
const out = {}
|
|
2082
|
+
if (Array.isArray(options['allow-host'])) out.allowHosts = options['allow-host']
|
|
2083
|
+
if (Array.isArray(options['allow-origin'])) out.allowOrigins = options['allow-origin']
|
|
2084
|
+
return out
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function hasLocalGuardOptions (options) {
|
|
2088
|
+
return options['allow-host'] !== undefined || options['allow-origin'] !== undefined
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1768
2091
|
// --saml-idp FILE (repeatable): a SAML 2.0 identity provider whose signed
|
|
1769
2092
|
// assertions the authorization server accepts, as the config member
|
|
1770
2093
|
// `samlIdps`. FILE is JSON {"entityId": ID, "jwk": RSA public JWK}; passed
|
|
1771
2094
|
// unread, the engine checks it (Wasm/Ops/Authz.lean, issue 926).
|
|
2095
|
+
// --saml-decrypt-key FILE (repeatable): one private JWK, as the config member
|
|
2096
|
+
// `samlKeys`; passed unread, the engine checks it (samlKeysOf?, issue 1073).
|
|
1772
2097
|
function samlConfig (options) {
|
|
2098
|
+
const out = {}
|
|
1773
2099
|
const files = options['saml-idp']
|
|
1774
|
-
if (
|
|
1775
|
-
|
|
2100
|
+
if (Array.isArray(files)) out.samlIdps = files.map((f) => readJsonFile(f))
|
|
2101
|
+
const keys = options['saml-decrypt-key']
|
|
2102
|
+
if (Array.isArray(keys)) out.samlKeys = keys.map((f) => readJsonFile(f))
|
|
2103
|
+
return out
|
|
1776
2104
|
}
|
|
1777
2105
|
|
|
1778
2106
|
function serveHost (options) {
|
|
@@ -1855,7 +2183,8 @@ async function commandServe (positional, options) {
|
|
|
1855
2183
|
host: serveHost(options),
|
|
1856
2184
|
...(tdb2 ? { tdb2: true } : {}),
|
|
1857
2185
|
...(typeof options['query-path'] === 'string' ? { queryPath: options['query-path'] } : {}),
|
|
1858
|
-
...(typeof options['gsp-path'] === 'string' ? { gspPath: options['gsp-path'] } : {})
|
|
2186
|
+
...(typeof options['gsp-path'] === 'string' ? { gspPath: options['gsp-path'] } : {}),
|
|
2187
|
+
...localGuardConfig(options)
|
|
1859
2188
|
}
|
|
1860
2189
|
const onWorker = options['no-worker'] !== true && workerRouteAvailable()
|
|
1861
2190
|
let running
|
|
@@ -1898,6 +2227,9 @@ async function commandLwsServe (positional, options) {
|
|
|
1898
2227
|
if (insecure && (issuerOption !== null || authFile !== null || authzDir !== null)) {
|
|
1899
2228
|
throw new UsageError('--insecure-no-auth cannot be combined with --authz, --issuer or --auth-config')
|
|
1900
2229
|
}
|
|
2230
|
+
if (!insecure && hasLocalGuardOptions(options)) {
|
|
2231
|
+
throw new UsageError('--allow-host and --allow-origin go with --insecure-no-auth; under authorization the token decides')
|
|
2232
|
+
}
|
|
1901
2233
|
// --authz DIR: an authorization server on the same port, its issuer the
|
|
1902
2234
|
// storage root (L4Factoidal/Authz/Mount.lean). Its options are passed
|
|
1903
2235
|
// unread; the engine checks them (Wasm/Ops/Authz.lean).
|
|
@@ -1941,7 +2273,7 @@ async function commandLwsServe (positional, options) {
|
|
|
1941
2273
|
...(options['redirects-max'] !== undefined ? { redirectsMax: natOption('redirects-max', options['redirects-max']) } : {}),
|
|
1942
2274
|
...(typeof options.storage === 'string' ? { storage: options.storage } : {}),
|
|
1943
2275
|
...corsConfig(options),
|
|
1944
|
-
...(insecure ? { insecureNoAuth: true } : { auth }),
|
|
2276
|
+
...(insecure ? { insecureNoAuth: true, ...localGuardConfig(options) } : { auth }),
|
|
1945
2277
|
...(authz !== undefined ? { authz } : {}),
|
|
1946
2278
|
...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
|
|
1947
2279
|
...(typeof options['root-acl'] === 'string'
|
|
@@ -1964,12 +2296,25 @@ async function commandLwsServe (positional, options) {
|
|
|
1964
2296
|
}
|
|
1965
2297
|
throw error
|
|
1966
2298
|
}
|
|
2299
|
+
// The engine's start notices (a stale lock taken over; encryption at rest
|
|
2300
|
+
// on or off), printed also with --quiet.
|
|
2301
|
+
for (const n of (await running.storage()).notices || []) err(`lws-serve: ${n}`)
|
|
1967
2302
|
if (!quiet) {
|
|
1968
2303
|
err(`lws-serve: listening on ${running.origin} for ${positional[0]}`)
|
|
1969
2304
|
if (running.authz() !== null) err(`lws-serve: authorization server ${running.authz().issuer} (keys in ${authzDir})`)
|
|
1970
2305
|
if (options['allow-http'] === true) err('lws-serve: WARNING: --allow-http: http subject identifiers are accepted')
|
|
1971
2306
|
if (options['allow-private-fetch'] === true) err('lws-serve: WARNING: --allow-private-fetch: private addresses may be fetched')
|
|
1972
|
-
if (insecure)
|
|
2307
|
+
if (insecure) {
|
|
2308
|
+
err('lws-serve: WARNING: --insecure-no-auth: every request is served without authorization')
|
|
2309
|
+
err('lws-serve: --insecure-no-auth: only the Host names of this listener are answered, and a ' +
|
|
2310
|
+
'request that changes state from another origin is refused (issue 1124; add names with ' +
|
|
2311
|
+
'--allow-host, origins with --allow-origin)')
|
|
2312
|
+
if (options['cors-origin'] === undefined) {
|
|
2313
|
+
err('lws-serve: --insecure-no-auth: no CORS fields are sent, because with no authorization ' +
|
|
2314
|
+
'an echoed Origin would let any web page read and write this storage; give --cors-origin ' +
|
|
2315
|
+
'ORIGIN for each page that may use it (https://github.com/danbri/factoidal/issues/1124)')
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
1973
2318
|
}
|
|
1974
2319
|
out(running.origin)
|
|
1975
2320
|
return untilInterrupted(running, quiet)
|
|
@@ -2018,6 +2363,39 @@ async function commandAuthzServe (positional, options) {
|
|
|
2018
2363
|
return untilInterrupted(running, quiet)
|
|
2019
2364
|
}
|
|
2020
2365
|
|
|
2366
|
+
// lws-keygen (https://github.com/danbri/factoidal/issues/1135): 32 random
|
|
2367
|
+
// bytes from the host's random source go to the engine (`lwsStorageKeyNew`),
|
|
2368
|
+
// which gives the key text and its key id. The key goes to standard output
|
|
2369
|
+
// only; nothing is written to a file.
|
|
2370
|
+
async function commandLwsKeygen (positional, options) {
|
|
2371
|
+
if (positional.length !== 0) throw new UsageError('lws-keygen takes no argument')
|
|
2372
|
+
const random = new Uint8Array(32)
|
|
2373
|
+
globalThis.crypto.getRandomValues(random)
|
|
2374
|
+
const engine = await loadEngine()
|
|
2375
|
+
let res
|
|
2376
|
+
try {
|
|
2377
|
+
res = engine.call('lwsStorageKeyNew', [JSON.stringify({ random: Buffer.from(random).toString('base64') })])
|
|
2378
|
+
} catch (error) {
|
|
2379
|
+
const message = error && error.message ? String(error.message) : String(error)
|
|
2380
|
+
if (message.indexOf('unknown op') >= 0) return notWired('lws-keygen', message)
|
|
2381
|
+
throw error
|
|
2382
|
+
}
|
|
2383
|
+
if (res.ok === false) {
|
|
2384
|
+
err(`factoidal lws-keygen: ${res.error}`)
|
|
2385
|
+
return EXIT_FAILURE
|
|
2386
|
+
}
|
|
2387
|
+
if (options.json === true) {
|
|
2388
|
+
out(JSON.stringify({ key: res.key, keyId: res.keyId }))
|
|
2389
|
+
} else {
|
|
2390
|
+
out(res.key)
|
|
2391
|
+
if (options.quiet !== true) {
|
|
2392
|
+
err(`lws-keygen: key id ${res.keyId}. Keep a copy of the key in a safe place: without it the storage cannot be read.`)
|
|
2393
|
+
err('lws-keygen: start the server with FACTOIDAL_LWS_KEY set to it, on an empty storage directory.')
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
return EXIT_OK
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2021
2399
|
async function commandAuthzKeys (positional, options) {
|
|
2022
2400
|
if (positional.length !== 1) throw new UsageError('authz-keys needs one DIR')
|
|
2023
2401
|
const { openAuthorizationServer, readKeyFile } = await import('../authz/server.mjs')
|
|
@@ -2064,6 +2442,9 @@ async function commandSolidServe (positional, options) {
|
|
|
2064
2442
|
if (insecure && (issuerOption !== null || authFile !== null)) {
|
|
2065
2443
|
throw new UsageError('--insecure-no-auth cannot be combined with --issuer or --auth-config')
|
|
2066
2444
|
}
|
|
2445
|
+
if (!insecure && hasLocalGuardOptions(options)) {
|
|
2446
|
+
throw new UsageError('--allow-host and --allow-origin go with --insecure-no-auth; under authentication the token decides')
|
|
2447
|
+
}
|
|
2067
2448
|
let auth
|
|
2068
2449
|
if (authFile !== null) {
|
|
2069
2450
|
auth = readJsonFile(authFile)
|
|
@@ -2077,7 +2458,7 @@ async function commandSolidServe (positional, options) {
|
|
|
2077
2458
|
port: servePort(options, 3000),
|
|
2078
2459
|
host: serveHost(options),
|
|
2079
2460
|
...(typeof options.base === 'string' ? { baseIri: options.base } : {}),
|
|
2080
|
-
...(insecure ? { insecureNoAuth: true } : { auth }),
|
|
2461
|
+
...(insecure ? { insecureNoAuth: true, ...localGuardConfig(options) } : { auth }),
|
|
2081
2462
|
...(typeof options.owner === 'string' ? { owner: options.owner } : {}),
|
|
2082
2463
|
realClock: true,
|
|
2083
2464
|
dir: positional[0]
|
|
@@ -2096,9 +2477,19 @@ async function commandSolidServe (positional, options) {
|
|
|
2096
2477
|
}
|
|
2097
2478
|
throw error
|
|
2098
2479
|
}
|
|
2480
|
+
// The engine's start notices (a stale lock taken over; encryption at rest
|
|
2481
|
+
// on or off), printed also with --quiet.
|
|
2482
|
+
for (const n of (await running.storage()).notices || []) err(`solid-serve: ${n}`)
|
|
2099
2483
|
if (!quiet) {
|
|
2100
2484
|
err(`solid-serve: listening on ${running.origin} for ${positional[0]}`)
|
|
2101
|
-
if (insecure)
|
|
2485
|
+
if (insecure) {
|
|
2486
|
+
err('solid-serve: WARNING: --insecure-no-auth: every request is served without authentication')
|
|
2487
|
+
err('solid-serve: --insecure-no-auth: only the Host names of this listener are answered, and a ' +
|
|
2488
|
+
'request that changes state from another origin is refused (issue 1124; add names with ' +
|
|
2489
|
+
'--allow-host, origins with --allow-origin). CORS still echoes the request Origin (Solid ' +
|
|
2490
|
+
'Protocol section 8.1), so a page on another origin can read this storage ' +
|
|
2491
|
+
'(https://github.com/danbri/factoidal/issues/1124)')
|
|
2492
|
+
}
|
|
2102
2493
|
}
|
|
2103
2494
|
out(running.origin)
|
|
2104
2495
|
return untilInterrupted(running, quiet)
|
|
@@ -2110,10 +2501,41 @@ async function commandSolidServe (positional, options) {
|
|
|
2110
2501
|
async function commandWebdavServe (positional, options) {
|
|
2111
2502
|
if (positional.length !== 1) throw new UsageError('webdav-serve needs one CONFIGDIR')
|
|
2112
2503
|
const quiet = options.quiet === true
|
|
2504
|
+
if (options.xmpp !== undefined) return webdavOverXmpp(positional[0], options, quiet)
|
|
2113
2505
|
const { listen, WebdavHostError } = await import('../webdav/server.mjs')
|
|
2506
|
+
const fs = await import('node:fs/promises')
|
|
2507
|
+
const { resolve: resolvePath } = await import('node:path')
|
|
2508
|
+
const useTls = options.cert !== undefined || options.key !== undefined
|
|
2509
|
+
if (useTls && (typeof options.cert !== 'string' || typeof options.key !== 'string')) {
|
|
2510
|
+
throw new UsageError('--cert and --key go together')
|
|
2511
|
+
}
|
|
2512
|
+
const settings = { configDir: positional[0], port: servePort(options, 8090), host: serveHost(options) }
|
|
2513
|
+
// The users file is named relative to the working directory here, and
|
|
2514
|
+
// given to the engine as an absolute path.
|
|
2515
|
+
if (typeof options.users === 'string') settings.users = resolvePath(options.users)
|
|
2516
|
+
// The guest user (https://github.com/danbri/factoidal/issues/1147): the
|
|
2517
|
+
// name goes to the engine unread; it checks it.
|
|
2518
|
+
if (options.guest !== undefined) {
|
|
2519
|
+
if (typeof options.guest !== 'string') throw new UsageError('--guest needs a user name')
|
|
2520
|
+
settings.guest = options.guest
|
|
2521
|
+
}
|
|
2522
|
+
// The Host and Origin guard (https://github.com/danbri/factoidal/issues/1124):
|
|
2523
|
+
// the values go to the engine unread; it checks them.
|
|
2524
|
+
Object.assign(settings, localGuardConfig(options))
|
|
2525
|
+
// The failed-login limit (https://github.com/danbri/factoidal/issues/1119):
|
|
2526
|
+
// the values go to the engine unread; it checks them.
|
|
2527
|
+
if (options['login-limit'] !== undefined) settings.loginLimit = String(options['login-limit'])
|
|
2528
|
+
if (options['login-window'] !== undefined) settings.loginWindow = String(options['login-window'])
|
|
2529
|
+
if (options['dangerously-disable-ratelimits'] !== undefined) {
|
|
2530
|
+
settings.disableRateLimits = options['dangerously-disable-ratelimits'] === true
|
|
2531
|
+
}
|
|
2114
2532
|
let running
|
|
2115
2533
|
try {
|
|
2116
|
-
|
|
2534
|
+
if (useTls) {
|
|
2535
|
+
settings.cert = await fs.readFile(options.cert)
|
|
2536
|
+
settings.key = await fs.readFile(options.key)
|
|
2537
|
+
}
|
|
2538
|
+
running = await listen(settings)
|
|
2117
2539
|
} catch (error) {
|
|
2118
2540
|
if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
|
|
2119
2541
|
if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
|
|
@@ -2122,16 +2544,254 @@ async function commandWebdavServe (positional, options) {
|
|
|
2122
2544
|
}
|
|
2123
2545
|
throw error
|
|
2124
2546
|
}
|
|
2547
|
+
// The engine's start warnings (--dangerously-disable-ratelimits), printed
|
|
2548
|
+
// also with --quiet.
|
|
2549
|
+
for (const w of running.warnings || []) err(`factoidal webdav-serve: WARNING: ${w}`)
|
|
2550
|
+
// The start notices of the local mounts (a stale lock taken over;
|
|
2551
|
+
// encryption at rest on or off), printed also with --quiet.
|
|
2552
|
+
for (const n of running.storageNotices || []) err(`webdav-serve: ${n}`)
|
|
2125
2553
|
if (!quiet) {
|
|
2126
|
-
err(`webdav-serve: listening on ${running.origin} for ${positional[0]}`
|
|
2554
|
+
err(`webdav-serve: listening on ${running.origin} for ${positional[0]}` +
|
|
2555
|
+
(settings.users !== undefined ? `, login from ${options.users}` : ', no --users: credentials get 401') +
|
|
2556
|
+
(settings.guest !== undefined ? `, guest ${settings.guest}` : ', no --guest') +
|
|
2557
|
+
(useTls ? ', TLS' : ''))
|
|
2127
2558
|
for (const m of running.mounts) {
|
|
2128
|
-
err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
|
|
2559
|
+
err(`webdav-serve: ${running.origin}/${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''}${Array.isArray(m.users) ? ', users: ' + m.users.join(', ') : ''})`)
|
|
2129
2560
|
}
|
|
2130
2561
|
}
|
|
2131
2562
|
out(running.origin)
|
|
2132
2563
|
return untilInterrupted(running, quiet)
|
|
2133
2564
|
}
|
|
2134
2565
|
|
|
2566
|
+
// webdav-serve CONFIGDIR --xmpp JID (https://github.com/danbri/factoidal/issues/1107,
|
|
2567
|
+
// step 1): the WebDAV handle of the HTTP listener, reached through an
|
|
2568
|
+
// outgoing XMPP client connection (XEP-0332). No port is opened. The host
|
|
2569
|
+
// moves bytes; the stream, SASL, binding, access and every response stanza
|
|
2570
|
+
// are decided in L4Factoidal/XMPP/ (ClientStream, HttpOverXmpp).
|
|
2571
|
+
async function webdavOverXmpp (configDir, options, quiet) {
|
|
2572
|
+
if (typeof options.xmpp !== 'string') throw new UsageError('--xmpp needs a JID')
|
|
2573
|
+
if (typeof options['xmpp-password-file'] !== 'string') throw new UsageError('--xmpp needs --xmpp-password-file FILE')
|
|
2574
|
+
if (typeof options['xmpp-allow'] !== 'string') throw new UsageError('--xmpp needs --xmpp-allow JID[,JID...]')
|
|
2575
|
+
// No login on the XMPP path yet: refuse --users rather than ignore it
|
|
2576
|
+
// (https://github.com/danbri/factoidal/issues/1125).
|
|
2577
|
+
if (['users', 'cert', 'key', 'login-limit', 'login-window', 'dangerously-disable-ratelimits'].some((k) => options[k] !== undefined)) {
|
|
2578
|
+
throw new UsageError('--users, --cert, --key and the login-limit options do not apply with --xmpp yet (https://github.com/danbri/factoidal/issues/1125)')
|
|
2579
|
+
}
|
|
2580
|
+
const fs = await import('node:fs/promises')
|
|
2581
|
+
const password = (await fs.readFile(options['xmpp-password-file'], 'utf8')).split(/\r?\n/)[0]
|
|
2582
|
+
const allow = options['xmpp-allow'].split(',').map((x) => x.trim()).filter((x) => x.length > 0)
|
|
2583
|
+
const { openWebdav, WebdavHostError } = await import('../webdav/server.mjs')
|
|
2584
|
+
const { connectHttpTarget, XmppClientError } = await import('../xmpp/client.mjs')
|
|
2585
|
+
if (options.guest !== undefined && typeof options.guest !== 'string') throw new UsageError('--guest needs a user name')
|
|
2586
|
+
let dav
|
|
2587
|
+
try {
|
|
2588
|
+
dav = await openWebdav({ configDir, ...(typeof options.guest === 'string' ? { guest: options.guest } : {}) })
|
|
2589
|
+
} catch (error) {
|
|
2590
|
+
if (error instanceof WebdavHostError && error.unknownOp) return notWired('webdav-serve', error.message)
|
|
2591
|
+
if (error instanceof WebdavHostError || (error !== null && typeof error === 'object' && error.code === 'ENOENT')) {
|
|
2592
|
+
err(`factoidal webdav-serve: ${error.message}`)
|
|
2593
|
+
return EXIT_FAILURE
|
|
2594
|
+
}
|
|
2595
|
+
throw error
|
|
2596
|
+
}
|
|
2597
|
+
for (const w of dav.warnings || []) err(`factoidal webdav-serve: WARNING: ${w}`)
|
|
2598
|
+
for (const n of dav.storageNotices || []) err(`webdav-serve: ${n}`)
|
|
2599
|
+
const settings = {
|
|
2600
|
+
engine: dav.engine,
|
|
2601
|
+
jid: options.xmpp,
|
|
2602
|
+
password,
|
|
2603
|
+
allow,
|
|
2604
|
+
resource: typeof options['xmpp-resource'] === 'string' ? options['xmpp-resource'] : 'dav',
|
|
2605
|
+
tls: options['xmpp-tls'] === true,
|
|
2606
|
+
mounts: dav.mounts,
|
|
2607
|
+
dangerouslyAllowWritableWithoutLogin: options['dangerously-allow-writable-without-login'] === true,
|
|
2608
|
+
step: (request) => dav.step(request),
|
|
2609
|
+
onError: (error) => { if (!quiet) err(`webdav-serve: xmpp: ${error.message}`) }
|
|
2610
|
+
}
|
|
2611
|
+
if (typeof options['xmpp-host'] === 'string') settings.host = options['xmpp-host']
|
|
2612
|
+
if (typeof options['xmpp-ca'] === 'string') settings.tlsOptions = { ca: await fs.readFile(options['xmpp-ca']) }
|
|
2613
|
+
if (options['xmpp-port'] !== undefined) {
|
|
2614
|
+
const port = Number(options['xmpp-port'])
|
|
2615
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError('--xmpp-port needs a port number')
|
|
2616
|
+
settings.port = port
|
|
2617
|
+
}
|
|
2618
|
+
let conn
|
|
2619
|
+
try {
|
|
2620
|
+
conn = await connectHttpTarget(settings)
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
dav.close()
|
|
2623
|
+
if (error instanceof XmppClientError && error.unknownOp) return notWired('webdav-serve', error.message)
|
|
2624
|
+
if (error instanceof XmppClientError || (error !== null && typeof error === 'object' && typeof error.code === 'string')) {
|
|
2625
|
+
err(`factoidal webdav-serve: xmpp: ${error.message}`)
|
|
2626
|
+
return EXIT_FAILURE
|
|
2627
|
+
}
|
|
2628
|
+
throw error
|
|
2629
|
+
}
|
|
2630
|
+
if (!quiet) {
|
|
2631
|
+
err(`webdav-serve: connected to XMPP as ${conn.jid}; no port is open; requests from ${allow.join(', ')}`)
|
|
2632
|
+
if (conn.writableWithoutLogin.length > 0) {
|
|
2633
|
+
err(`webdav-serve: WARNING: --dangerously-allow-writable-without-login: every allowed JID can write ${conn.writableWithoutLogin.join(', ')} as the storage owner, with no login`)
|
|
2634
|
+
}
|
|
2635
|
+
for (const m of dav.mounts) {
|
|
2636
|
+
err(`webdav-serve: /${encodeURIComponent(m.name)}/ - ${m.title} (${m.kind}${m.writable ? '' : ', read-only'}${m.agent ? ', as ' + m.agent : ''})`)
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
out(`xmpp:${conn.jid}`)
|
|
2640
|
+
const running = { close: async () => { await conn.close(); dav.close() } }
|
|
2641
|
+
const ended = conn.closed.then((reason) => {
|
|
2642
|
+
if (!quiet && reason) err(`webdav-serve: xmpp: connection closed: ${reason}`)
|
|
2643
|
+
dav.close()
|
|
2644
|
+
return EXIT_FAILURE
|
|
2645
|
+
})
|
|
2646
|
+
return Promise.race([untilInterrupted(running, quiet), ended])
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// xmpp-http-proxy (https://github.com/danbri/factoidal/issues/1107, step 2):
|
|
2650
|
+
// a loopback HTTP listener whose requests go over XMPP (XEP-0332) to one
|
|
2651
|
+
// target JID. The host moves bytes; the target, the listener address, the
|
|
2652
|
+
// headers forwarded, the limits, timeouts and every status are decided in
|
|
2653
|
+
// L4Factoidal/XMPP/HttpRequester.lean and Wasm/Ops/XmppClient.lean.
|
|
2654
|
+
async function commandXmppHttpProxy (positional, options) {
|
|
2655
|
+
if (positional.length !== 0) throw new UsageError('xmpp-http-proxy takes no positional argument')
|
|
2656
|
+
const quiet = options.quiet === true
|
|
2657
|
+
if (typeof options.xmpp !== 'string') throw new UsageError('xmpp-http-proxy needs --xmpp JID')
|
|
2658
|
+
if (typeof options['xmpp-password-file'] !== 'string') throw new UsageError('xmpp-http-proxy needs --xmpp-password-file FILE')
|
|
2659
|
+
if (typeof options.target !== 'string') throw new UsageError('xmpp-http-proxy needs --target JID/RESOURCE')
|
|
2660
|
+
const fs = await import('node:fs/promises')
|
|
2661
|
+
const password = (await fs.readFile(options['xmpp-password-file'], 'utf8')).split(/\r?\n/)[0]
|
|
2662
|
+
const { startHttpProxy } = await import('../xmpp/proxy.mjs')
|
|
2663
|
+
const { XmppClientError } = await import('../xmpp/client.mjs')
|
|
2664
|
+
const settings = {
|
|
2665
|
+
jid: options.xmpp,
|
|
2666
|
+
password,
|
|
2667
|
+
target: options.target,
|
|
2668
|
+
resource: typeof options['xmpp-resource'] === 'string' ? options['xmpp-resource'] : 'proxy',
|
|
2669
|
+
tls: options['xmpp-tls'] === true,
|
|
2670
|
+
// The address goes to the engine unread; it accepts 127.0.0.1 only.
|
|
2671
|
+
listenHost: typeof options.host === 'string' ? options.host : '127.0.0.1',
|
|
2672
|
+
listenPort: servePort(options, 8091),
|
|
2673
|
+
onError: (error) => { if (!quiet) err(`xmpp-http-proxy: ${error.message}`) }
|
|
2674
|
+
}
|
|
2675
|
+
if (options.timeout !== undefined) {
|
|
2676
|
+
const t = Number(options.timeout)
|
|
2677
|
+
if (!Number.isInteger(t) || t < 0) throw new UsageError('--timeout needs a whole number of seconds')
|
|
2678
|
+
settings.timeoutSeconds = t
|
|
2679
|
+
}
|
|
2680
|
+
if (typeof options['xmpp-host'] === 'string') settings.host = options['xmpp-host']
|
|
2681
|
+
if (typeof options['xmpp-ca'] === 'string') settings.tlsOptions = { ca: await fs.readFile(options['xmpp-ca']) }
|
|
2682
|
+
if (options['xmpp-port'] !== undefined) {
|
|
2683
|
+
const port = Number(options['xmpp-port'])
|
|
2684
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError('--xmpp-port needs a port number')
|
|
2685
|
+
settings.port = port
|
|
2686
|
+
}
|
|
2687
|
+
let running
|
|
2688
|
+
try {
|
|
2689
|
+
running = await startHttpProxy(settings)
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
if (error instanceof XmppClientError && error.unknownOp) return notWired('xmpp-http-proxy', error.message)
|
|
2692
|
+
if (error instanceof XmppClientError || (error !== null && typeof error === 'object' && typeof error.code === 'string')) {
|
|
2693
|
+
err(`factoidal xmpp-http-proxy: ${error.message}`)
|
|
2694
|
+
return EXIT_FAILURE
|
|
2695
|
+
}
|
|
2696
|
+
throw error
|
|
2697
|
+
}
|
|
2698
|
+
if (!quiet) {
|
|
2699
|
+
err(`xmpp-http-proxy: connected to XMPP as ${running.jid}; requests to ${running.origin} go to ${running.target} only`)
|
|
2700
|
+
err('xmpp-http-proxy: the listener has no login: every local process (and any web page in a local browser) acts as ' +
|
|
2701
|
+
`${running.jid} (https://github.com/danbri/factoidal/issues/1124)`)
|
|
2702
|
+
}
|
|
2703
|
+
out(running.origin)
|
|
2704
|
+
const ended = running.closed.then((reason) => {
|
|
2705
|
+
if (!quiet && reason) err(`xmpp-http-proxy: xmpp: connection closed: ${reason}`)
|
|
2706
|
+
return EXIT_FAILURE
|
|
2707
|
+
})
|
|
2708
|
+
return Promise.race([untilInterrupted(running, quiet), ended])
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
/** A password from standard input: on a terminal, asked for with no echo;
|
|
2712
|
+
* else all of standard input. The bytes go to the engine unread. Pure I/O. */
|
|
2713
|
+
async function readPasswordInput () {
|
|
2714
|
+
const stdin = process.stdin
|
|
2715
|
+
if (!stdin.isTTY) {
|
|
2716
|
+
const chunks = []
|
|
2717
|
+
for await (const chunk of stdin) chunks.push(chunk)
|
|
2718
|
+
return new Uint8Array(Buffer.concat(chunks))
|
|
2719
|
+
}
|
|
2720
|
+
process.stderr.write('Password: ')
|
|
2721
|
+
stdin.setRawMode(true)
|
|
2722
|
+
stdin.resume()
|
|
2723
|
+
const typed = []
|
|
2724
|
+
try {
|
|
2725
|
+
for await (const chunk of stdin) {
|
|
2726
|
+
for (const b of chunk) {
|
|
2727
|
+
if (b === 0x0d || b === 0x0a || b === 0x04) return new Uint8Array(typed)
|
|
2728
|
+
if (b === 0x03) throw new UsageError('interrupted')
|
|
2729
|
+
if (b === 0x7f || b === 0x08) typed.pop()
|
|
2730
|
+
else typed.push(b)
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
return new Uint8Array(typed)
|
|
2734
|
+
} finally {
|
|
2735
|
+
stdin.setRawMode(false)
|
|
2736
|
+
stdin.pause()
|
|
2737
|
+
err('')
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// webdav-user FILE NAME (https://github.com/danbri/factoidal/issues/1108):
|
|
2742
|
+
// the engine checks the name, the password and the file, names the PBKDF2
|
|
2743
|
+
// derivation, and gives the new text; this host reads the password, runs
|
|
2744
|
+
// the derivation with WebCrypto (lib/cipher-host.mjs) and writes the file.
|
|
2745
|
+
async function commandWebdavUser (positional, options) {
|
|
2746
|
+
if (positional.length !== 2) throw new UsageError('webdav-user needs FILE and NAME')
|
|
2747
|
+
const [file, name] = positional
|
|
2748
|
+
const fs = await import('node:fs')
|
|
2749
|
+
const { dirname, basename, join } = await import('node:path')
|
|
2750
|
+
const { runWithCipher } = await import('../lib/cipher-host.mjs')
|
|
2751
|
+
let text = null
|
|
2752
|
+
try {
|
|
2753
|
+
text = fs.readFileSync(file, 'utf8')
|
|
2754
|
+
} catch (error) {
|
|
2755
|
+
if (error === null || typeof error !== 'object' || error.code !== 'ENOENT') throw error
|
|
2756
|
+
}
|
|
2757
|
+
const input = await readPasswordInput()
|
|
2758
|
+
const salt = new Uint8Array(16)
|
|
2759
|
+
globalThis.crypto.getRandomValues(salt)
|
|
2760
|
+
const engine = await loadEngine()
|
|
2761
|
+
const args = {
|
|
2762
|
+
name,
|
|
2763
|
+
file,
|
|
2764
|
+
text,
|
|
2765
|
+
passwordBase64: Buffer.from(input).toString('base64'),
|
|
2766
|
+
saltBase64: Buffer.from(salt).toString('base64')
|
|
2767
|
+
}
|
|
2768
|
+
if (options.iterations !== undefined) args.iterations = String(options.iterations)
|
|
2769
|
+
let res
|
|
2770
|
+
try {
|
|
2771
|
+
res = await runWithCipher((a) => engine.call('webdavUser', [JSON.stringify(a)]), args)
|
|
2772
|
+
} catch (error) {
|
|
2773
|
+
const message = error && error.message ? String(error.message) : String(error)
|
|
2774
|
+
if (message.indexOf('unknown op') >= 0) return notWired('webdav-user', message)
|
|
2775
|
+
throw error
|
|
2776
|
+
}
|
|
2777
|
+
if (res.ok === false) {
|
|
2778
|
+
err(`factoidal webdav-user: ${res.error}`)
|
|
2779
|
+
return EXIT_FAILURE
|
|
2780
|
+
}
|
|
2781
|
+
// A new file in the same directory, mode 0600, flushed, renamed over FILE.
|
|
2782
|
+
const temp = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`)
|
|
2783
|
+
const fd = fs.openSync(temp, 'wx', 0o600)
|
|
2784
|
+
try {
|
|
2785
|
+
fs.writeSync(fd, res.text)
|
|
2786
|
+
fs.fsyncSync(fd)
|
|
2787
|
+
} finally {
|
|
2788
|
+
fs.closeSync(fd)
|
|
2789
|
+
}
|
|
2790
|
+
fs.renameSync(temp, file)
|
|
2791
|
+
err(`webdav-user: ${res.replaced ? 'replaced' : 'added'} ${name} in ${file}`)
|
|
2792
|
+
return EXIT_OK
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2135
2795
|
async function commandXmppServe (positional, options) {
|
|
2136
2796
|
if (positional.length !== 1) throw new UsageError('xmpp-serve needs one DIR')
|
|
2137
2797
|
const quiet = options.quiet === true
|
|
@@ -2256,6 +2916,11 @@ async function commandLwsClient (positional, options) {
|
|
|
2256
2916
|
op.precondition = 'ifUnmodifiedSince'; op.tag = options['if-unmodified-since']
|
|
2257
2917
|
}
|
|
2258
2918
|
if (options['no-precondition'] === true) op.precondition = 'none'
|
|
2919
|
+
if (typeof options['if-none-match'] === 'string') {
|
|
2920
|
+
// The value goes to the engine whole; it reads the entity-tag list.
|
|
2921
|
+
if (kind === 'read') op.ifNoneMatch = options['if-none-match']
|
|
2922
|
+
else op.ifNoneMatchTags = [options['if-none-match']]
|
|
2923
|
+
}
|
|
2259
2924
|
if (typeof options['retry-412'] === 'string') op.retry412 = Number(options['retry-412'])
|
|
2260
2925
|
if (typeof options.destination === 'string') op.destination = options.destination
|
|
2261
2926
|
if (typeof options.query === 'string') op.query = JSON.parse(options.query)
|
|
@@ -2264,7 +2929,11 @@ async function commandLwsClient (positional, options) {
|
|
|
2264
2929
|
if (typeof options.action === 'string') op.actions = list(options.action)
|
|
2265
2930
|
if (typeof options.assignee === 'string') op.assignee = options.assignee
|
|
2266
2931
|
if (typeof options.target === 'string') op.targets = list(options.target)
|
|
2932
|
+
if (typeof options.policies === 'string') op.policies = JSON.parse(options.policies)
|
|
2933
|
+
if (typeof options.endpoint === 'string') op.endpoint = options.endpoint
|
|
2267
2934
|
const clientOptions = {}
|
|
2935
|
+
if (options['legacy-challenge'] === true) clientOptions.legacyChallenge = true
|
|
2936
|
+
if (options['require-https'] === true) clientOptions.requireHttps = true
|
|
2268
2937
|
if (typeof options['subject-token-file'] === 'string') {
|
|
2269
2938
|
clientOptions.subjectToken = new TextDecoder('utf-8', { fatal: true })
|
|
2270
2939
|
.decode(readWhole(options['subject-token-file'])).trim()
|
|
@@ -2313,7 +2982,10 @@ const COMMANDS = {
|
|
|
2313
2982
|
'authz-serve': commandAuthzServe,
|
|
2314
2983
|
'authz-keys': commandAuthzKeys,
|
|
2315
2984
|
'xmpp-serve': commandXmppServe,
|
|
2316
|
-
'webdav-serve': commandWebdavServe
|
|
2985
|
+
'webdav-serve': commandWebdavServe,
|
|
2986
|
+
'webdav-user': commandWebdavUser,
|
|
2987
|
+
'xmpp-http-proxy': commandXmppHttpProxy,
|
|
2988
|
+
'lws-keygen': commandLwsKeygen
|
|
2317
2989
|
}
|
|
2318
2990
|
|
|
2319
2991
|
// ----------------------------------------------------------------- main
|