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.
- package/deploy/clavarium.mjs +17 -1
- package/deploy/doctor.mjs +203 -0
- package/deploy/install.sh +27 -8
- package/deploy/online.mjs +43 -12
- package/deploy/updater.mjs +8 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/updates.mjs +6 -2
package/deploy/clavarium.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
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:
|
|
85
|
-
|
|
86
|
-
if (!o.relay && !o.port) o.
|
|
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
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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` */
|
package/deploy/updater.mjs
CHANGED
|
@@ -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
|
-
|
|
498
|
-
if (verb === '
|
|
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 } });
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
9
|
+
"version": "0.1.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/package.json
CHANGED
package/server/updates.mjs
CHANGED
|
@@ -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
|
|
56
|
+
version,
|
|
53
57
|
...this.#settings(),
|
|
54
|
-
available:
|
|
58
|
+
available: newer ? s.available : null,
|
|
55
59
|
checkedAt: s.checkedAt ?? null,
|
|
56
60
|
checkError: s.checkError ?? null,
|
|
57
61
|
running: s.running ?? null,
|