clavarium 0.1.0 → 0.1.2

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.
@@ -12,6 +12,8 @@
12
12
  * sudo clavarium online --port --domain NAME [...]
13
13
  * sudo clavarium key the public keys, text and QR
14
14
  * sudo clavarium relay status | repair | update-os | update-relay | forget-setup-key
15
+ * sudo clavarium doctor [--fix] what works, what does not, what to do
16
+ * sudo clavarium password EMAIL a new password for an administrator
15
17
  *
16
18
  * `install` runs deploy/install.sh of this package, which installs THIS
17
19
  * version from npm (checked) into /opt/clavarium/releases/X.Y.Z; every other
@@ -25,6 +27,18 @@ import { fileURLToPath } from 'node:url';
25
27
  const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
28
  const [verb, ...rest] = process.argv.slice(2);
27
29
  if (verb === '--version' || verb === '-v') { console.log(version); process.exit(0); }
30
+ if (['doctor', 'password'].includes(verb)) {
31
+ const { doctor, newPassword, certificateWatch, installWatch } = await import('./doctor.mjs');
32
+ if (process.getuid?.() !== 0) { console.error(`clavarium ${verb}: run it as root: sudo clavarium ${verb} ...`); process.exit(78); }
33
+ const sys = (await import('./online.mjs')).realSys();
34
+ try {
35
+ if (verb === 'password') { await newPassword(sys, rest[0]); process.exit(0); }
36
+ if (rest.length === 1 && rest[0] === '--certificate') process.exit(await certificateWatch(sys));
37
+ if (rest.length === 1 && rest[0] === '--install-watch') { await installWatch(sys); process.exit(0); }
38
+ if (rest.some((a) => a !== '--fix')) throw new Error('usage: sudo clavarium doctor [--fix]');
39
+ process.exit((await doctor(sys, { fix: rest.includes('--fix') })) ? 1 : 0);
40
+ } catch (error) { console.error(`clavarium ${verb}: ${error.message}`); process.exit(1); }
41
+ }
28
42
  if (['online', 'key', 'relay'].includes(verb)) {
29
43
  const o = await import('./online.mjs');
30
44
  if (process.getuid?.() !== 0) { console.error(`clavarium ${verb}: run it as root: sudo clavarium ${verb} ...`); process.exit(78); }
@@ -48,7 +62,9 @@ if (verb !== 'install') {
48
62
  + ' sudo clavarium online --relay HOST --domain NAME then online, through a relay of yours\n'
49
63
  + ' sudo clavarium online --port --domain NAME or with ports 80 and 443 open\n'
50
64
  + ' sudo clavarium key the public keys again (text and QR)\n'
51
- + ' sudo clavarium relay status|repair|update-os|update-relay|forget-setup-key\n\n'
65
+ + ' sudo clavarium relay status|repair|update-os|update-relay|forget-setup-key\n'
66
+ + ' sudo clavarium doctor [--fix] what works, what does not, what to do\n'
67
+ + ' sudo clavarium password EMAIL a new password for an administrator\n\n'
52
68
  + 'https://clavarium.org');
53
69
  process.exit(verb ? 64 : 0);
54
70
  }
