clavarium 0.1.0-next.7 → 0.1.0-next.9
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/README.md +8 -2
- package/deploy/online.mjs +83 -8
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -216,8 +216,14 @@ The host needs `tmux` and `bindfs` (the read-only views).
|
|
|
216
216
|
# the stable version from npm, in local mode, asking nothing (Node.js pinned and
|
|
217
217
|
# checked first); it prints the next command and the public keys (text and QR)
|
|
218
218
|
curl -fsSL https://clavarium.org/install.sh | sudo bash
|
|
219
|
-
#
|
|
220
|
-
#
|
|
219
|
+
# online in the same line: it installs, shows the setup key (text and QR), asks for the
|
|
220
|
+
# relay's address once you made it with that key, waits for your DNS, sets the relay up
|
|
221
|
+
# over SSH, tests from the internet, shows the password once
|
|
222
|
+
DOMAIN=<your-domain>
|
|
223
|
+
EMAIL=<your-email>
|
|
224
|
+
curl -fsSL https://clavarium.org/install.sh | sudo bash -s -- --domain "$DOMAIN" --admin "$EMAIL" --relay-user ubuntu
|
|
225
|
+
curl -fsSL https://clavarium.org/install.sh | sudo bash -s -- --direct --domain "$DOMAIN"
|
|
226
|
+
# or later, from local mode
|
|
221
227
|
sudo clavarium online --relay relay.example.net --domain clav.example.com
|
|
222
228
|
sudo clavarium online --port --domain clav.example.com
|
|
223
229
|
sudo clavarium key # the public keys again
|
package/deploy/online.mjs
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* `sys`, so the tests can stand in for SSH, npm and DNS.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, mkdtempSync, renameSync, openSync, readSync, writeSync, closeSync } from 'node:fs';
|
|
27
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, mkdtempSync, renameSync, openSync, readSync, writeSync, closeSync } from 'node:fs';
|
|
28
28
|
import { promises as dns } from 'node:dns';
|
|
29
|
+
import { connect } from 'node:net';
|
|
29
30
|
import { createInterface } from 'node:readline';
|
|
30
31
|
import { tmpdir } from 'node:os';
|
|
31
32
|
import { join } from 'node:path';
|
|
@@ -62,7 +63,7 @@ export const USAGE = [
|
|
|
62
63
|
].join('\n');
|
|
63
64
|
|
|
64
65
|
export function parseOnline(args) {
|
|
65
|
-
const o = { relay: '', user: 'root', relayPort: '22', domain: '', admin: '', fingerprint: '', keepSetupKey: false, yes: false, port: false };
|
|
66
|
+
const o = { askRelay: false, relay: '', user: 'root', relayPort: '22', domain: '', admin: '', fingerprint: '', keepSetupKey: false, yes: false, port: false };
|
|
66
67
|
const value = (i, name) => { const v = args[i + 1]; if (v === undefined || v.startsWith('--')) throw new Bad(`${name} needs a value`); return v; };
|
|
67
68
|
for (let i = 0; i < args.length; i++) {
|
|
68
69
|
const a = args[i];
|
|
@@ -79,12 +80,15 @@ export function parseOnline(args) {
|
|
|
79
80
|
default: throw new Bad(`unknown parameter: ${a}`);
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
if (
|
|
83
|
+
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;
|
|
83
87
|
if (!DOMAIN.test(o.domain)) throw new Bad('--domain is a host name, lower case, like clav.example.com');
|
|
84
88
|
o.admin ||= `admin@${o.domain}`;
|
|
85
89
|
if (!EMAIL.test(o.admin)) throw new Bad('--admin is an e-mail address, lower case');
|
|
86
|
-
if (o.relay) {
|
|
87
|
-
if (!HOST.test(o.relay)) throw new Bad('--relay is the name or the address of the relay machine');
|
|
90
|
+
if (o.relay || o.askRelay) {
|
|
91
|
+
if (o.relay && !HOST.test(o.relay)) throw new Bad('--relay is the name or the address of the relay machine');
|
|
88
92
|
if (!USER.test(o.user)) throw new Bad('--relay-user is an account name');
|
|
89
93
|
if (!/^\d{1,5}$/.test(o.relayPort) || +o.relayPort < 1 || +o.relayPort > 65535) throw new Bad('--relay-port is a port number');
|
|
90
94
|
if (o.fingerprint && !PRINT.test(o.fingerprint)) throw new Bad('--relay-fingerprint looks like SHA256:... (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the relay)');
|
|
@@ -144,7 +148,7 @@ export async function showKeys(sys, P = PATHS, { which = 'all' } = {}) {
|
|
|
144
148
|
|
|
145
149
|
/* ---------------------------------- SSH ---------------------------------- */
|
|
146
150
|
const sshOptions = (key, knownHosts, port) => [
|
|
147
|
-
'-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', `IdentityFile=${key}`,
|
|
151
|
+
'-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', `IdentityFile=${key}`, '-o', 'PreferredAuthentications=publickey', '-o', 'NumberOfPasswordPrompts=0',
|
|
148
152
|
'-o', `UserKnownHostsFile=${knownHosts}`, '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', '-p', String(port),
|
|
149
153
|
];
|
|
150
154
|
const quote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
@@ -215,6 +219,19 @@ export async function checkDns(sys, o) {
|
|
|
215
219
|
return got;
|
|
216
220
|
}
|
|
217
221
|
|
|
222
|
+
/* The domain made to point to the relay while the operator is at it: waited for
|
|
223
|
+
(every 30 s, Enter to look now), the record to make said once. */
|
|
224
|
+
export async function waitDns(sys, o) {
|
|
225
|
+
let said = false;
|
|
226
|
+
for (;;) {
|
|
227
|
+
const relay = await addresses(sys, o.relay);
|
|
228
|
+
const got = await addresses(sys, o.domain);
|
|
229
|
+
if (relay.length && got.some((a) => relay.includes(a))) return;
|
|
230
|
+
if (!said && relay.length) { said = true; sys.print(`At your DNS provider, point the domain to the relay:\n ${o.domain} A ${relay[0]}\nWaiting for it (it takes a few minutes) -- or press Enter to look now.`); }
|
|
231
|
+
await (sys.sleepOrEnter ? sys.sleepOrEnter(30_000) : sys.sleep(30_000));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
218
235
|
/* ------------------------ the test from the internet ------------------------ */
|
|
219
236
|
/* What Let's Encrypt (or Caddy) said, as the operator needs to read it. */
|
|
220
237
|
export function explain(error, { domain, port, relay }) {
|
|
@@ -304,12 +321,34 @@ async function fingerprintOf(sys, P, line) {
|
|
|
304
321
|
return { file, print };
|
|
305
322
|
}
|
|
306
323
|
|
|
307
|
-
|
|
324
|
+
/* NOT BANNED WHILE WAITING. A relay may run fail2ban (by default: banned after
|
|
325
|
+
5 failed logins in 10 minutes). So: while its SSH port does not even answer,
|
|
326
|
+
only that port is looked at (no login, nothing counted); once it answers,
|
|
327
|
+
one key, one try, then 5 minutes, a second, 5 minutes, a third (the
|
|
328
|
+
operator's rule): after three failures it waits for Enter on the keyboard
|
|
329
|
+
(with --yes and no keyboard: 5 minutes more each time). */
|
|
330
|
+
export const KNOCK_MS = 5 * 60_000;
|
|
331
|
+
export const KNOCKS = 3;
|
|
332
|
+
const portAnswers = (host, port, ms = 5000) => new Promise((resolve) => {
|
|
333
|
+
const s = connect({ host, port: Number(port) });
|
|
334
|
+
const done = (ok) => { s.destroy(); resolve(ok); };
|
|
335
|
+
s.setTimeout(ms, () => done(false)); s.once('connect', () => done(true)); s.once('error', () => done(false));
|
|
336
|
+
});
|
|
337
|
+
export async function waitForSetupKey(sys, o, at, deadline) {
|
|
308
338
|
const check = o.user === 'root' ? 'true' : 'sudo -n true';
|
|
309
339
|
let said = '';
|
|
310
340
|
const say = (text) => { if (text !== said) { said = text; sys.print(text); } };
|
|
341
|
+
let tries = 0;
|
|
342
|
+
const knock = sys.portAnswers ?? portAnswers;
|
|
311
343
|
for (;;) {
|
|
344
|
+
if (!(await knock(o.relay, o.relayPort ?? 22))) {
|
|
345
|
+
say(`${o.relay} does not answer on its SSH port yet: waiting, without trying to log in...`);
|
|
346
|
+
if (sys.now() > deadline) throw new Failed(`${o.relay} did not answer on its SSH port within ${WAIT_MS / 60000} minutes`);
|
|
347
|
+
await sys.sleep(15_000);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
312
350
|
const r = await sys.run(['ssh', ...at, `${o.user}@${o.relay}`, check]);
|
|
351
|
+
tries += 1;
|
|
313
352
|
if (r.code === 0) { sys.print(`The setup key opens ${o.user}@${o.relay}${o.user === 'root' ? '' : ', with sudo'}.`); return; }
|
|
314
353
|
const err = r.err || '';
|
|
315
354
|
if (/password is required|not in the sudoers|may not run sudo/i.test(err)) say(`${o.user} on ${o.relay} needs sudo without a password: add "${o.user} ALL=(ALL) NOPASSWD: ALL" in /etc/sudoers.d there. Waiting...`);
|
|
@@ -317,7 +356,14 @@ async function waitForSetupKey(sys, o, at, deadline) {
|
|
|
317
356
|
else if (/Host key verification failed|REMOTE HOST IDENTIFICATION/i.test(err)) throw new Refused(`${o.relay} answered with another key than a moment ago: stopped`);
|
|
318
357
|
else say(`${o.relay} does not answer yet (${tail(r)}). Waiting...`);
|
|
319
358
|
if (sys.now() > deadline) throw new Failed(`the setup key was not accepted on ${o.user}@${o.relay} within ${WAIT_MS / 60000} minutes`);
|
|
320
|
-
|
|
359
|
+
if (tries >= KNOCKS && !o.yes && sys.waitEnter) {
|
|
360
|
+
sys.print(` ${KNOCKS} tries refused: put the setup key on the relay, then press Enter to try again (Ctrl-C to stop).`);
|
|
361
|
+
if (!(await sys.waitEnter())) throw new Failed('no keyboard to wait on: run sudo clavarium online again once the key is on the relay');
|
|
362
|
+
tries = 0;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
sys.print(` next try in ${KNOCK_MS / 60_000} min (${tries} of ${KNOCKS})${sys.sleepOrEnter && !o.yes ? ' -- or press Enter to try now' : ''}`);
|
|
366
|
+
await (sys.sleepOrEnter && !o.yes ? sys.sleepOrEnter(KNOCK_MS) : sys.sleep(KNOCK_MS));
|
|
321
367
|
}
|
|
322
368
|
}
|
|
323
369
|
|
|
@@ -407,6 +453,17 @@ export async function online(o, sys, P = PATHS) {
|
|
|
407
453
|
if (was) throw new Refused(`this machine is in server mode already: https://${was}`);
|
|
408
454
|
if (!existsSync(P.caddy)) throw new Failed('the HTTPS programs are not on this machine: run the installer again (curl -fsSL https://clavarium.org/install.sh | sudo bash)');
|
|
409
455
|
const keys = await ensureKeys(sys, P);
|
|
456
|
+
if (o.askRelay) {
|
|
457
|
+
await showKeys(sys, P, { which: 'setup' });
|
|
458
|
+
sys.print('Create the relay at your host now (Ubuntu), with the key above as its SSH key.');
|
|
459
|
+
for (;;) {
|
|
460
|
+
const a = String(await sys.askLine('Its address, once it exists (IP or name): ') ?? '').trim();
|
|
461
|
+
if (HOST.test(a)) { o.relay = a; break; }
|
|
462
|
+
if (!a && !sys.hasKeyboard?.()) throw new Failed('no keyboard to ask on: give --relay HOST');
|
|
463
|
+
sys.print(' That is not an address: an IP address, or a name like relay.example.com');
|
|
464
|
+
}
|
|
465
|
+
await waitDns(sys, o);
|
|
466
|
+
}
|
|
410
467
|
await checkDns(sys, o);
|
|
411
468
|
await portsFree(sys);
|
|
412
469
|
if (o.relay) await setUpRelay(sys, P, o, keys);
|
|
@@ -478,6 +535,24 @@ export function realSys() {
|
|
|
478
535
|
const exited = new Promise((resolve) => { child.on('error', () => { ended = true; resolve({ code: 127 }); }); child.on('close', (code) => { ended = true; resolve({ code: code ?? 1 }); }); });
|
|
479
536
|
return { exited, ended: () => ended, stop: () => { if (!ended) child.kill('SIGTERM'); } };
|
|
480
537
|
},
|
|
538
|
+
/* A wait that Enter on the keyboard cuts short (no keyboard: the whole wait). */
|
|
539
|
+
sleepOrEnter: (ms) => new Promise((resolve) => {
|
|
540
|
+
let tty; let timer;
|
|
541
|
+
const done = () => { clearTimeout(timer); try { tty?.destroy(); } catch { /* gone */ } resolve(); };
|
|
542
|
+
timer = setTimeout(done, ms);
|
|
543
|
+
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 */ }
|
|
544
|
+
}),
|
|
545
|
+
hasKeyboard: () => { try { closeSync(openSync('/dev/tty', 'r')); return true; } catch { return false; } },
|
|
546
|
+
askLine: async (question) => {
|
|
547
|
+
let fd;
|
|
548
|
+
try { fd = openSync('/dev/tty', 'r+'); } catch { return ''; }
|
|
549
|
+
try { writeSync(fd, question); const buf = Buffer.alloc(512); let s = ''; while (!s.includes('\n')) { const n = readSync(fd, buf, 0, buf.length, null); if (!n) break; s += buf.toString('utf8', 0, n); } return s.split('\n')[0]; } finally { closeSync(fd); }
|
|
550
|
+
},
|
|
551
|
+
waitEnter: async () => {
|
|
552
|
+
let fd;
|
|
553
|
+
try { fd = openSync('/dev/tty', 'r'); } catch { return false; }
|
|
554
|
+
try { const buf = Buffer.alloc(256); let s = ''; while (!s.includes('\n')) { const n = readSync(fd, buf, 0, buf.length, null); if (!n) break; s += buf.toString('utf8', 0, n); } return true; } finally { closeSync(fd); }
|
|
555
|
+
},
|
|
481
556
|
ask: async (question) => {
|
|
482
557
|
let fd;
|
|
483
558
|
try { fd = openSync('/dev/tty', 'r+'); } catch { return false; }
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.1.0-next.
|
|
3
|
+
"version": "0.1.0-next.9",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.1.0-next.
|
|
9
|
+
"version": "0.1.0-next.9",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|