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.
@@ -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
@@ -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
- -R '*:8443:127.0.0.1:443' -R '*:8080:127.0.0.1:80' -p "$port" "clavarium-tunnel@$relay" 2>"$said"
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.
@@ -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
- /* The tunnel, for the test only: the service comes with server mode. */
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', '-R', '*:8443:127.0.0.1:443', '-R', '*:8080:127.0.0.1:80', `clavarium-tunnel@${o.relay}`], { onLine: (l) => lines.push(l) });
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.relay) await setUpRelay(sys, P, o, keys);
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 ONE CLAVARIUM. One command, parameters
4
- # only. Run it on a small machine that has a public address; the machine at
5
- # home dials out to it, so home needs no open port and no fixed address.
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 two
22
- # listening ports (8443, 8080) forwarded to home. No shell, no terminal,
23
- # no command, no other forwarding;
24
- # - a rule that sends what arrives on 443 and 80 to those two ports;
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 your name, so it could answer a certificate
33
- # authority's challenge itself and obtain a valid certificate for your name.
34
- # One DNS record closes that: a CAA record that names YOUR certificate
35
- # account ("accounturi="). The end of this script says it again.
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.1.1"
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
- for port in 443 80 "$TLS_PORT" "$HTTP_PORT"; do
101
- # (On a second run, the tunnel itself holds two of them: that is the SSH server.)
102
- 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
103
- done
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
- if [ -n "$KEEPER_KEY" ]; then "$ROOT$PROGRAM" setup --key "$KEY" --keeper-key "$KEEPER_KEY"
133
- else "$ROOT$PROGRAM" setup --key "$KEY"; fi
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 that names your certificate"
142
- echo "account (accounturi=...). Without it, whoever controls this machine could obtain a"
143
- echo "certificate for your name. The account address is at home, after the first certificate:"
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
- export function tunnelUnit(relay, port, program = TUNNEL_PROGRAM) {
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})$`, 'm').exec(text);
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 });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.379-next.1",
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.379-next.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.379-next.1",
3
+ "version": "0.2.381-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
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;