clavarium 0.2.379-next.1 → 0.2.381-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deploy/clavarium-host +12 -1
- package/deploy/clavarium-tunnel +14 -3
- package/deploy/clavarium.mjs +4 -0
- package/deploy/install.sh +8 -3
- package/deploy/online.mjs +232 -7
- package/deploy/relay.sh +53 -27
- package/deploy/tunnel.mjs +7 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/main.mjs +5 -2
- package/server/relayjoin.mjs +67 -0
- package/web/app.js +4 -1
package/deploy/clavarium-host
CHANGED
|
@@ -166,7 +166,7 @@ CADDYFILE="/etc/caddy/Caddyfile"
|
|
|
166
166
|
MESH_CADDY="/etc/caddy/clavarium-meshcentral.caddy"
|
|
167
167
|
|
|
168
168
|
refuse() { echo "REFUSED: $*" >&2; exit 78; }
|
|
169
|
-
usage() { echo "usage: clavarium-host [--say-only [--data DIR] [--mnt DIR]] <vm|shelf|folder|work|view|tools|mounts|operator|machines|disks|net|git|server|update|wifi|meshcentral> <verb> [arguments]" >&2; exit 64; }
|
|
169
|
+
usage() { echo "usage: clavarium-host [--say-only [--data DIR] [--mnt DIR]] <vm|shelf|folder|work|view|tools|mounts|operator|machines|disks|net|git|server|update|wifi|meshcentral|relay> <verb> [arguments]" >&2; exit 64; }
|
|
170
170
|
|
|
171
171
|
SAY="no"; DATA=""; MNT=""; TEST=""
|
|
172
172
|
while [ $# -gt 0 ]; do
|
|
@@ -738,6 +738,8 @@ AREA="$1"; VERB="$2"; shift 2
|
|
|
738
738
|
# verbs, and commands run inside a machine (the server's own probes, mostly), are not written.
|
|
739
739
|
case "$AREA $VERB" in
|
|
740
740
|
"vm exec"|"vm ping"|"vm states"|"vm info"|"server logs"|"server room"|"server usage"|"shelf info"|"shelf room"|"shelf list"|"shelf size"|"shelf exec"|"git read"|"work room"|"disks list"|"disks room"|"machines places"|"machines moving"|"machines old"|"net taps"|"net leases"|"wifi status"|"wifi scan"|"path held"|"disk room"|"update check"|"meshcentral status"|"operator "*) : ;;
|
|
741
|
+
# A one-time code is not written down, even spent.
|
|
742
|
+
"relay join") [ "$SAY" = yes ] || logger -t clavarium-host -- "relay join (a home asks to join the relay)" 2>/dev/null || true ;;
|
|
741
743
|
*) [ "$SAY" = yes ] || logger -t clavarium-host -- "$AREA $VERB$(printf ' %.80s' "$@" | head -c 400)" 2>/dev/null || true ;;
|
|
742
744
|
esac
|
|
743
745
|
|
|
@@ -1521,6 +1523,15 @@ OOMScoreAdjust=-900'
|
|
|
1521
1523
|
systemctl stop "clvread-$1_*.service" >/dev/null 2>&1 || true
|
|
1522
1524
|
systemctl reset-failed "clvread-$1_*.service" >/dev/null 2>&1 || true
|
|
1523
1525
|
exit 0 ;;
|
|
1526
|
+
"relay join")
|
|
1527
|
+
# A HOME ASKS TO JOIN THIS MACHINE'S RELAY (server/relayjoin.mjs, POST /c/relay-join): <code>
|
|
1528
|
+
# ssh-ed25519 <key>. The code is one the owner made here as root (`sudo clavarium relay invite
|
|
1529
|
+
# NAME`); its hash, its name and its end are kept where the server cannot read them. Checked,
|
|
1530
|
+
# spent and acted on as root by deploy/online.mjs (relay join): a wrong, used or expired code is
|
|
1531
|
+
# refused (78) and counted; a good one adds this PUBLIC key to the relay, bounded to two ports
|
|
1532
|
+
# of its own. What it prints holds nothing secret: the relay's address, fingerprint, two ports.
|
|
1533
|
+
[ $# -eq 3 ] && [[ "$1" =~ ^[A-HJKMNP-Z2-9]{8}$ ]] && [ "$2" = "ssh-ed25519" ] && [[ "$3" =~ ^[A-Za-z0-9+/]{68}$ ]] || usage
|
|
1534
|
+
run "$UPDATER_NODE" "$CODE/current/app/deploy/clavarium.mjs" relay join "$1" "$2 $3" ;;
|
|
1524
1535
|
"update check")
|
|
1525
1536
|
# Asks the registry what is out on the channel, now, and prints what
|
|
1526
1537
|
# the updater knows (state.json). No argument: the registry, the name
|
package/deploy/clavarium-tunnel
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
# written by install.sh and kept up to date by the updater). Root installs it at
|
|
5
5
|
# /usr/local/sbin/clavarium-tunnel; it runs as the clavarium-tunnel account.
|
|
6
6
|
#
|
|
7
|
-
# clavarium-tunnel RELAY PORT
|
|
7
|
+
# clavarium-tunnel RELAY PORT [TLS-PORT HTTP-PORT]
|
|
8
|
+
#
|
|
9
|
+
# WITHOUT TLS-PORT AND HTTP-PORT: the relay's first home (its principal), on
|
|
10
|
+
# 8443 and 8080, held on every address of the relay, as it always was. WITH
|
|
11
|
+
# THEM: a home the principal added (clavarium-relay add-home), on the ports it
|
|
12
|
+
# was given, held on the relay's 127.0.0.1 only -- its router alone reaches
|
|
13
|
+
# them, and the relay lets this key hold no other port.
|
|
8
14
|
#
|
|
9
15
|
# A CUT OF THE NETWORK is noticed in 20 to 30 seconds (a keepalive every 10 s,
|
|
10
16
|
# two missed), and the tunnel is tried again 5 s later by systemd, as long as it
|
|
@@ -17,8 +23,13 @@
|
|
|
17
23
|
# the count starts over.
|
|
18
24
|
# ---------------------------------------------------------------------------
|
|
19
25
|
set -u
|
|
20
|
-
[ $# -eq 2 ] || { echo "usage: clavarium-tunnel RELAY PORT" >&2; exit 64; }
|
|
26
|
+
[ $# -eq 2 ] || [ $# -eq 4 ] || { echo "usage: clavarium-tunnel RELAY PORT [TLS-PORT HTTP-PORT]" >&2; exit 64; }
|
|
21
27
|
relay="$1"; port="$2"
|
|
28
|
+
forwards=(-R '*:8443:127.0.0.1:443' -R '*:8080:127.0.0.1:80')
|
|
29
|
+
if [ $# -eq 4 ]; then
|
|
30
|
+
for p in "$3" "$4"; do [[ "$p" =~ ^[0-9]{4,5}$ ]] && [ "$p" -le 65535 ] || { echo "clavarium-tunnel: not a port: $p" >&2; exit 64; }; done
|
|
31
|
+
forwards=(-R "127.0.0.1:$3:127.0.0.1:443" -R "127.0.0.1:$4:127.0.0.1:80")
|
|
32
|
+
fi
|
|
22
33
|
[[ "$relay" =~ ^[A-Za-z0-9.-]{1,253}$ ]] || { echo "clavarium-tunnel: not a host name: $relay" >&2; exit 64; }
|
|
23
34
|
[[ "$port" =~ ^[0-9]{1,5}$ ]] || { echo "clavarium-tunnel: not a port: $port" >&2; exit 64; }
|
|
24
35
|
home=/var/lib/clavarium-tunnel
|
|
@@ -30,7 +41,7 @@ trap 'rm -f "$said"' EXIT
|
|
|
30
41
|
started=$SECONDS
|
|
31
42
|
ssh -N -T -o BatchMode=yes -o IdentitiesOnly=yes -o "IdentityFile=$home/key" -o "UserKnownHostsFile=$home/known_hosts" \
|
|
32
43
|
-o StrictHostKeyChecking=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=2 -o ConnectTimeout=15 \
|
|
33
|
-
|
|
44
|
+
"${forwards[@]}" -p "$port" "clavarium-tunnel@$relay" 2>"$said"
|
|
34
45
|
code=$?
|
|
35
46
|
# Said to the journal once it ended -- read through a second writer (tee) it could still be on its way
|
|
36
47
|
# when it was looked at (found on a CI runner): the refusal then passed for a cut.
|
package/deploy/clavarium.mjs
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* sudo clavarium online --relay HOST --domain NAME [...] see deploy/online.mjs
|
|
12
12
|
* sudo clavarium online --port --domain NAME [...]
|
|
13
13
|
* sudo clavarium key the public keys, text and QR
|
|
14
|
+
* sudo clavarium online --relay PRINCIPAL --domain NAME --join CODE another home on the same relay
|
|
14
15
|
* sudo clavarium relay status | repair | update-os | update-relay | forget-setup-key
|
|
16
|
+
* sudo clavarium relay homes | invite NAME | add-home NAME --key KEY | remove-home NAME
|
|
15
17
|
* sudo clavarium doctor [--fix] what works, what does not, what to do
|
|
16
18
|
* sudo clavarium password EMAIL a new password for an administrator
|
|
17
19
|
* sudo clavarium client-id [ID] the sign-in client id (no ID: a random one)
|
|
@@ -70,7 +72,9 @@ if (verb !== 'install') {
|
|
|
70
72
|
+ ' sudo clavarium online --relay HOST --domain NAME then online, through a relay of yours\n'
|
|
71
73
|
+ ' sudo clavarium online --port --domain NAME or with ports 80 and 443 open\n'
|
|
72
74
|
+ ' sudo clavarium key the public keys again (text and QR)\n'
|
|
75
|
+
+ ' sudo clavarium online --relay PRINCIPAL --domain NAME --join CODE another home on the relay of PRINCIPAL\n'
|
|
73
76
|
+ ' sudo clavarium relay status|repair|update-os|update-relay|forget-setup-key\n'
|
|
77
|
+
+ ' sudo clavarium relay homes|invite NAME|add-home NAME --key KEY|remove-home NAME\n'
|
|
74
78
|
+ ' sudo clavarium doctor [--fix] what works, what does not, what to do\n'
|
|
75
79
|
+ ' sudo clavarium password EMAIL a new password for an administrator\n'
|
|
76
80
|
+ ' sudo clavarium client-id [ID] the sign-in client id (no ID: a random one)\n\n'
|
package/deploy/install.sh
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
# server mode)
|
|
39
39
|
# --relay-installed the relay was set up already (by `clavarium online`):
|
|
40
40
|
# do not print the command to run on it
|
|
41
|
+
# --relay-ports T,H the two ports the relay gave this machine, when it is
|
|
42
|
+
# not the relay's first home (`clavarium online --join`)
|
|
41
43
|
# --name NAME the name of this Clavarium, under its icon and in its
|
|
42
44
|
# tab (default: drawn from --domain); changed in Settings.
|
|
43
45
|
# Letters, digits, spaces, dots, hyphens: 30 at most
|
|
@@ -118,7 +120,7 @@ bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator
|
|
|
118
120
|
refuse() { echo "install: REFUSED: $*" >&2; exit 78; }
|
|
119
121
|
step() { echo "== $*"; }
|
|
120
122
|
|
|
121
|
-
DOMAIN=""; OPERATORS=""; PORT="7717"; CORE_ONLY="no"; DRY="no"; RELAY=""; RELAY_PORT="22"; LOCAL="no"; KEEP_RELEASE="no"; RELAY_INSTALLED="no"
|
|
123
|
+
DOMAIN=""; OPERATORS=""; PORT="7717"; CORE_ONLY="no"; DRY="no"; RELAY=""; RELAY_PORT="22"; RELAY_PORTS=""; LOCAL="no"; KEEP_RELEASE="no"; RELAY_INSTALLED="no"
|
|
122
124
|
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""; APP_NAME=""; CLIENT_ID=""
|
|
123
125
|
while [ $# -gt 0 ]; do
|
|
124
126
|
case "$1" in
|
|
@@ -136,6 +138,7 @@ while [ $# -gt 0 ]; do
|
|
|
136
138
|
--channel) CHANNEL="${2:-}"; shift 2 || bad "--channel needs a value" ;;
|
|
137
139
|
--keep-release) KEEP_RELEASE="yes"; shift ;;
|
|
138
140
|
--relay-installed) RELAY_INSTALLED="yes"; shift ;;
|
|
141
|
+
--relay-ports) RELAY_PORTS="${2:-}"; shift 2 || bad "--relay-ports needs a value" ;;
|
|
139
142
|
--name) APP_NAME="${2:-}"; [ -n "$APP_NAME" ] || bad "--name needs a value"; shift 2 ;;
|
|
140
143
|
--client-id) CLIENT_ID="${2:-}"; [ -n "$CLIENT_ID" ] || bad "--client-id needs a value"; shift 2 ;;
|
|
141
144
|
--dry-run) DRY="yes"; shift ;;
|
|
@@ -161,7 +164,9 @@ if [ -n "$RELAY" ]; then
|
|
|
161
164
|
[[ "$RELAY" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$|^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || bad "--relay is the name or the address of the relay machine"
|
|
162
165
|
[[ "$RELAY_PORT" =~ ^[0-9]{1,5}$ ]] && [ "$RELAY_PORT" -ge 1 ] && [ "$RELAY_PORT" -le 65535 ] || bad "--relay-port is a port number"
|
|
163
166
|
[ "$CORE_ONLY" = "no" ] || bad "--relay comes with the edge: it cannot go with --core-only"
|
|
167
|
+
[ -z "$RELAY_PORTS" ] || [[ "$RELAY_PORTS" =~ ^[0-9]{4,5},[0-9]{4,5}$ ]] || bad "--relay-ports is two ports: TLS,HTTP"
|
|
164
168
|
fi
|
|
169
|
+
[ -z "$RELAY_PORTS" ] || [ -n "$RELAY" ] || bad "--relay-ports goes with --relay"
|
|
165
170
|
|
|
166
171
|
# WHAT IS INSTALLED: an exact version from npm, or a working tree (development).
|
|
167
172
|
[ -z "$VERSION" ] || [[ "$VERSION" =~ ^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,40})?$ ]] || bad "--version is an exact version, like 1.2.3 or 1.3.0-next.1"
|
|
@@ -744,8 +749,8 @@ if [ -n "$RELAY" ]; then
|
|
|
744
749
|
fi
|
|
745
750
|
# The unit is the updater's (deploy/tunnel.mjs, tunnelUnit): one text, kept up to date by each update.
|
|
746
751
|
install -o root -g root -m 0755 "$APP/deploy/clavarium-tunnel" /usr/local/sbin/clavarium-tunnel
|
|
747
|
-
"$NODE_DIR/bin/node" --input-type=module -e 'const { tunnelUnit } = await import(process.argv[1]); process.stdout.write(tunnelUnit(process.argv[2], process.argv[3]));' \
|
|
748
|
-
"$APP/deploy/tunnel.mjs" "$RELAY" "$RELAY_PORT" > /etc/systemd/system/clavarium-tunnel.service
|
|
752
|
+
"$NODE_DIR/bin/node" --input-type=module -e 'const { tunnelUnit } = await import(process.argv[1]); const [t, h] = (process.argv[4] || "").split(","); process.stdout.write(tunnelUnit(process.argv[2], process.argv[3], undefined, t ? { tls: t, http: h } : null));' \
|
|
753
|
+
"$APP/deploy/tunnel.mjs" "$RELAY" "$RELAY_PORT" "$RELAY_PORTS" > /etc/systemd/system/clavarium-tunnel.service
|
|
749
754
|
systemctl daemon-reload
|
|
750
755
|
systemctl enable --quiet clavarium-tunnel.service
|
|
751
756
|
systemctl restart clavarium-tunnel.service
|
package/deploy/online.mjs
CHANGED
|
@@ -13,6 +13,26 @@
|
|
|
13
13
|
* --port this machine is reachable on 80 and 443 (a server at a host,
|
|
14
14
|
* or a router whose ports were opened).
|
|
15
15
|
*
|
|
16
|
+
* SEVERAL HOMES ON ONE RELAY. The machine that set the relay up is its
|
|
17
|
+
* PRINCIPAL: it alone holds the relay's maintenance key. Another Clavarium
|
|
18
|
+
* joins the same relay without any access to it:
|
|
19
|
+
*
|
|
20
|
+
* on the principal sudo clavarium relay invite NAME a one-time code (8 letters and
|
|
21
|
+
* digits, 30 minutes, good once) for the domain NAME
|
|
22
|
+
* on the new home sudo clavarium online --relay PRINCIPAL --domain NAME --join CODE
|
|
23
|
+
*
|
|
24
|
+
* The new home sends the code and its PUBLIC tunnel key to the principal over
|
|
25
|
+
* HTTPS (POST /c/relay-join, server/relayjoin.mjs, then the host program, then
|
|
26
|
+
* `clavarium relay join` here, as root). The principal checks the code, spends
|
|
27
|
+
* it, adds the key to the relay (clavarium-relay add-home, through its
|
|
28
|
+
* maintenance key) and answers with the relay's address, its host key's
|
|
29
|
+
* fingerprint and the two ports the relay gave. The new home checks that
|
|
30
|
+
* fingerprint against the relay itself, and goes online on its own ports.
|
|
31
|
+
* Nothing else is copied by hand. By hand all the same (no code): on the
|
|
32
|
+
* principal `sudo clavarium relay add-home NAME --key KEY`, which prints the
|
|
33
|
+
* line to type on the new home (--relay-ports, --relay-fingerprint).
|
|
34
|
+
* A second owner is let in this way only: by the principal's owner.
|
|
35
|
+
*
|
|
16
36
|
* NOTHING IS CUT BEFORE A REAL TEST FROM THE INTERNET HAS PASSED: Let's
|
|
17
37
|
* Encrypt's staging service is asked for a certificate for the domain, so it
|
|
18
38
|
* really comes to this machine (through the relay, or through the open
|
|
@@ -24,6 +44,7 @@
|
|
|
24
44
|
* `sys`, so the tests can stand in for SSH, npm and DNS.
|
|
25
45
|
*/
|
|
26
46
|
import { spawn } from 'node:child_process';
|
|
47
|
+
import { createHash, randomInt } from 'node:crypto';
|
|
27
48
|
import { relayOfUnit, TUNNEL_UNIT } from './tunnel.mjs';
|
|
28
49
|
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, mkdtempSync, renameSync, openSync, readSync, writeSync, closeSync } from 'node:fs';
|
|
29
50
|
import { promises as dns } from 'node:dns';
|
|
@@ -62,11 +83,13 @@ export class Failed extends Error {}
|
|
|
62
83
|
export const USAGE = [
|
|
63
84
|
'usage: sudo clavarium online --relay HOST --domain NAME [--relay-user USER] [--relay-port N] [--admin EMAIL]',
|
|
64
85
|
' [--relay-fingerprint SHA256:...] [--keep-setup-key] [--yes]',
|
|
86
|
+
' or: sudo clavarium online --relay PRINCIPAL --domain NAME --join CODE [--admin EMAIL]',
|
|
87
|
+
' or: sudo clavarium online --relay HOST --domain NAME --relay-ports TLS,HTTP --relay-fingerprint SHA256:... [--relay-port N]',
|
|
65
88
|
' or: sudo clavarium online --port --domain NAME [--admin EMAIL]',
|
|
66
89
|
].join('\n');
|
|
67
90
|
|
|
68
91
|
export function parseOnline(args) {
|
|
69
|
-
const o = { askRelay: false, relay: '', user: 'root', relayPort: '22', domain: '', admin: '', fingerprint: '', keepSetupKey: false, yes: false, port: false };
|
|
92
|
+
const o = { askRelay: false, relay: '', user: 'root', relayPort: '22', domain: '', admin: '', fingerprint: '', keepSetupKey: false, yes: false, port: false, join: '', ports: null };
|
|
70
93
|
const value = (i, name) => { const v = args[i + 1]; if (v === undefined || v.startsWith('--')) throw new Bad(`${name} needs a value`); return v; };
|
|
71
94
|
for (let i = 0; i < args.length; i++) {
|
|
72
95
|
const a = args[i];
|
|
@@ -80,9 +103,25 @@ export function parseOnline(args) {
|
|
|
80
103
|
case '--keep-setup-key': o.keepSetupKey = true; break;
|
|
81
104
|
case '--yes': o.yes = true; break;
|
|
82
105
|
case '--port': o.port = true; break;
|
|
106
|
+
case '--join': o.join = normalCode(value(i, a)); i++; if (!CODE.test(o.join)) throw new Bad('--join is the code the principal gave: 8 letters and digits, like ABCD-EFGH'); break;
|
|
107
|
+
case '--relay-ports': {
|
|
108
|
+
const m = /^(\d{4,5}),(\d{4,5})$/.exec(value(i, a)); i++;
|
|
109
|
+
if (!m || [m[1], m[2]].some((p) => +p < 1024 || +p > 65535)) throw new Bad('--relay-ports is the two ports the relay gave: TLS,HTTP');
|
|
110
|
+
o.ports = { tls: m[1], http: m[2] }; break;
|
|
111
|
+
}
|
|
83
112
|
default: throw new Bad(`unknown parameter: ${a}`);
|
|
84
113
|
}
|
|
85
114
|
}
|
|
115
|
+
if (o.join || o.ports) {
|
|
116
|
+
/* A home the principal adds: the relay is not set up from here, and no account of it is used. */
|
|
117
|
+
if (o.join && o.ports) throw new Bad('--join or --relay-ports, not both');
|
|
118
|
+
if (!o.relay) throw new Bad(o.join ? '--join goes with --relay PRINCIPAL: the name of the Clavarium that gave the code' : '--relay-ports goes with --relay HOST');
|
|
119
|
+
if (o.keepSetupKey || o.user !== 'root') throw new Bad('--relay-user and --keep-setup-key are for the relay\'s first home; a home that joins uses no account of the relay');
|
|
120
|
+
if (o.join && !DOMAIN.test(o.relay)) throw new Bad('--relay is the NAME of the Clavarium that gave the code (it is asked over HTTPS)');
|
|
121
|
+
if (o.join && (o.fingerprint || o.relayPort !== '22')) throw new Bad('with --join, the principal says the relay\'s address and fingerprint');
|
|
122
|
+
if (o.ports && !o.fingerprint) throw new Bad('--relay-ports goes with --relay-fingerprint (what the principal printed)');
|
|
123
|
+
if (o.join) o.principal = o.relay;
|
|
124
|
+
}
|
|
86
125
|
if (o.relay && o.port) throw new Bad('say how it will be reached: --relay HOST or --port, not both');
|
|
87
126
|
/* Neither: the usual case -- the domain points to the relay, which is
|
|
88
127
|
reached by the domain itself (its address is not needed). */
|
|
@@ -196,6 +235,8 @@ export async function keepRelayCurrent(sys, P = PATHS, { done, unit = TUNNEL_UNI
|
|
|
196
235
|
let conf = readRelayConf(P);
|
|
197
236
|
if (!conf) { try { const at = relayOfUnit(readFileSync(unit, 'utf8')); if (at) conf = { HOST: at.relay, PORT: at.port }; } catch { /* no tunnel */ } }
|
|
198
237
|
if (!conf) return { state: 'none' };
|
|
238
|
+
/* A home the principal added: the relay is the principal's to keep. */
|
|
239
|
+
if (conf.ROLE === 'home') return { state: 'none' };
|
|
199
240
|
if (!existsSync(keyFiles(P).keeper)) return { state: 'no-key' };
|
|
200
241
|
const version = relayVersion(P);
|
|
201
242
|
if (!version) return { state: 'none' };
|
|
@@ -407,6 +448,7 @@ async function setUpRelay(sys, P, o, keys) {
|
|
|
407
448
|
const f = keyFiles(P);
|
|
408
449
|
const conf = { HOST: o.relay, PORT: o.relayPort, USER: o.user };
|
|
409
450
|
const prior = readRelayConf(P);
|
|
451
|
+
if (prior?.ROLE === 'home') throw new Refused(`this machine is a home of the relay ${prior.HOST}, added by its principal: it does not set a relay up`);
|
|
410
452
|
/* Run again after the relay was set up (a test that failed, a DNS record
|
|
411
453
|
made late): the maintenance account answers, the setup key is not needed. */
|
|
412
454
|
if (prior && prior.HOST === o.relay && existsSync(f.knownHosts)) {
|
|
@@ -447,7 +489,7 @@ async function setUpRelay(sys, P, o, keys) {
|
|
|
447
489
|
input, a program it starts (apt-get...) could eat the rest of it. */
|
|
448
490
|
const runner = 'f="$(mktemp)"; cat > "$f"; bash "$f" "$@" </dev/null; s=$?; rm -f -- "$f"; exit $s';
|
|
449
491
|
const r = await sys.run(['ssh', ...sshOptions(f.setup, f.knownHosts, o.relayPort), `${o.user}@${o.relay}`,
|
|
450
|
-
`${sudo}bash -c ${quote(runner)} relay.sh --key ${quote(keys.tunnel)} --keeper-key ${quote(keys.keeper)}`], { input: script, stream: true });
|
|
492
|
+
`${sudo}bash -c ${quote(runner)} relay.sh --key ${quote(keys.tunnel)} --keeper-key ${quote(keys.keeper)} --name ${quote(o.domain)}`], { input: script, stream: true });
|
|
451
493
|
if (r.code !== 0) throw new Failed(`the relay could not be set up (exit ${r.code}, above: why). This machine is still in local mode`);
|
|
452
494
|
const next = { ...conf, SETUP_KEY: 'kept' };
|
|
453
495
|
writeRelayConf(P, next);
|
|
@@ -459,11 +501,52 @@ async function setUpRelay(sys, P, o, keys) {
|
|
|
459
501
|
return readRelayConf(P);
|
|
460
502
|
}
|
|
461
503
|
|
|
462
|
-
/*
|
|
504
|
+
/* A HOME THE PRINCIPAL ADDED: the relay's host key checked against the fingerprint the principal gave
|
|
505
|
+
(`from`: who gave it), kept for the tunnel, and the relay and its ports kept in relay.conf. The relay
|
|
506
|
+
is maintained by its principal: this machine holds no key of its maintenance account. */
|
|
507
|
+
async function useRelay(sys, P, o, at, from) {
|
|
508
|
+
o.relay = at.host; o.relayPort = String(at.port); o.ports = { tls: String(at.tls), http: String(at.http) };
|
|
509
|
+
const scanned = await scanRelay(sys, o, sys.now() + WAIT_MS);
|
|
510
|
+
const { file: pending, print } = await fingerprintOf(sys, P, scanned);
|
|
511
|
+
rmSync(pending, { force: true });
|
|
512
|
+
if (print !== at.fingerprint) throw new Refused(`${o.relay} answers with the key ${print}, not ${at.fingerprint} (said by ${from}): it may not be the relay. Nothing was changed`);
|
|
513
|
+
sys.print(`The relay ${o.relay}: its fingerprint is the one ${from} gave. Its ports for this machine: ${o.ports.tls} and ${o.ports.http}.`);
|
|
514
|
+
writeFileSync(join(P.tunnel, 'known_hosts'), `${scanned}\n`, { mode: 0o644 });
|
|
515
|
+
await sys.run(['chown', 'clavarium-tunnel:clavarium-tunnel', join(P.tunnel, 'known_hosts')]);
|
|
516
|
+
writeRelayConf(P, { HOST: o.relay, PORT: o.relayPort, TLS_PORT: o.ports.tls, HTTP_PORT: o.ports.http, ROLE: 'home', ...(o.principal ? { PRINCIPAL: o.principal } : {}) });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* --join: the code and this machine's PUBLIC tunnel key to the principal, over HTTPS (its certificate
|
|
520
|
+
checked as any browser would); its answer is read field by field. Run again after a failed test: the
|
|
521
|
+
relay and the ports kept from the first time, no code needed. */
|
|
522
|
+
async function joinRelay(sys, P, o, keys) {
|
|
523
|
+
const prior = readRelayConf(P);
|
|
524
|
+
if (prior?.ROLE === 'home' && prior.TLS_PORT && existsSync(join(P.tunnel, 'known_hosts'))) {
|
|
525
|
+
Object.assign(o, { relay: prior.HOST, relayPort: prior.PORT, ports: { tls: prior.TLS_PORT, http: prior.HTTP_PORT } });
|
|
526
|
+
sys.print(`This machine joined the relay ${prior.HOST} already (ports ${prior.TLS_PORT} and ${prior.HTTP_PORT}).`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
sys.print(`Asking ${o.principal} to add this machine to its relay (code ${o.join.slice(0, 4)}-${o.join.slice(4)})...`);
|
|
530
|
+
let r;
|
|
531
|
+
try { r = await sys.post(`https://${o.principal}${JOIN_PATH}`, { code: o.join, key: keys.tunnel.split(' ').slice(0, 2).join(' ') }); } catch (error) { throw new Failed(`${o.principal} did not answer over HTTPS: ${error.message}`); }
|
|
532
|
+
const said = typeof r?.body?.error === 'string' ? r.body.error.slice(0, 200) : '';
|
|
533
|
+
if (r?.status === 403) throw new Refused(`${o.principal} refused the code${said ? `: ${said}` : ''}. Ask its owner for a new one: sudo clavarium relay invite ${o.domain}`);
|
|
534
|
+
if (r?.status === 429) throw new Failed(`${o.principal} says too many tries: wait a minute, then run this again`);
|
|
535
|
+
if (r?.status !== 200) throw new Failed(`${o.principal} did not add this machine (HTTP ${r?.status})${said ? `: ${said}` : ''}`);
|
|
536
|
+
const a = r.body ?? {};
|
|
537
|
+
if (a.name !== o.domain) throw new Refused(`the code was made for ${String(a.name).slice(0, 80)}, not ${o.domain}`);
|
|
538
|
+
const port = (v, lo) => Number.isInteger(v) && v >= lo && v <= 65535;
|
|
539
|
+
if (!HOST.test(a.relay?.host ?? '') || !port(a.relay?.port, 1) || !PRINT.test(a.relay?.fingerprint ?? '') || !port(a.ports?.tls, 1024) || !port(a.ports?.http, 1024)) throw new Failed(`${o.principal} answered something else than a relay and two ports`);
|
|
540
|
+
await useRelay(sys, P, o, { host: a.relay.host, port: a.relay.port, fingerprint: a.relay.fingerprint, tls: a.ports.tls, http: a.ports.http }, o.principal);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* The tunnel, for the test only: the service comes with server mode. The relay's first home on 8443
|
|
544
|
+
and 8080 (every address, as ever); a home it added on its own ports, on the relay's loopback. */
|
|
463
545
|
function startTunnel(sys, P, o) {
|
|
464
546
|
const lines = [];
|
|
547
|
+
const forwards = o.ports ? ['-R', `127.0.0.1:${o.ports.tls}:127.0.0.1:443`, '-R', `127.0.0.1:${o.ports.http}:127.0.0.1:80`] : ['-R', '*:8443:127.0.0.1:443', '-R', '*:8080:127.0.0.1:80'];
|
|
465
548
|
const proc = sys.start(['sudo', '-u', 'clavarium-tunnel', 'ssh', '-N', '-T', ...sshOptions(join(P.tunnel, 'key'), join(P.tunnel, 'known_hosts'), o.relayPort),
|
|
466
|
-
'-o', 'ExitOnForwardFailure=yes',
|
|
549
|
+
'-o', 'ExitOnForwardFailure=yes', ...forwards, `clavarium-tunnel@${o.relay}`], { onLine: (l) => lines.push(l) });
|
|
467
550
|
return { proc, lines };
|
|
468
551
|
}
|
|
469
552
|
|
|
@@ -477,6 +560,7 @@ async function switchToServer(sys, P, o) {
|
|
|
477
560
|
const port = readConf(P.updateConf).PORT;
|
|
478
561
|
const argv = ['/bin/bash', join(app, 'deploy', 'install.sh'), '--keep-release', '--domain', o.domain, '--operator', o.admin, '--port', port];
|
|
479
562
|
if (o.relay) argv.push('--relay', o.relay, '--relay-port', o.relayPort, '--relay-installed');
|
|
563
|
+
if (o.relay && o.ports) argv.push('--relay-ports', `${o.ports.tls},${o.ports.http}`);
|
|
480
564
|
sys.print('');
|
|
481
565
|
sys.print('Switching to server mode: HTTPS, sign-in, the administrator...');
|
|
482
566
|
const r = await sys.run(argv, { stream: true, inherit: true, env: { CLAVARIUM_PASSWORDS_TO: P.passwords } });
|
|
@@ -514,7 +598,9 @@ export async function online(o, sys, P = PATHS) {
|
|
|
514
598
|
}
|
|
515
599
|
await checkDns(sys, o);
|
|
516
600
|
await portsFree(sys);
|
|
517
|
-
if (o.
|
|
601
|
+
if (o.join) await joinRelay(sys, P, o, keys);
|
|
602
|
+
else if (o.ports) await useRelay(sys, P, o, { host: o.relay, port: o.relayPort, fingerprint: o.fingerprint, tls: o.ports.tls, http: o.ports.http }, 'the principal');
|
|
603
|
+
else if (o.relay) await setUpRelay(sys, P, o, keys);
|
|
518
604
|
let tunnel = null;
|
|
519
605
|
try {
|
|
520
606
|
if (o.relay) {
|
|
@@ -532,7 +618,13 @@ export async function online(o, sys, P = PATHS) {
|
|
|
532
618
|
await doctor(sys, {}, { ...DOCTOR_PATHS, unit: P.unit, relayConf: P.relayConf });
|
|
533
619
|
sys.print('');
|
|
534
620
|
sys.print(`Online: https://${o.domain}`);
|
|
535
|
-
if (o.relay) sys.print('The relay is watched from here: sudo clavarium relay status');
|
|
621
|
+
if (o.relay && !o.ports) sys.print('The relay is watched from here: sudo clavarium relay status');
|
|
622
|
+
if (o.relay && o.ports) {
|
|
623
|
+
sys.print(`The relay is kept by its principal${o.principal ? `, ${o.principal}` : ''}.`);
|
|
624
|
+
sys.print(`TO KEEP THE RELAY BLIND for ${o.domain}, add a CAA record to your DNS that names this machine's`);
|
|
625
|
+
sys.print('certificate account (accounturi=...); without it, whoever controls the relay could obtain a');
|
|
626
|
+
sys.print(`certificate for ${o.domain}. The account address: sudo grep -rho '"location": *"[^"]*"' /var/lib/caddy/caddy/acme`);
|
|
627
|
+
}
|
|
536
628
|
sys.print('Anything wrong later: sudo clavarium doctor (it says what to do; --fix repairs what it can)');
|
|
537
629
|
const pw = existsSync(P.passwords) ? readFileSync(P.passwords, 'utf8') : '';
|
|
538
630
|
sys.print('');
|
|
@@ -543,12 +635,138 @@ export async function online(o, sys, P = PATHS) {
|
|
|
543
635
|
} finally { rmSync(P.passwords, { force: true }); }
|
|
544
636
|
}
|
|
545
637
|
|
|
638
|
+
/* ------------------------- other homes on this relay ------------------------- */
|
|
639
|
+
/* 31 letters and digits a person can read and type: no I, L, O, 0, 1. Eight of them: some 10^12 codes,
|
|
640
|
+
against WRONG_MAX wrong tries before every invitation is cancelled. */
|
|
641
|
+
export const CODE_LETTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
642
|
+
const CODE = /^[A-HJKMNP-Z2-9]{8}$/;
|
|
643
|
+
export const INVITE_MS = 30 * 60_000;
|
|
644
|
+
export const WRONG_MAX = 10;
|
|
645
|
+
export const JOIN_PATH = '/c/relay-join';
|
|
646
|
+
export const normalCode = (s) => String(s ?? '').toUpperCase().replace(/[\s-]/g, '');
|
|
647
|
+
const hashOf = (code) => createHash('sha256').update(code).digest('hex');
|
|
648
|
+
const invitesFile = (P) => join(P.access, 'invites.json');
|
|
649
|
+
/* Root's, in the access folder (0700): only the hash of each code is kept, with its name and its end. */
|
|
650
|
+
function readInvites(P, now) {
|
|
651
|
+
let j = {};
|
|
652
|
+
try { j = JSON.parse(readFileSync(invitesFile(P), 'utf8')); } catch { /* none yet */ }
|
|
653
|
+
return {
|
|
654
|
+
invites: (Array.isArray(j.invites) ? j.invites : []).filter((x) => x && /^[0-9a-f]{64}$/.test(x.hash) && DOMAIN.test(x.name ?? '') && x.until > now),
|
|
655
|
+
wrong: (Array.isArray(j.wrong) ? j.wrong : []).filter((t) => typeof t === 'number' && t > now - INVITE_MS),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function writeInvites(P, v) {
|
|
659
|
+
mkdirSync(P.access, { recursive: true, mode: 0o700 });
|
|
660
|
+
writeFileSync(`${invitesFile(P)}.new`, `${JSON.stringify(v)}\n`, { mode: 0o600 });
|
|
661
|
+
renameSync(`${invitesFile(P)}.new`, invitesFile(P));
|
|
662
|
+
}
|
|
663
|
+
/* Only the principal adds homes: the machine that holds the relay's maintenance key, online. */
|
|
664
|
+
function principal(P) {
|
|
665
|
+
const conf = readRelayConf(P);
|
|
666
|
+
if (!conf) throw new Refused('this machine has no relay (sudo clavarium online --relay HOST --domain NAME)');
|
|
667
|
+
if (conf.ROLE === 'home') throw new Refused(`this machine is a home of the relay ${conf.HOST}: only its principal${conf.PRINCIPAL ? ` (${conf.PRINCIPAL})` : ''} adds or removes homes`);
|
|
668
|
+
const domain = serverModeOf(P);
|
|
669
|
+
if (!domain) throw new Refused('this machine is not online yet (sudo clavarium online ...)');
|
|
670
|
+
return { conf, domain };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/* `relay invite NAME`: a one-time code for the home that will be NAME. A new one for the same name
|
|
674
|
+
replaces the one before. */
|
|
675
|
+
export async function invite(sys, P, name) {
|
|
676
|
+
if (!DOMAIN.test(name ?? '')) throw new Bad('relay invite NAME: the domain of the new home, lower case, like clav2.example.com');
|
|
677
|
+
const { conf, domain } = principal(P);
|
|
678
|
+
if (name === domain) throw new Bad(`${name} is this machine`);
|
|
679
|
+
const now = sys.now();
|
|
680
|
+
const v = readInvites(P, now);
|
|
681
|
+
const code = Array.from({ length: 8 }, () => CODE_LETTERS[randomInt(CODE_LETTERS.length)]).join('');
|
|
682
|
+
v.invites = [...v.invites.filter((x) => x.name !== name), { hash: hashOf(code), name, until: now + INVITE_MS }];
|
|
683
|
+
writeInvites(P, v);
|
|
684
|
+
const shown = `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
685
|
+
sys.print(`One-time code for ${name} (good once, for ${INVITE_MS / 60_000} minutes): ${shown}`);
|
|
686
|
+
sys.print(`On that machine (Clavarium installed; ${name} leading to the relay, like ${domain}), type:`);
|
|
687
|
+
sys.print(` sudo clavarium online --relay ${domain} --domain ${name} --join ${shown}`);
|
|
688
|
+
sys.print(`It joins the relay ${conf.HOST} on ports of its own. Then, for ${name}, a CAA record as for ${domain} keeps the relay blind.`);
|
|
689
|
+
return { code: shown, name };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/* The principal's name told to the relay (a relay set up before 0.2.0 knew none), then the home added.
|
|
693
|
+
What the relay printed, read line by line. */
|
|
694
|
+
async function addHomeOnRelay(sys, P, conf, domain, name, key) {
|
|
695
|
+
const older = (r) => (/not a verb|unknown verb/.test(`${r.err ?? ''}`) ? ': the relay\'s program is older (sudo clavarium relay update-relay)' : '');
|
|
696
|
+
const n = await keeper(sys, P, conf, ['name-principal', domain]);
|
|
697
|
+
if (n.code !== 0) throw new Failed(`the relay did not take this machine's name (${tail(n)})${older(n)}`);
|
|
698
|
+
const r = await keeper(sys, P, conf, ['add-home', name, key]);
|
|
699
|
+
if (r.code === 78) throw new Refused(`the relay refused: ${tail(r).replace(/^clavarium-relay: REFUSED: /, '')}`);
|
|
700
|
+
if (r.code !== 0) throw new Failed(`the relay did not add ${name} (${tail(r)})${older(r)}`);
|
|
701
|
+
const get = (k) => new RegExp(`^${k} (\\S+)$`, 'm').exec(r.out ?? '')?.[1] ?? '';
|
|
702
|
+
const tls = Number(get('tls-port')); const http = Number(get('http-port')); const fingerprint = get('fingerprint');
|
|
703
|
+
if (!(tls >= 1024 && tls <= 65535 && http >= 1024 && http <= 65535) || !PRINT.test(fingerprint)) throw new Failed('the relay\'s answer is not two ports and a fingerprint');
|
|
704
|
+
return { name, relay: { host: conf.HOST, port: Number(conf.PORT), fingerprint }, ports: { tls, http } };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* `relay join CODE KEY`, as root, for the server's /c/relay-join (through the host program): the code
|
|
708
|
+
must be one of the owner's, unused and not expired. It is spent BEFORE the relay is asked (good once,
|
|
709
|
+
whatever happens next); a wrong one is counted, and WRONG_MAX of them within INVITE_MS cancel every
|
|
710
|
+
invitation. The answer holds nothing secret: the relay's address, its public fingerprint, two ports. */
|
|
711
|
+
export async function joinWithCode(sys, P, given, key) {
|
|
712
|
+
const code = normalCode(given);
|
|
713
|
+
if (!CODE.test(code) || !KEY.test(key ?? '')) throw new Bad('relay join CODE KEY');
|
|
714
|
+
const { conf, domain } = principal(P);
|
|
715
|
+
const now = sys.now();
|
|
716
|
+
const v = readInvites(P, now);
|
|
717
|
+
const at = v.invites.findIndex((x) => x.hash === hashOf(code));
|
|
718
|
+
if (at < 0) {
|
|
719
|
+
v.wrong.push(now);
|
|
720
|
+
const cancel = v.wrong.length >= WRONG_MAX;
|
|
721
|
+
if (cancel) v.invites = [];
|
|
722
|
+
writeInvites(P, v);
|
|
723
|
+
throw new Refused(cancel ? 'not a valid code; too many wrong codes: every invitation was cancelled' : 'not a valid code (wrong, used or expired)');
|
|
724
|
+
}
|
|
725
|
+
const [spent] = v.invites.splice(at, 1);
|
|
726
|
+
writeInvites(P, v);
|
|
727
|
+
return addHomeOnRelay(sys, P, conf, domain, spent.name, key);
|
|
728
|
+
}
|
|
729
|
+
|
|
546
730
|
/* `sudo clavarium relay VERB` */
|
|
547
731
|
export async function relayCommand(args, sys, P = PATHS) {
|
|
548
732
|
const conf = readRelayConf(P);
|
|
549
733
|
if (!conf) throw new Refused('this machine has no relay (sudo clavarium online --relay HOST --domain NAME)');
|
|
550
734
|
const [verb, ...rest] = args;
|
|
735
|
+
if (conf.ROLE === 'home') throw new Refused(`this machine is a home of the relay ${conf.HOST}: the relay is kept by its principal${conf.PRINCIPAL ? `, ${conf.PRINCIPAL}` : ''}`);
|
|
551
736
|
switch (verb) {
|
|
737
|
+
case 'invite': {
|
|
738
|
+
if (rest.length !== 1) throw new Bad('relay invite NAME');
|
|
739
|
+
await invite(sys, P, rest[0]);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
case 'join': {
|
|
743
|
+
if (rest.length !== 2) throw new Bad('relay join CODE KEY');
|
|
744
|
+
sys.print(JSON.stringify(await joinWithCode(sys, P, rest[0], rest[1])));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
case 'add-home': {
|
|
748
|
+
const [name, flag, ...words] = rest;
|
|
749
|
+
const key = words.join(' ');
|
|
750
|
+
if (!DOMAIN.test(name ?? '') || flag !== '--key' || !KEY.test(key)) throw new Bad('relay add-home NAME --key "ssh-ed25519 AAAA..." (the new home\'s tunnel key: sudo clavarium key tunnel, there)');
|
|
751
|
+
const { domain } = principal(P);
|
|
752
|
+
const a = await addHomeOnRelay(sys, P, conf, domain, name, key);
|
|
753
|
+
sys.print(`${name} is a home of the relay ${a.relay.host}, on its ports ${a.ports.tls} and ${a.ports.http}. On that machine, type:`);
|
|
754
|
+
sys.print(` sudo clavarium online --relay ${a.relay.host} --domain ${name} --relay-ports ${a.ports.tls},${a.ports.http} --relay-fingerprint ${a.relay.fingerprint}${a.relay.port !== 22 ? ` --relay-port ${a.relay.port}` : ''}`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
case 'remove-home': {
|
|
758
|
+
if (rest.length !== 1 || !DOMAIN.test(rest[0])) throw new Bad('relay remove-home NAME');
|
|
759
|
+
principal(P);
|
|
760
|
+
const r = await keeper(sys, P, conf, ['remove-home', rest[0]], { stream: true });
|
|
761
|
+
if (r.code !== 0) throw new Failed(`the relay did not remove ${rest[0]} (exit ${r.code})`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
case 'homes': {
|
|
765
|
+
if (rest.length) throw new Bad('relay homes takes nothing');
|
|
766
|
+
const r = await keeper(sys, P, conf, ['homes'], { stream: true });
|
|
767
|
+
if (r.code !== 0) throw new Failed(`the relay answered with exit ${r.code}`);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
552
770
|
case 'forget-setup-key': return forgetSetupKey(sys, P, conf);
|
|
553
771
|
case 'status': case 'repair': case 'update-os': {
|
|
554
772
|
if (rest.length && !(verb === 'update-os' && rest.length === 1 && rest[0] === '--reboot')) throw new Bad(`relay ${verb} takes nothing`);
|
|
@@ -563,7 +781,7 @@ export async function relayCommand(args, sys, P = PATHS) {
|
|
|
563
781
|
if (r.code !== 0) throw new Failed(`the relay did not update (exit ${r.code})`);
|
|
564
782
|
return;
|
|
565
783
|
}
|
|
566
|
-
default: throw new Bad('usage: sudo clavarium relay status | repair | update-os [--reboot] | update-relay [X.Y.Z] | forget-setup-key');
|
|
784
|
+
default: throw new Bad('usage: sudo clavarium relay status | repair | update-os [--reboot] | update-relay [X.Y.Z] | forget-setup-key | homes | invite NAME | add-home NAME --key KEY | remove-home NAME');
|
|
567
785
|
}
|
|
568
786
|
}
|
|
569
787
|
|
|
@@ -601,6 +819,13 @@ export function realSys() {
|
|
|
601
819
|
timer = setTimeout(done, ms);
|
|
602
820
|
try { tty = createReadStream('/dev/tty'); tty.on('data', (d) => { if (d.includes(10) || d.includes(13)) done(); }); tty.on('error', () => {}); } catch { /* no keyboard: the timer alone */ }
|
|
603
821
|
}),
|
|
822
|
+
/* JSON to an address in https, its certificate checked; never a redirect. */
|
|
823
|
+
post: async (url, body) => {
|
|
824
|
+
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(60_000) });
|
|
825
|
+
let json = null;
|
|
826
|
+
try { json = await r.json(); } catch { /* not JSON */ }
|
|
827
|
+
return { status: r.status, body: json };
|
|
828
|
+
},
|
|
604
829
|
hasKeyboard: () => { try { closeSync(openSync('/dev/tty', 'r')); return true; } catch { return false; } },
|
|
605
830
|
askLine: async (question) => {
|
|
606
831
|
let fd;
|
package/deploy/relay.sh
CHANGED
|
@@ -1,45 +1,64 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# ---------------------------------------------------------------------------
|
|
3
|
-
# TURNS THIS MACHINE INTO THE RELAY OF
|
|
4
|
-
# only. Run it on a small machine
|
|
5
|
-
# home dials out to it, so home
|
|
3
|
+
# TURNS THIS MACHINE INTO THE RELAY OF A CLAVARIUM -- and, later, of other
|
|
4
|
+
# Clavarium homes too. One command, parameters only. Run it on a small machine
|
|
5
|
+
# that has a public address; the machine at home dials out to it, so home
|
|
6
|
+
# needs no open port and no fixed address.
|
|
6
7
|
#
|
|
7
|
-
# sudo deploy/relay.sh --key "ssh-ed25519 AAAA... clavarium-tunnel"
|
|
8
|
+
# sudo deploy/relay.sh --key "ssh-ed25519 AAAA... clavarium-tunnel" --name clav.example.com
|
|
8
9
|
#
|
|
9
10
|
# --key LINE the PUBLIC tunnel key of the home machine (`sudo clavarium key`)
|
|
10
11
|
# --keeper-key LINE the PUBLIC maintenance key of the home machine (optional)
|
|
12
|
+
# --name NAME the home's name (its domain); with it, the relay routes by
|
|
13
|
+
# name from the start, and other homes can be added later
|
|
11
14
|
# --dry-run check the parameters, say what would be done, do nothing
|
|
12
15
|
#
|
|
13
16
|
# `sudo clavarium online --relay HOST` at home runs this script here by itself,
|
|
14
|
-
# over SSH, with both keys.
|
|
17
|
+
# over SSH, with both keys and the name. That home is the PRINCIPAL: the only
|
|
18
|
+
# one that holds the maintenance key. Run again on a relay that serves homes
|
|
19
|
+
# already, this script sets up the principal again (its key, its name) and
|
|
20
|
+
# leaves the other homes as they are.
|
|
15
21
|
#
|
|
16
22
|
# THE RELAY IS BLIND. It carries bytes: TLS starts in the browser and ends at
|
|
17
23
|
# home, so this machine never sees a page, a keystroke or a password. It holds
|
|
18
24
|
# no certificate and no secret of yours.
|
|
19
25
|
#
|
|
20
26
|
# WHAT IT SETS UP:
|
|
21
|
-
# - an account, "clavarium-tunnel", that can do ONE thing: hold
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
27
|
+
# - an account, "clavarium-tunnel", that can do ONE thing: hold listening
|
|
28
|
+
# ports forwarded to home. Each home has its own key there, and each key
|
|
29
|
+
# may hold its own two ports only (the principal: 8443 and 8080). No
|
|
30
|
+
# shell, no terminal, no command, no other forwarding;
|
|
31
|
+
# - what takes 443 and 80: the router, which reads the name the visitor
|
|
32
|
+
# asks for and passes the bytes, untouched, to that home's ports (a name
|
|
33
|
+
# it does not know goes nowhere); or, for a relay of one home whose name
|
|
34
|
+
# it does not know (set up before names were given), a rule that sends
|
|
35
|
+
# 443 and 80 to 8443 and 8080;
|
|
25
36
|
# - the maintenance program, clavarium-relay, fetched by THIS machine from
|
|
26
37
|
# npm at the version written below and checked against the registry's
|
|
27
38
|
# sha512; with --keeper-key, an account "clavarium-keeper" that may run
|
|
28
|
-
# that program, and nothing else (status, repair, updates);
|
|
39
|
+
# that program, and nothing else (status, repair, updates, the homes);
|
|
29
40
|
# - Ubuntu's security updates, by themselves (unattended-upgrades).
|
|
30
41
|
#
|
|
42
|
+
# ANOTHER HOME is added from the principal, never from here and never with
|
|
43
|
+
# an account of this machine: `sudo clavarium relay invite NAME` there gives
|
|
44
|
+
# a one-time code, typed on the new home (`sudo clavarium online --relay
|
|
45
|
+
# PRINCIPAL --domain NAME --join CODE`). The principal adds the new home's
|
|
46
|
+
# PUBLIC tunnel key here (clavarium-relay add-home) and tells it its ports.
|
|
47
|
+
# A second owner is thus let in by the principal's owner, and by nobody else.
|
|
48
|
+
#
|
|
31
49
|
# WHAT A BLIND RELAY CAN STILL DO, and how to close it: whoever controls this
|
|
32
|
-
# machine receives port 80 for
|
|
33
|
-
# authority's challenge itself and obtain a valid certificate for
|
|
34
|
-
# One DNS record closes that: a CAA record that names
|
|
35
|
-
# account ("accounturi="). The end of this script says
|
|
50
|
+
# machine receives port 80 for every name it serves, so it could answer a
|
|
51
|
+
# certificate authority's challenge itself and obtain a valid certificate for
|
|
52
|
+
# such a name. One DNS record per name closes that: a CAA record that names
|
|
53
|
+
# THAT home's certificate account ("accounturi="). The end of this script says
|
|
54
|
+
# it again, and so does each home as it goes online.
|
|
36
55
|
#
|
|
37
56
|
# Exit codes: 0 done; 64 bad parameters; 78 this machine is refused.
|
|
38
57
|
# ---------------------------------------------------------------------------
|
|
39
58
|
set -euo pipefail
|
|
40
59
|
|
|
41
60
|
# The version of the maintenance program this Clavarium works with.
|
|
42
|
-
RELAY_VERSION="0.
|
|
61
|
+
RELAY_VERSION="0.2.0-next.1"
|
|
43
62
|
ACCOUNT="clavarium-tunnel"
|
|
44
63
|
TLS_PORT="8443"
|
|
45
64
|
HTTP_PORT="8080"
|
|
@@ -49,15 +68,16 @@ ROOT="${CLAVARIUM_RELAY_ROOT:-}"
|
|
|
49
68
|
REGISTRY="https://registry.npmjs.org"
|
|
50
69
|
[ -z "$ROOT" ] || REGISTRY="${CLAVARIUM_RELAY_REGISTRY:-$REGISTRY}"
|
|
51
70
|
|
|
52
|
-
bad() { echo "relay: $*" >&2; echo "usage: relay.sh --key \"ssh-ed25519 AAAA... name\" [--keeper-key \"ssh-ed25519 AAAA... name\"] [--dry-run]" >&2; exit 64; }
|
|
71
|
+
bad() { echo "relay: $*" >&2; echo "usage: relay.sh --key \"ssh-ed25519 AAAA... name\" [--keeper-key \"ssh-ed25519 AAAA... name\"] [--name NAME] [--dry-run]" >&2; exit 64; }
|
|
53
72
|
refuse() { echo "relay: REFUSED: $*" >&2; exit 78; }
|
|
54
73
|
step() { echo "== $*"; }
|
|
55
74
|
|
|
56
|
-
KEY=""; KEEPER_KEY=""; DRY="no"
|
|
75
|
+
KEY=""; KEEPER_KEY=""; NAME=""; DRY="no"
|
|
57
76
|
while [ $# -gt 0 ]; do
|
|
58
77
|
case "$1" in
|
|
59
78
|
--key) KEY="${2:-}"; shift 2 || bad "--key needs a value" ;;
|
|
60
79
|
--keeper-key) KEEPER_KEY="${2:-}"; [ -n "$KEEPER_KEY" ] || bad "--keeper-key needs a value"; shift 2 ;;
|
|
80
|
+
--name) NAME="${2:-}"; [ -n "$NAME" ] || bad "--name needs a value"; shift 2 ;;
|
|
61
81
|
--dry-run) DRY="yes"; shift ;;
|
|
62
82
|
*) bad "unknown parameter: $1" ;;
|
|
63
83
|
esac
|
|
@@ -70,12 +90,13 @@ done
|
|
|
70
90
|
[[ "$KEY" =~ ^ssh-ed25519\ [A-Za-z0-9+/]{68}(\ [A-Za-z0-9@._-]{1,64})?$ ]] || bad "--key is one line: ssh-ed25519, the key, and optionally a name"
|
|
71
91
|
[ -z "$KEEPER_KEY" ] || [[ "$KEEPER_KEY" =~ ^ssh-ed25519\ [A-Za-z0-9+/]{68}(\ [A-Za-z0-9@._-]{1,64})?$ ]] || bad "--keeper-key is one line: ssh-ed25519, the key, and optionally a name"
|
|
72
92
|
[ "$KEY" != "$KEEPER_KEY" ] || bad "the tunnel and the maintenance keys are two different keys"
|
|
93
|
+
[ -z "$NAME" ] || [[ "$NAME" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$ ]] || bad "--name is a host name, lower case, like clav.example.com"
|
|
73
94
|
|
|
74
95
|
if [ "$DRY" = "yes" ]; then
|
|
75
96
|
cat <<PLAN
|
|
76
97
|
would make this machine a relay:
|
|
77
98
|
account $ACCOUNT: may hold ports $TLS_PORT and $HTTP_PORT forwarded to home, and nothing else
|
|
78
|
-
ports 443 -> $TLS_PORT, 80 -> $HTTP_PORT
|
|
99
|
+
ports 443 -> $TLS_PORT, 80 -> $HTTP_PORT, for $([ -n "$NAME" ] && echo "the name $NAME, through the router (other names: refused)" || echo "any name (no --name: one home only)")
|
|
79
100
|
sees nothing: TLS ends at home
|
|
80
101
|
program clavarium-relay@$RELAY_VERSION from npm, checked (sha512 of the registry)
|
|
81
102
|
maintenance $([ -n "$KEEPER_KEY" ] && echo "clavarium-keeper: may run clavarium-relay, and nothing else" || echo "none (no --keeper-key)")
|
|
@@ -97,10 +118,12 @@ for k in "$KEY" "$KEEPER_KEY"; do
|
|
|
97
118
|
ssh-keygen -l -f "$check" >/dev/null 2>&1 || { rm -f -- "$check"; refuse "this is not a key the SSH tools recognise"; }
|
|
98
119
|
done
|
|
99
120
|
rm -f -- "$check"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
# On a relay already (a second run), its own router holds 443 and 80, and the tunnels hold theirs.
|
|
122
|
+
if [ ! -s "$ROOT/etc/clavarium-relay/homes" ] && [ ! -s "$ROOT/etc/ssh/clavarium-relay/$ACCOUNT" ]; then
|
|
123
|
+
for port in 443 80 "$TLS_PORT" "$HTTP_PORT"; do
|
|
124
|
+
if ss -ltnpH "sport = :$port" 2>/dev/null | grep -v '"sshd' | grep -q .; then refuse "something already listens on port $port here: a relay needs 443 and 80 for itself"; fi
|
|
125
|
+
done
|
|
126
|
+
fi
|
|
104
127
|
|
|
105
128
|
step "the maintenance program, from npm"
|
|
106
129
|
for tool in curl openssl tar; do command -v "$tool" >/dev/null 2>&1 || { export DEBIAN_FRONTEND=noninteractive; apt-get update -qq; apt-get install -y -qq curl openssl tar ca-certificates >/dev/null; break; }; done
|
|
@@ -129,8 +152,10 @@ install -m 0755 "$work/package/clavarium-relay" "$ROOT$PROGRAM"
|
|
|
129
152
|
rm -rf -- "$work"
|
|
130
153
|
|
|
131
154
|
step "accounts, rules, ports, updates"
|
|
132
|
-
|
|
133
|
-
|
|
155
|
+
setup=(setup --key "$KEY")
|
|
156
|
+
[ -z "$KEEPER_KEY" ] || setup+=(--keeper-key "$KEEPER_KEY")
|
|
157
|
+
[ -z "$NAME" ] || setup+=(--name "$NAME")
|
|
158
|
+
"$ROOT$PROGRAM" "${setup[@]}"
|
|
134
159
|
|
|
135
160
|
echo
|
|
136
161
|
echo "This machine is a relay. It waits for the home machine to dial in."
|
|
@@ -138,7 +163,8 @@ echo "Compare with what the home machine printed (it must be the same):"
|
|
|
138
163
|
echo " $(ssh-keygen -l -f "$ROOT/etc/ssh/ssh_host_ed25519_key.pub" | cut -d' ' -f2)"
|
|
139
164
|
echo "Point your name at this machine's public address (ports 443 and 80 must reach it)."
|
|
140
165
|
echo
|
|
141
|
-
echo "TO KEEP THIS RELAY BLIND, add a CAA record to your DNS
|
|
142
|
-
echo "account (accounturi=...). Without it, whoever controls
|
|
143
|
-
echo "certificate for
|
|
166
|
+
echo "TO KEEP THIS RELAY BLIND, add a CAA record to your DNS FOR EACH NAME it serves, that"
|
|
167
|
+
echo "names that home's certificate account (accounturi=...). Without it, whoever controls"
|
|
168
|
+
echo "this machine could obtain a certificate for that name. The account address is at"
|
|
169
|
+
echo "each home, after its first certificate:"
|
|
144
170
|
echo " sudo grep -rho '\"location\": *\"[^\"]*\"' /var/lib/caddy/caddy/acme"
|
package/deploy/tunnel.mjs
CHANGED
|
@@ -10,7 +10,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
its port are read from the unit there, written as before or as now. Restarted only when it changed. */
|
|
11
11
|
export const TUNNEL_UNIT = '/etc/systemd/system/clavarium-tunnel.service';
|
|
12
12
|
export const TUNNEL_PROGRAM = '/usr/local/sbin/clavarium-tunnel';
|
|
13
|
-
|
|
13
|
+
/* `ports`: { tls, http } for a home the relay's principal added (its own ports on the relay); none for
|
|
14
|
+
the principal itself (8443 and 8080, as ever). */
|
|
15
|
+
export function tunnelUnit(relay, port, program = TUNNEL_PROGRAM, ports = null) {
|
|
14
16
|
return `[Unit]
|
|
15
17
|
Description=Clavarium - the outgoing tunnel to the relay
|
|
16
18
|
After=network-online.target caddy.service
|
|
@@ -21,7 +23,7 @@ StartLimitIntervalSec=0
|
|
|
21
23
|
User=clavarium-tunnel
|
|
22
24
|
Group=clavarium-tunnel
|
|
23
25
|
# Only TLS (443) and the certificate challenge (80) are carried: see ${program}.
|
|
24
|
-
ExecStart=${program} ${relay} ${port}
|
|
26
|
+
ExecStart=${program} ${relay} ${port}${ports ? ` ${ports.tls} ${ports.http}` : ''}
|
|
25
27
|
Restart=always
|
|
26
28
|
RestartSec=5
|
|
27
29
|
# What it counts of the relay's refusals, kept across its restarts.
|
|
@@ -37,8 +39,8 @@ WantedBy=multi-user.target
|
|
|
37
39
|
`;
|
|
38
40
|
}
|
|
39
41
|
export function relayOfUnit(text) {
|
|
40
|
-
const now = new RegExp(`^ExecStart=\\S+clavarium-tunnel ([A-Za-z0-9.-]{1,253}) (\\d{1,5})
|
|
41
|
-
if (now) return { relay: now[1], port: now[2] };
|
|
42
|
+
const now = new RegExp(`^ExecStart=\\S+clavarium-tunnel ([A-Za-z0-9.-]{1,253}) (\\d{1,5})(?: (\\d{4,5}) (\\d{4,5}))?$`, 'm').exec(text);
|
|
43
|
+
if (now) return { relay: now[1], port: now[2], ...(now[3] ? { ports: { tls: now[3], http: now[4] } } : {}) };
|
|
42
44
|
const before = /-p (\d{1,5}) clavarium-tunnel@([A-Za-z0-9.-]{1,253})\s*$/m.exec(text);
|
|
43
45
|
return before ? { relay: before[2], port: before[1] } : null;
|
|
44
46
|
}
|
|
@@ -56,7 +58,7 @@ export async function ensureTunnel({ file = TUNNEL_UNIT, program = TUNNEL_PROGRA
|
|
|
56
58
|
if (!existsSync(program)) return false;
|
|
57
59
|
const at = relayOfUnit(text);
|
|
58
60
|
if (!at) return false;
|
|
59
|
-
const wanted = tunnelUnit(at.relay, at.port, program);
|
|
61
|
+
const wanted = tunnelUnit(at.relay, at.port, program, at.ports ?? null);
|
|
60
62
|
if (text === wanted) {
|
|
61
63
|
/* The same unit, a new program: what runs now is the old one until it is started again. */
|
|
62
64
|
if (installed && process.getuid?.() === 0) await run(['systemctl', 'try-restart', 'clavarium-tunnel.service'], { timeoutMs: 60_000 });
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.381-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.381-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/package.json
CHANGED
package/server/main.mjs
CHANGED
|
@@ -87,6 +87,7 @@ import { Pocket } from './pocket.mjs';
|
|
|
87
87
|
import { Speech } from './speech.mjs';
|
|
88
88
|
import { Features, handleFeatures, cached } from './features.mjs';
|
|
89
89
|
import { ApiKeys, handleExternal, handleApiKeys, statusOf } from './apikeys.mjs';
|
|
90
|
+
import { RelayJoin, handleRelayJoin, RELAY_JOIN_PATH } from './relayjoin.mjs';
|
|
90
91
|
import { WebSocketServer } from 'ws';
|
|
91
92
|
|
|
92
93
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
@@ -184,7 +185,7 @@ const withoutTmux = (env) => Object.fromEntries(Object.entries(env).filter(([key
|
|
|
184
185
|
|
|
185
186
|
const REFUSED = 'This Clavarium belongs to someone else: your account is not one of its operators.\n';
|
|
186
187
|
|
|
187
|
-
function serve(port, conversations, resources, { gate = () => 'local', originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, repair, capacity, updates, instance = new Instance(), certificate, team, reacting, machinesMove, serverLogs, history, events, bundles, wifi, binders, meshcentral, tethers, accounts, groups, boxes, catalogue, rooms, features, apikeys } = {}) {
|
|
188
|
+
function serve(port, conversations, resources, { gate = () => 'local', originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, repair, capacity, updates, instance = new Instance(), certificate, team, reacting, machinesMove, serverLogs, history, events, bundles, wifi, binders, meshcentral, tethers, accounts, groups, boxes, catalogue, rooms, features, apikeys, relayJoin } = {}) {
|
|
188
189
|
/* Its manifest carries the name of this Clavarium, or the one a device chose (instance.mjs). */
|
|
189
190
|
const sendManifest = (response, url) => response.writeHead(200, { ...HEADERS, 'content-type': 'application/manifest+json' }).end(instance.manifest(new URL(url ?? '/', 'http://local').searchParams.get('name') ?? undefined));
|
|
190
191
|
const sendFile = async (response, file) => {
|
|
@@ -232,6 +233,8 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
232
233
|
try { say(200, boxes.pair(JSON.parse(text || '{}'))); } catch (error) { say(error.status ?? 400, { error: error instanceof SyntaxError ? 'Not JSON.' : error.message }); }
|
|
233
234
|
return;
|
|
234
235
|
}
|
|
236
|
+
/* Another home joins this machine's relay: under /c/, no sign-on; the one-time code is the right (relayjoin.mjs). */
|
|
237
|
+
if (relayJoin && pathname === RELAY_JOIN_PATH) { await handleRelayJoin(request, response, relayJoin, HEADERS); return; }
|
|
235
238
|
/* THE DOOR FOR PROGRAMS (apikeys.mjs): an API key is the right, never the gate -- the front lets
|
|
236
239
|
/api/ext/ through without sign-on, and strips the identity headers there. */
|
|
237
240
|
if (apikeys && (pathname === '/api/ext' || pathname.startsWith('/api/ext/'))) {
|
|
@@ -781,7 +784,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
781
784
|
answerer: answererConfig(env)?.models.length ?? 0, updates: updates.about(), team: team ? team.list().length : null,
|
|
782
785
|
screen: await quick(screenNow(), 1500, null), catalogue: await quick(catalogue.providers().catch(() => []), 1500, []),
|
|
783
786
|
}) });
|
|
784
|
-
const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, instance, certificate, team, reacting, machinesMove, serverLogs, history, events, wifi, binders, meshcentral, tethers, accounts, groups, boxes, catalogue, rooms, features, apikeys, bundles: new Bundles({ dir: `${dataDir}/imports`, folder: (name) => folders.folder(name) }), repair: (name) => repairTerminal(name) });
|
|
787
|
+
const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, instance, certificate, team, reacting, machinesMove, serverLogs, history, events, wifi, binders, meshcentral, tethers, accounts, groups, boxes, catalogue, rooms, features, apikeys, relayJoin: host.length ? new RelayJoin({ host: asHost }) : null, bundles: new Bundles({ dir: `${dataDir}/imports`, folder: (name) => folders.folder(name) }), repair: (name) => repairTerminal(name) });
|
|
785
788
|
server.on('close', () => { closing = true; stopBinders(); stopTethers(); clearInterval(historyTimer); clearInterval(secretsTimer); history.save(); stopVmWatch(); stopShelf(); stopHealth(); stopNetworkWatch(); stopUpdates(); stopWatch(); clearInterval(mailTimer); clearInterval(restartTimer); clearInterval(expiryTimer); clearInterval(answerTimer); clearInterval(wakeTimer); clearInterval(docketTimer); dockets.stop(); clearInterval(gitTimer); talk.stop(); });
|
|
786
789
|
let closing = false;
|
|
787
790
|
boxes.attach(server, WebSocketServer);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* ANOTHER HOME JOINS THIS MACHINE'S RELAY: POST /c/relay-join, with no sign-in (the front lets /c/
|
|
2
|
+
* through), the one-time code being the right. See deploy/online.mjs ("SEVERAL HOMES ON ONE RELAY").
|
|
3
|
+
*
|
|
4
|
+
* POST /c/relay-join {"code": "ABCD-EFGH", "key": "ssh-ed25519 AAAA..."}
|
|
5
|
+
* 200 {"name", "relay": {"host", "port", "fingerprint"}, "ports": {"tls", "http"}}
|
|
6
|
+
* 400 not this shape; 403 not a valid code (wrong, used or expired); 429 too many tries; 502 the relay did not add it
|
|
7
|
+
*
|
|
8
|
+
* The server only carries it: the code is checked, spent and acted on by root (host program, "relay
|
|
9
|
+
* join"), where the server cannot read the codes. Here: a body of exactly these two fields, a code of
|
|
10
|
+
* the right shape, a PUBLIC ed25519 key (its comment dropped) and nothing else; at most PER_MINUTE
|
|
11
|
+
* tries a minute, from everybody together (behind the relay, every caller has the same address). The
|
|
12
|
+
* answer is rebuilt field by field from what root printed: nothing else can go out.
|
|
13
|
+
*/
|
|
14
|
+
import { Refusal } from './conversations.mjs';
|
|
15
|
+
|
|
16
|
+
export const RELAY_JOIN_PATH = '/c/relay-join';
|
|
17
|
+
export const PER_MINUTE = 6;
|
|
18
|
+
const CODE = /^[A-HJKMNP-Z2-9]{8}$/;
|
|
19
|
+
const KEY = /^ssh-ed25519 ([A-Za-z0-9+/]{68})( [A-Za-z0-9@._-]{1,64})?$/;
|
|
20
|
+
const HOST = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$|^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
|
21
|
+
const PRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
|
|
22
|
+
const NOT_VALID = 'This code is not valid: wrong, used or expired. Ask the owner of that Clavarium for a new one.';
|
|
23
|
+
|
|
24
|
+
export class RelayJoin {
|
|
25
|
+
#host; #now; #tries = [];
|
|
26
|
+
/* `host(argv)`: the host program (vm.mjs, runner) -> { code, stdout, stderr }. */
|
|
27
|
+
constructor({ host, now = Date.now }) { this.#host = host; this.#now = now; }
|
|
28
|
+
|
|
29
|
+
/* -> { status, body }. `text`: the request's body. */
|
|
30
|
+
async join(text) {
|
|
31
|
+
const now = this.#now();
|
|
32
|
+
this.#tries = this.#tries.filter((t) => t > now - 60_000);
|
|
33
|
+
if (this.#tries.length >= PER_MINUTE) return { status: 429, body: { error: 'Too many tries: wait a minute.' } };
|
|
34
|
+
this.#tries.push(now);
|
|
35
|
+
let given;
|
|
36
|
+
try { given = JSON.parse(text); } catch { return { status: 400, body: { error: 'Not JSON.' } }; }
|
|
37
|
+
if (!given || typeof given !== 'object' || Array.isArray(given) || Object.keys(given).sort().join(',') !== 'code,key') return { status: 400, body: { error: 'Send {"code", "key"} and nothing else.' } };
|
|
38
|
+
const code = String(given.code).toUpperCase().replace(/[\s-]/g, '');
|
|
39
|
+
const key = KEY.exec(typeof given.key === 'string' ? given.key.trim() : '');
|
|
40
|
+
if (typeof given.code !== 'string' || !CODE.test(code)) return { status: 403, body: { error: NOT_VALID } };
|
|
41
|
+
if (!key) return { status: 400, body: { error: 'The key is the PUBLIC tunnel key: one line, ssh-ed25519 and the key.' } };
|
|
42
|
+
const r = await this.#host(['relay', 'join', code, 'ssh-ed25519', key[1]], { timeoutMs: 120_000 });
|
|
43
|
+
if (r.code === 78) return { status: 403, body: { error: NOT_VALID } };
|
|
44
|
+
if (r.code !== 0) return { status: 502, body: { error: 'The relay did not add this home just now: try again, or ask the owner of that Clavarium.' } };
|
|
45
|
+
let a;
|
|
46
|
+
try { a = JSON.parse(String(r.stdout).trim().split('\n').pop()); } catch { a = null; }
|
|
47
|
+
const port = (v, lo) => Number.isInteger(v) && v >= lo && v <= 65535;
|
|
48
|
+
if (!a || typeof a.name !== 'string' || !HOST.test(a.name) || !HOST.test(a.relay?.host ?? '') || !port(a.relay?.port, 1) || !PRINT.test(a.relay?.fingerprint ?? '')
|
|
49
|
+
|| !port(a.ports?.tls, 1024) || !port(a.ports?.http, 1024)) return { status: 502, body: { error: 'The relay did not answer as expected.' } };
|
|
50
|
+
return { status: 200, body: { name: a.name, relay: { host: a.relay.host, port: a.relay.port, fingerprint: a.relay.fingerprint }, ports: { tls: a.ports.tls, http: a.ports.http } } };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* The route, answered here when it is this one (true). */
|
|
55
|
+
export async function handleRelayJoin(request, response, join, headers = {}) {
|
|
56
|
+
const say = (status, body) => { response.writeHead(status, { ...headers, 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }).end(JSON.stringify(body)); return true; };
|
|
57
|
+
if (request.method !== 'POST') return say(405, { error: 'Call this address with POST.' });
|
|
58
|
+
let text = '';
|
|
59
|
+
for await (const piece of request) { text += piece; if (text.length > 2048) return say(413, { error: 'Too long.' }); }
|
|
60
|
+
try {
|
|
61
|
+
const { status, body } = await join.join(text);
|
|
62
|
+
return say(status, body);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof Refusal) return say(error.status, { error: error.message });
|
|
65
|
+
return say(502, { error: 'The relay did not add this home just now.' });
|
|
66
|
+
}
|
|
67
|
+
}
|
package/web/app.js
CHANGED
|
@@ -2124,6 +2124,7 @@
|
|
|
2124
2124
|
let voiceOn = false;
|
|
2125
2125
|
const chatVoice = () => (voiceOn && window.clavariumVoice ? window.clavariumVoice : null);
|
|
2126
2126
|
async function offerVoice() {
|
|
2127
|
+
if (DEVICE && !pairing) return; /* asked again by start(), once there is a pairing to ask with */
|
|
2127
2128
|
voiceOn = window.clavariumVoice ? await window.clavariumVoice.available() : false;
|
|
2128
2129
|
$('mic').hidden = $('auto').hidden = !voiceOn;
|
|
2129
2130
|
keysForChat();
|
|
@@ -3017,6 +3018,8 @@
|
|
|
3017
3018
|
const deviceLang = () => (navigator.language || 'en');
|
|
3018
3019
|
let chatWords = null;
|
|
3019
3020
|
async function readWords() {
|
|
3021
|
+
/* A device asks its own door only once it has its pairing (start): asked before, it was refused (401), and kept empty. */
|
|
3022
|
+
if (DEVICE && !pairing) return {};
|
|
3020
3023
|
if (!chatWords) chatWords = await fetch(`${API}/talk/words?${new URLSearchParams({ lang: deviceLang() })}`).then((r) => (r.ok ? r.json() : {})).catch(() => ({}));
|
|
3021
3024
|
return chatWords;
|
|
3022
3025
|
}
|
|
@@ -8922,7 +8925,7 @@
|
|
|
8922
8925
|
await paintAssets();
|
|
8923
8926
|
});
|
|
8924
8927
|
setConnexionProvider('google');
|
|
8925
|
-
getJson('/accounts', null).then((d) => { if (d?.supported) { accountSupported = { ...d.supported, classic: ['mail', 'calendar', 'contacts'] }; setConnexionProvider($('connexion-new').dataset.provider || 'google'); } });
|
|
8928
|
+
if (!DEVICE) getJson('/accounts', null).then((d) => { if (d?.supported) { accountSupported = { ...d.supported, classic: ['mail', 'calendar', 'contacts'] }; setConnexionProvider($('connexion-new').dataset.provider || 'google'); } });
|
|
8926
8929
|
$('list-new').addEventListener('submit', async (event) => {
|
|
8927
8930
|
event.preventDefault();
|
|
8928
8931
|
$('assets-error').hidden = true; $('cassets-error').hidden = true;
|