@@ -0,0 +1,203 @@
1
+ /* `sudo clavarium doctor [--fix]`: WHAT WORKS AND WHAT DOES NOT, each problem
2
+ * with what to do about it. Run at the end of `clavarium online` too.
3
+ *
4
+ * the services (clavarium; in server mode the edge: caddy, dex, oauth2-proxy,
5
+ * and the tunnel behind a relay), the domain's address, and the certificate
6
+ * the front actually serves: a public one, valid, not about to expire.
7
+ *
8
+ * --fix does only what is safe and undoes nothing of the operator's: it starts
9
+ * a service that is down, and a certificate that is not a public one (made by
10
+ * Caddy itself when Let's Encrypt could not be reached: its fallback, so the
11
+ * site keeps answering) is dropped so that Caddy asks Let's Encrypt again.
12
+ * The same is done by itself every 20 minutes (--certificate, from a timer).
13
+ *
14
+ * A password lost: `sudo clavarium password EMAIL` makes a new one (Dex's
15
+ * static password of that administrator), shown once.
16
+ */
17
+ import { readFileSync, writeFileSync, existsSync, rmSync, renameSync } from 'node:fs';
18
+
19
+ export const DOCTOR_PATHS = {
20
+ unit: '/etc/systemd/system/clavarium.service',
21
+ relayConf: '/etc/clavarium/relay.conf',
22
+ caddyfile: '/etc/caddy/Caddyfile',
23
+ caddyData: '/var/lib/caddy/caddy',
24
+ dexConf: '/etc/dex/config.yaml',
25
+ caddy: '/usr/local/bin/caddy',
26
+ systemd: '/etc/systemd/system',
27
+ };
28
+ const EMAIL = /^[a-z0-9._+-]{1,64}@([a-z0-9-]{1,63}\.)+[a-z]{2,24}$/;
29
+
30
+ const read = (f) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
31
+ export const domainOf = (P) => /--public-origin https:\/\/(\S+)/.exec(read(P.unit) ?? '')?.[1] ?? null;
32
+ const relayOf = (P) => /^HOST=(\S+)$/m.exec(read(P.relayConf) ?? '')?.[1] ?? null;
33
+
34
+ async function active(sys, unit) {
35
+ const r = await sys.run(['systemctl', 'is-active', unit]);
36
+ return r.out.trim() === 'active';
37
+ }
38
+
39
+ /* What the front serves for the domain, read on the loopback: issuer, end, verified or not. */
40
+ export async function servedCert(sys, domain) {
41
+ const r = await sys.run(['openssl', 's_client', '-connect', '127.0.0.1:443', '-servername', domain, '-verify_hostname', domain, '-showcerts'], { input: '' });
42
+ const pem = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/.exec(r.out)?.[0];
43
+ if (!pem) return { none: true, why: (r.err || r.out).trim().split('\n').filter(Boolean).pop() ?? 'nothing answers on 443' };
44
+ const x = await sys.run(['openssl', 'x509', '-noout', '-issuer', '-enddate'], { input: pem });
45
+ const issuer = /issuer=\s*(.*)/.exec(x.out)?.[1]?.trim() ?? '?';
46
+ const end = Date.parse(/notAfter=(.*)/.exec(x.out)?.[1] ?? '');
47
+ const code = /Verify return code: (\d+) \(([^)]*)\)/.exec(r.out);
48
+ return { issuer, end, verified: code?.[1] === '0', why: code?.[2] ?? 'not verified', local: /Caddy Local Authority/i.test(issuer) };
49
+ }
50
+
51
+ /* The last error Caddy logged, in plain words. */
52
+ async function caddyError(sys) {
53
+ const r = await sys.run(['journalctl', '-u', 'caddy', '-n', '300', '-o', 'cat', '--no-pager']);
54
+ const lines = r.out.split('\n').filter((l) => /"level":"error"/.test(l));
55
+ if (!lines.length) return null;
56
+ try { const j = JSON.parse(lines.at(-1)); return [j.msg, j.error].filter(Boolean).join(': ').slice(0, 300); } catch { return lines.at(-1).slice(0, 300); }
57
+ }
58
+
59
+ /* Each check: { what, ok, detail, fix (words), repair (what --fix does) }. */
60
+ export async function diagnose(sys, P = DOCTOR_PATHS) {
61
+ const checks = [];
62
+ const add = (c) => { checks.push(c); return c; };
63
+ const domain = domainOf(P);
64
+ const relay = domain ? relayOf(P) : null;
65
+ const units = ['clavarium', ...(domain ? ['caddy', 'dex', 'oauth2-proxy'] : []), ...(relay ? ['clavarium-tunnel'] : [])];
66
+ for (const u of units) {
67
+ const ok = await active(sys, u);
68
+ add({ what: `service ${u}`, ok, detail: ok ? 'running' : 'not running', fix: `sudo systemctl restart ${u}; to see why: journalctl -u ${u} -n 50`, repair: ok ? null : 'restart', unit: u });
69
+ }
70
+ if (!domain) { add({ what: 'mode', ok: true, detail: 'local (this machine only): no certificate, no sign-in' }); return checks; }
71
+
72
+ const addrs = await sys.resolve(domain);
73
+ const want = !relay ? null : /^[0-9.]+$/.test(relay) ? [relay] : await sys.resolve(relay);
74
+ const dnsOk = addrs.length > 0 && (!want || !want.length || addrs.some((a) => want.includes(a)));
75
+ add({ what: `the name ${domain}`, ok: dnsOk, detail: addrs.length ? `leads to ${addrs.join(', ')}` : 'leads nowhere',
76
+ fix: relay ? `make ${domain} lead to the relay (${want?.join(', ') || relay}) at your DNS host` : `make ${domain} lead to this machine at your DNS host` });
77
+
78
+ const c = await servedCert(sys, domain);
79
+ const days = Number.isFinite(c.end) ? Math.floor((c.end - sys.now()) / 86_400_000) : null;
80
+ if (c.none) {
81
+ add({ what: 'certificate', ok: false, detail: c.why, fix: 'sudo systemctl restart caddy', repair: 'restart', unit: 'caddy' });
82
+ } else if (c.local || !c.verified) {
83
+ const err = await caddyError(sys);
84
+ add({ what: 'certificate', ok: false,
85
+ detail: `${c.local ? 'made by Caddy itself, not by Let\'s Encrypt: browsers warn' : `not valid (${c.why})`}; issuer ${c.issuer}${err ? `. Caddy's last error: ${err}` : ''}`,
86
+ fix: `check that ${domain} leads ${relay ? 'to the relay' : 'here'} and that port 80 reaches it, then: sudo clavarium doctor --fix (Caddy asks Let's Encrypt again; this is also tried by itself every 20 minutes)`,
87
+ repair: 'certificate' });
88
+ } else {
89
+ add({ what: 'certificate', ok: days === null || days >= 10, detail: `from ${c.issuer}${days === null ? '' : `, ${days} days left`}`,
90
+ fix: 'Caddy renews it by itself 30 days ahead; it has not: journalctl -u caddy -n 100, then sudo clavarium doctor --fix', repair: days !== null && days < 10 ? 'certificate' : null });
91
+ }
92
+ return checks;
93
+ }
94
+
95
+ export function report(sys, checks) {
96
+ const bad = checks.filter((c) => !c.ok);
97
+ sys.print('');
98
+ sys.print(`Diagnosis: ${bad.length ? `${bad.length} problem${bad.length > 1 ? 's' : ''}` : 'everything works'}`);
99
+ for (const c of checks) {
100
+ sys.print(` ${c.ok ? 'ok ' : 'NO '} ${c.what}: ${c.detail}`);
101
+ if (!c.ok && c.fix) sys.print(` what to do: ${c.fix}`);
102
+ }
103
+ return bad.length;
104
+ }
105
+
106
+ /* The certificate again from Let's Encrypt: the one Caddy made itself (its
107
+ fallback when Let's Encrypt could not be reached) is dropped and Caddy
108
+ restarted, so it asks Let's Encrypt again -- and falls back again if it still
109
+ fails: the site keeps answering, with a warning, never down. */
110
+ async function renewCertificate(sys, P) {
111
+ const domain = domainOf(P);
112
+ const local = `${P.caddyData}/certificates/local/${domain}`;
113
+ if (domain && existsSync(local)) { rmSync(local, { recursive: true, force: true }); sys.print(' the certificate made by Caddy itself: dropped'); }
114
+ await sys.run(['systemctl', 'restart', 'caddy']);
115
+ sys.print(' Caddy restarted: it asks Let\'s Encrypt now');
116
+ }
117
+
118
+ /* `clavarium doctor --certificate`, every 20 minutes (clavarium-certificate.timer):
119
+ a certificate Caddy made itself is replaced by Let's Encrypt's as soon as it can.
120
+ Nothing is done while the certificate is a public one. 3 tries an hour at most:
121
+ under Let's Encrypt's limit of 5 failed validations an hour. */
122
+ export async function certificateWatch(sys, P = DOCTOR_PATHS) {
123
+ const domain = domainOf(P);
124
+ if (!domain) return 0;
125
+ const c = await servedCert(sys, domain);
126
+ if (!c.none && !c.local) return 0;
127
+ sys.print(`${domain}: ${c.none ? 'no certificate served' : 'a certificate made by Caddy itself'}: asking Let's Encrypt again`);
128
+ await renewCertificate(sys, P);
129
+ return 0;
130
+ }
131
+
132
+ /* The timer of certificateWatch. Written by install.sh, and by doctor --fix on a
133
+ machine installed before it existed (an update does not rerun install.sh). */
134
+ export const WATCH_SERVICE = `[Unit]
135
+ Description=Clavarium - a certificate made by Caddy itself is replaced by Let's Encrypt's
136
+ After=caddy.service
137
+
138
+ [Service]
139
+ Type=oneshot
140
+ ExecStart=/usr/local/bin/clavarium doctor --certificate
141
+ `;
142
+ export const WATCH_TIMER = `[Unit]
143
+ Description=Clavarium - the certificate, every 20 minutes
144
+
145
+ [Timer]
146
+ OnBootSec=10min
147
+ OnUnitActiveSec=20min
148
+
149
+ [Install]
150
+ WantedBy=timers.target
151
+ `;
152
+ export async function installWatch(sys, P = DOCTOR_PATHS) {
153
+ const svc = `${P.systemd}/clavarium-certificate.service`; const tim = `${P.systemd}/clavarium-certificate.timer`;
154
+ if (read(svc) === WATCH_SERVICE && read(tim) === WATCH_TIMER) return false;
155
+ writeFileSync(svc, WATCH_SERVICE); writeFileSync(tim, WATCH_TIMER);
156
+ await sys.run(['systemctl', 'daemon-reload']);
157
+ await sys.run(['systemctl', 'enable', '--now', '--quiet', 'clavarium-certificate.timer']);
158
+ return true;
159
+ }
160
+
161
+ export async function doctor(sys, { fix = false } = {}, P = DOCTOR_PATHS) {
162
+ let checks = await diagnose(sys, P);
163
+ let bad = report(sys, checks);
164
+ if (!bad) return 0;
165
+ const repairs = checks.filter((c) => !c.ok && c.repair);
166
+ if (!fix) { if (repairs.length) sys.print('To repair what can be: sudo clavarium doctor --fix'); return bad; }
167
+ sys.print('');
168
+ sys.print('Repairing:');
169
+ if (domainOf(P) && await installWatch(sys, P)) sys.print(' the certificate is now checked every 20 minutes (clavarium-certificate.timer)');
170
+ for (const c of repairs) {
171
+ if (c.repair === 'restart') { await sys.run(['systemctl', 'restart', c.unit]); sys.print(` ${c.unit}: restarted`); }
172
+ if (c.repair === 'certificate') await renewCertificate(sys, P);
173
+ }
174
+ if (!repairs.length) { sys.print(' nothing this command can repair: see "what to do" above'); return bad; }
175
+ /* Let's Encrypt answers in seconds when all is well. */
176
+ for (let i = 0; i < 12; i += 1) {
177
+ await sys.sleep(10_000);
178
+ checks = await diagnose(sys, P);
179
+ if (checks.every((c) => c.ok)) break;
180
+ }
181
+ return report(sys, checks);
182
+ }
183
+
184
+ /* `sudo clavarium password EMAIL`: a new password for that administrator, shown once. */
185
+ export async function newPassword(sys, email, P = DOCTOR_PATHS) {
186
+ if (!EMAIL.test(String(email ?? ''))) throw new Error('usage: sudo clavarium password EMAIL');
187
+ const conf = read(P.dexConf);
188
+ if (!conf) throw new Error('no administrators on this machine: it is in local mode (no sign-in)');
189
+ const at = conf.indexOf(` - email: "${email}"\n hash: "`);
190
+ if (at < 0) throw new Error(`${email} is not an administrator here`);
191
+ const pw = (await sys.run(['openssl', 'rand', '-base64', '18'])).out.trim().replace(/\+/g, '-').replace(/\//g, '_');
192
+ if (pw.length < 20) throw new Error('no random password could be made');
193
+ const hash = (await sys.run([P.caddy, 'hash-password'], { input: `${pw}\n` })).out.trim();
194
+ if (!/^\$2[aby]\$\d\d\$[./A-Za-z0-9]{53}$/.test(hash)) throw new Error('the password could not be hashed');
195
+ const start = at + ` - email: "${email}"\n hash: "`.length;
196
+ const next = conf.slice(0, start) + hash + conf.slice(conf.indexOf('"', start));
197
+ writeFileSync(`${P.dexConf}.new`, next, { mode: 0o640 });
198
+ await sys.run(['chown', 'root:dex', `${P.dexConf}.new`]);
199
+ renameSync(`${P.dexConf}.new`, P.dexConf);
200
+ await sys.run(['systemctl', 'restart', 'dex']);
201
+ sys.print(`New password for ${email} (shown ONCE, kept nowhere in clear):`);
202
+ sys.print(` ${pw}`);
203
+ }
package/deploy/install.sh CHANGED
@@ -263,15 +263,22 @@ if [ "$KEEP_RELEASE" = "yes" ]; then
263
263
  release="$(basename "$(readlink -f "$CODE/current")")"
264
264
  echo " the release that runs is kept: $release"
265
265
  else
266
- if [ -n "$VERSION" ]; then
266
+ if [ -n "$VERSION" ] && [ -L "$CODE/current" ] && [ "$(basename "$(readlink -f "$CODE/current")")" = "$VERSION" ]; then
267
+ # Run again with the version that runs already (a step after it failed, a
268
+ # DNS record made late): kept as it is, and the rest goes on.
269
+ release="$VERSION"
270
+ echo " clavarium@$VERSION runs here already: kept"
271
+ elif [ -n "$VERSION" ]; then
267
272
  echo " clavarium@$VERSION from npm: fetched, checked, built, tried (a few minutes)"
268
273
  release="$(updater prepare --version "$VERSION")" || refuse "clavarium@$VERSION was not installed (above: why)"
269
274
  else
270
275
  echo " the working tree $FROM_DIR (development): copied, built, tried"
271
276
  release="$(updater prepare --from-dir "$FROM_DIR")" || refuse "the working tree was not installed (above: why)"
272
277
  fi
273
- systemctl stop clavarium.service 2>/dev/null || true
274
- updater switch "$release"
278
+ if [ "$(basename "$(readlink -f "$CODE/current" 2>/dev/null)")" != "$release" ]; then
279
+ systemctl stop clavarium.service 2>/dev/null || true
280
+ updater switch "$release"
281
+ fi
275
282
  fi
276
283
  APP="$CODE/current/app"
277
284
  NODE="$CODE/current/node/bin/node"
@@ -531,7 +538,10 @@ else
531
538
  fi
532
539
 
533
540
  # Behind a relay, the front listens on the loopback only: the tunnel is the
534
- # one way in.
541
+ # one way in. Let's Encrypt first; if it cannot be reached (a tunnel not up yet),
542
+ # a certificate Caddy makes itself, so the site still answers (with a warning).
543
+ # Caddy would keep that one for hours: clavarium-certificate.timer replaces it
544
+ # with Let's Encrypt's as soon as it can (sudo clavarium doctor --certificate).
535
545
  BIND=""; [ -z "$RELAY" ] || BIND=" bind 127.0.0.1"
536
546
  cat > /etc/caddy/Caddyfile <<CADDY
537
547
  # The only thing on this machine that takes what comes from the outside.
@@ -614,6 +624,8 @@ PROXYUNIT
614
624
  systemctl daemon-reload
615
625
  systemctl enable --quiet caddy.service dex.service oauth2-proxy.service
616
626
  systemctl restart dex.service caddy.service oauth2-proxy.service
627
+ # A certificate Caddy made itself (its fallback) replaced by Let's Encrypt's as soon as it can.
628
+ /usr/local/bin/clavarium doctor --install-watch || echo "install: the certificate timer could not be set: sudo clavarium doctor --fix" >&2
617
629
 
618
630
  step "edge: checks"
619
631
  local_get() { curl -sk -o /dev/null -w '%{http_code}' --max-time 5 --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN$1" || true; }
@@ -685,7 +697,11 @@ fi
685
697
 
686
698
  echo
687
699
  echo "Clavarium is installed: https://$DOMAIN"
688
- if [ -n "$PASSWORDS" ]; then
700
+ if [ -n "$PASSWORDS" ] && [ -n "${CLAVARIUM_PASSWORDS_TO:-}" ]; then
701
+ # Run by `clavarium online`: it shows them LAST, where they do not scroll away, then deletes them.
702
+ ( umask 077; printf '%s' "$PASSWORDS" > "$CLAVARIUM_PASSWORDS_TO" )
703
+ echo "Sign in with: the password is shown at the very end."
704
+ elif [ -n "$PASSWORDS" ]; then
689
705
  echo "Sign in with (shown ONCE, kept nowhere in clear):"
690
706
  printf '%s' "$PASSWORDS"
691
707
  else
@@ -695,14 +711,17 @@ if [ -n "$RELAY" ] && [ "$RELAY_INSTALLED" = "yes" ]; then
695
711
  echo "The relay $RELAY is set up; the tunnel comes up by itself. Its fingerprint: $RELAY_PRINT"
696
712
  elif [ -n "$RELAY" ]; then
697
713
  echo
698
- echo "ON THE RELAY ($RELAY), run this one command; the tunnel then comes up by itself:"
699
- echo " sudo deploy/relay.sh --key \"$TUNNEL_KEY\""
700
714
  # A server with a screen and a keyboard only: the key, carried by a phone.
701
715
  # It is a PUBLIC key: showing it is safe; it lets the relay know this machine.
716
+ # The QR code far from the key's text, so a phone reads the code, not the text.
702
717
  if command -v qrencode >/dev/null 2>&1 || apt-get install -y -qq qrencode >/dev/null 2>&1; then
703
- echo "The same key, to read with a phone or a tablet:"
718
+ echo "The relay's tunnel key, as a QR code:"
719
+ echo "WARNING: read the QR code with the camera, not the text: some phones read the text on screen and get letters wrong, while the code is exact."
704
720
  qrencode -t ANSI -m 1 -l L -- "$TUNNEL_KEY" || true
721
+ printf '\n\n\n\n\n\n'
705
722
  fi
723
+ echo "ON THE RELAY ($RELAY), run this one command; the tunnel then comes up by itself:"
724
+ echo " sudo deploy/relay.sh --key \"$TUNNEL_KEY\""
706
725
  echo "It will print this, and it must be the same: $RELAY_PRINT"
707
726
  echo "The name $DOMAIN must lead to the relay."
708
727
  else
package/deploy/online.mjs CHANGED
@@ -31,6 +31,7 @@ import { createInterface } from 'node:readline';
31
31
  import { tmpdir } from 'node:os';
32
32
  import { join } from 'node:path';
33
33
  import { readConf } from './updater.mjs';
34
+ import { doctor, servedCert, DOCTOR_PATHS } from './doctor.mjs';
34
35
 
35
36
  export const PATHS = {
36
37
  access: '/var/lib/clavarium-relay-access',
@@ -40,6 +41,7 @@ export const PATHS = {
40
41
  unit: '/etc/systemd/system/clavarium.service',
41
42
  code: '/opt/clavarium',
42
43
  caddy: '/usr/local/bin/caddy',
44
+ passwords: '/run/clavarium-passwords',
43
45
  };
44
46
  export const STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
45
47
  const DOMAIN = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$/;
@@ -81,9 +83,9 @@ export function parseOnline(args) {
81
83
  }
82
84
  }
83
85
  if (o.relay && o.port) throw new Bad('say how it will be reached: --relay HOST or --port, not both');
84
- /* Neither: through a relay that does not exist yet -- its key is shown first,
85
- and its address asked once the operator has made it with that key. */
86
- if (!o.relay && !o.port) o.askRelay = true;
86
+ /* Neither: the usual case -- the domain points to the relay, which is
87
+ reached by the domain itself (its address is not needed). */
88
+ if (!o.relay && !o.port) o.relay = o.domain;
87
89
  if (!DOMAIN.test(o.domain)) throw new Bad('--domain is a host name, lower case, like clav.example.com');
88
90
  o.admin ||= `admin@${o.domain}`;
89
91
  if (!EMAIL.test(o.admin)) throw new Bad('--admin is an e-mail address, lower case');
@@ -123,24 +125,30 @@ export async function ensureKeys(sys, P = PATHS) {
123
125
  return { setup: read(f.setup), keeper: read(f.keeper), tunnel: read(f.tunnel) };
124
126
  }
125
127
 
128
+ /* The QR code far from the key's text: with the text next to it, a phone may read
129
+ the TEXT (I/l/1 and case mixed up, the end cut off) instead of the code. */
130
+ export const QR_WARNING = 'WARNING: read the QR code with the camera, not the text: some phones read the text on screen and get letters wrong, while the code is exact.';
126
131
  async function qr(sys, text) {
127
132
  const r = await sys.run(['qrencode', '-t', 'ANSI', '-m', '1', '-l', 'L', '--', text]);
128
- if (r.code === 0 && r.out) sys.print(r.out.replace(/\n$/, ''));
133
+ if (r.code !== 0 || !r.out) return;
134
+ sys.print(QR_WARNING);
135
+ sys.print(r.out.replace(/\n$/, ''));
136
+ sys.print('\n\n\n\n\n');
129
137
  }
130
138
 
131
139
  /* `sudo clavarium key`: the public keys, as text and as QR codes for a phone. */
132
140
  export async function showKeys(sys, P = PATHS, { which = 'all' } = {}) {
133
141
  const keys = await ensureKeys(sys, P);
134
142
  if (which === 'all' || which === 'setup') {
143
+ await qr(sys, keys.setup);
135
144
  sys.print('SETUP KEY: for `clavarium online --relay`. Put it once on the relay, in');
136
145
  sys.print('~/.ssh/authorized_keys of an account that has sudo. It is taken out at the end.');
137
146
  sys.print(` ${keys.setup}`);
138
- await qr(sys, keys.setup);
139
147
  }
140
148
  if (which === 'all' || which === 'tunnel') {
149
+ await qr(sys, keys.tunnel);
141
150
  sys.print('TUNNEL KEY: only if you set up the relay yourself:');
142
151
  sys.print(` sudo deploy/relay.sh --key "${keys.tunnel}"`);
143
- await qr(sys, keys.tunnel);
144
152
  }
145
153
  sys.print('These are PUBLIC keys: showing them is safe.');
146
154
  return keys;
@@ -379,9 +387,9 @@ async function setUpRelay(sys, P, o, keys) {
379
387
  }
380
388
 
381
389
  sys.print('');
390
+ await qr(sys, keys.setup);
382
391
  sys.print(`1. Put this SETUP KEY on the relay, in ~${o.user}/.ssh/authorized_keys of ${o.relay}${o.user === 'root' ? '' : ` (${o.user} needs sudo without a password)`}:`);
383
392
  sys.print(` ${keys.setup}`);
384
- await qr(sys, keys.setup);
385
393
  sys.print('(A public key: safe to show. It is taken out again at the end.)');
386
394
  sys.print('');
387
395
  const deadline = sys.now() + WAIT_MS;
@@ -443,10 +451,22 @@ async function switchToServer(sys, P, o) {
443
451
  if (o.relay) argv.push('--relay', o.relay, '--relay-port', o.relayPort, '--relay-installed');
444
452
  sys.print('');
445
453
  sys.print('Switching to server mode: HTTPS, sign-in, the administrator...');
446
- const r = await sys.run(argv, { stream: true, inherit: true });
454
+ const r = await sys.run(argv, { stream: true, inherit: true, env: { CLAVARIUM_PASSWORDS_TO: P.passwords } });
447
455
  if (r.code !== 0) throw new Failed(`the switch to server mode failed (exit ${r.code}, above: why)`);
448
456
  }
449
457
 
458
+ /* The certificate: Caddy asks Let's Encrypt as it starts; a few minutes at most. */
459
+ async function waitCertificate(sys, o) {
460
+ sys.print('');
461
+ sys.print(`Waiting for the certificate of ${o.domain} from Let's Encrypt (a few minutes at most)...`);
462
+ for (let i = 0; i < 18; i += 1) {
463
+ const c = await servedCert(sys, o.domain);
464
+ if (!c.none && c.verified && !c.local) { sys.print(` obtained: ${c.issuer}`); return; }
465
+ await sys.sleep(10_000);
466
+ }
467
+ sys.print(' not yet: Caddy keeps asking by itself. The diagnosis below says why.');
468
+ }
469
+
450
470
  /* `sudo clavarium online ...` */
451
471
  export async function online(o, sys, P = PATHS) {
452
472
  const was = serverModeOf(P);
@@ -478,10 +498,21 @@ export async function online(o, sys, P = PATHS) {
478
498
  } finally {
479
499
  tunnel?.proc.stop();
480
500
  }
481
- await switchToServer(sys, P, o);
482
- sys.print('');
483
- sys.print(`Online: https://${o.domain} (sign in as ${o.admin}, with the password above)`);
484
- if (o.relay) sys.print('The relay is watched from here: sudo clavarium relay status');
501
+ try {
502
+ await switchToServer(sys, P, o);
503
+ await waitCertificate(sys, o);
504
+ await doctor(sys, {}, { ...DOCTOR_PATHS, unit: P.unit, relayConf: P.relayConf });
505
+ sys.print('');
506
+ sys.print(`Online: https://${o.domain}`);
507
+ if (o.relay) sys.print('The relay is watched from here: sudo clavarium relay status');
508
+ sys.print('Anything wrong later: sudo clavarium doctor (it says what to do; --fix repairs what it can)');
509
+ const pw = existsSync(P.passwords) ? readFileSync(P.passwords, 'utf8') : '';
510
+ sys.print('');
511
+ if (pw) {
512
+ sys.print('SIGN IN WITH (shown ONCE, kept nowhere in clear; lost: sudo clavarium password EMAIL):');
513
+ sys.print(pw.replace(/\n$/, ''));
514
+ } else sys.print(`Sign in as ${o.admin}, with its password (lost: sudo clavarium password ${o.admin}).`);
515
+ } finally { rmSync(P.passwords, { force: true }); }
485
516
  }
486
517
 
487
518
  /* `sudo clavarium relay VERB` */
@@ -159,6 +159,8 @@ export class Updater {
159
159
  #save(change) {
160
160
  const state = { ...this.#state(), ...change, releases: this.releases().map(({ id, version, dataFormat, current }) => ({ id, version, dataFormat, current })) };
161
161
  state.version = this.current()?.version ?? null;
162
+ /* "Available" is only ever a version NEWER than the one that runs: once it runs, it is not offered again. */
163
+ if (state.available && (!VERSION.test(state.available) || (state.version && VERSION.test(state.version) && compareVersions(state.available, state.version) <= 0))) state.available = null;
162
164
  mkdirSync(this.#o.stateDir, { recursive: true, mode: 0o755 });
163
165
  const file = join(this.#o.stateDir, 'state.json');
164
166
  writeFileSync(`${file}.new`, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o644 });
@@ -494,8 +496,12 @@ export class Updater {
494
496
  try { request = readFileSync(file, 'utf8').trim(); rmSync(file, { force: true }); } catch { /* none */ }
495
497
  const [verb, arg] = request.split(/\s+/);
496
498
  try {
497
- if (verb === 'apply') return await this.apply(arg);
498
- if (verb === 'rollback') return await this.rollback(arg);
499
+ /* Asked by the administrator: once done, the registry asked once more, so what is offered is what is true now. */
500
+ if (verb === 'apply' || verb === 'rollback') {
501
+ const r = verb === 'apply' ? await this.apply(arg) : await this.rollback(arg);
502
+ await this.check().catch(() => {});
503
+ return r;
504
+ }
499
505
  return await this.tick();
500
506
  } catch (error) {
501
507
  if (error instanceof Refused && request) this.#save({ refused: { at: this.#o.now().toISOString(), why: error.message } });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.1.0",
9
+ "version": "0.1.2",
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.1.0",
3
+ "version": "0.1.2",
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.",
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { readFileSync, existsSync } from 'node:fs';
16
16
  import { Refusal } from './conversations.mjs';
17
+ import { compareVersions } from '../deploy/updater.mjs';
17
18
 
18
19
  export const STATE_FILE = '/var/lib/clavarium/update/state.json';
19
20
  export const CONF_FILE = '/etc/clavarium/update.conf';
@@ -46,12 +47,15 @@ export class Updates {
46
47
  about() {
47
48
  if (!this.installed) return { installed: false, version: this.#version };
48
49
  const s = this.#read();
50
+ const version = s.version ?? this.#version;
51
+ /* Never offered: the version that runs, or an older one (a state written before it ran). */
52
+ const newer = VERSION.test(s.available ?? '') && !(VERSION.test(version ?? '') && compareVersions(s.available, version) <= 0);
49
53
  const releases = Array.isArray(s.releases) ? s.releases : [];
50
54
  return {
51
55
  installed: true,
52
- version: s.version ?? this.#version,
56
+ version,
53
57
  ...this.#settings(),
54
- available: VERSION.test(s.available ?? '') ? s.available : null,
58
+ available: newer ? s.available : null,
55
59
  checkedAt: s.checkedAt ?? null,
56
60
  checkError: s.checkError ?? null,
57
61
  running: s.running ?? null,