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 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
- # then online: HTTPS, an administrator, a password shown once -- after a real
220
- # test from the internet (Let's Encrypt staging); the relay is set up over SSH
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 (!o.relay === !o.port) throw new Bad('say how it will be reached: --relay HOST or --port');
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
- async function waitForSetupKey(sys, o, at, deadline) {
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
- await sys.sleep(5000);
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; }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.0-next.7",
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.7",
9
+ "version": "0.1.0-next.9",
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-next.7",
3
+ "version": "0.1.0-next.9",
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.",