clavarium 0.1.2 → 0.1.4

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/doctor.mjs CHANGED
@@ -75,6 +75,10 @@ export async function diagnose(sys, P = DOCTOR_PATHS) {
75
75
  add({ what: `the name ${domain}`, ok: dnsOk, detail: addrs.length ? `leads to ${addrs.join(', ')}` : 'leads nowhere',
76
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
77
 
78
+ const seen = await reached(sys, domain, probeOf(P));
79
+ add({ what: `reached by its name, port 80`, ok: seen.ok, detail: seen.ok ? `http://${domain} leads here` : seen.why,
80
+ fix: seen.why?.includes('probe') ? 'sudo clavarium doctor --fix' : relay ? `the tunnel: sudo clavarium relay status; the name: ${domain} must lead to the relay` : `${domain} must lead to this machine, and port 80 be open to it`,
81
+ repair: seen.why?.includes('probe') ? 'probe' : null });
78
82
  const c = await servedCert(sys, domain);
79
83
  const days = Number.isFinite(c.end) ? Math.floor((c.end - sys.now()) / 86_400_000) : null;
80
84
  if (c.none) {
@@ -115,20 +119,85 @@ async function renewCertificate(sys, P) {
115
119
  sys.print(' Caddy restarted: it asks Let\'s Encrypt now');
116
120
  }
117
121
 
122
+ /* IS THIS MACHINE REACHED BY ITS NAME, ON PORT 80, FROM THE INTERNET? Asked
123
+ BEFORE Let's Encrypt is, so its quotas are not spent on a name or a tunnel
124
+ that is not ready: Caddy answers a token of this machine at
125
+ http://NAME/.well-known/clavarium-probe (the way Let's Encrypt comes in). */
126
+ export const PROBE_PATH = '/.well-known/clavarium-probe';
127
+ export const probeOf = (P) => /respond "([0-9a-f]{32})" 200/.exec(read(P.caddyfile) ?? '')?.[1] ?? null;
128
+ export function probeBlock(domain, token, behindRelay) {
129
+ return `
130
+ # Clavarium's probe: is this machine reached by its name on port 80? Asked before Let's Encrypt is.
131
+ http://${domain} {
132
+ ${behindRelay ? '\tbind 127.0.0.1\n' : ''}\thandle ${PROBE_PATH} {
133
+ \t\trespond "${token}" 200
134
+ \t}
135
+ \thandle {
136
+ \t\tredir https://{host}{uri} 308
137
+ \t}
138
+ }
139
+ `;
140
+ }
141
+ export async function reached(sys, domain, token) {
142
+ if (!token) return { ok: false, why: 'the probe is not set up (sudo clavarium doctor --fix sets it up)' };
143
+ const addrs = await sys.resolve(domain);
144
+ if (!addrs.length) return { ok: false, why: `${domain} leads nowhere (DNS)` };
145
+ const r = await sys.run(['curl', '-s', '--max-time', '15', '-o', '-', '-w', '\n%{http_code}', `http://${domain}${PROBE_PATH}`]);
146
+ const [body, code] = [r.out.split('\n').slice(0, -1).join('\n').trim(), r.out.split('\n').at(-1)];
147
+ if (body === token) return { ok: true };
148
+ return { ok: false, silent: r.code !== 0, why: r.code !== 0 ? `http://${domain} does not answer from the internet (curl exit ${r.code})` : `http://${domain} answers, but it is not this machine (HTTP ${code})` };
149
+ }
150
+
118
151
  /* `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. */
152
+ a certificate Caddy made itself is replaced by Let's Encrypt's as soon as it can
153
+ -- only once this machine is seen reached by its name: Let's Encrypt is not
154
+ asked for nothing. Nothing at all while the certificate is a public one. */
122
155
  export async function certificateWatch(sys, P = DOCTOR_PATHS) {
123
156
  const domain = domainOf(P);
124
157
  if (!domain) return 0;
125
158
  const c = await servedCert(sys, domain);
126
159
  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`);
160
+ const seen = await reached(sys, domain, probeOf(P));
161
+ /* No answer from here is not proof: a home router may not loop its own public
162
+ address back (--port). Then Let's Encrypt's test server decides. A name that
163
+ leads nowhere, or to another machine, is proof enough: nothing is asked. */
164
+ if (!seen.ok && !seen.silent) { sys.print(`${domain}: ${c.none ? 'no certificate served' : 'a certificate made by Caddy itself'}; Let's Encrypt not asked: ${seen.why}`); return 0; }
165
+ const staged = await stagingTest(sys, P, domain);
166
+ if (!staged.ok) { sys.print(`${domain}: Let's Encrypt's test server could not validate it: ${staged.why}. The real one is not asked; Caddy's own certificate is kept.`); return 0; }
167
+ sys.print(`${domain}: reached from the internet and validated by Let's Encrypt's test server: asking the real one now`);
128
168
  await renewCertificate(sys, P);
129
169
  return 0;
130
170
  }
131
171
 
172
+ /* LET'S ENCRYPT'S TEST SERVER FIRST (staging: its quotas are far larger): the
173
+ real one is asked only once the test one validated this very name, the same
174
+ way. Port 80 is the front's, so the front stops for the test (seconds; the
175
+ site only had Caddy's own certificate anyway) and starts again after it,
176
+ whatever happens. */
177
+ export async function stagingTest(sys, P, domain) {
178
+ const { acmeTest } = await import('./online.mjs');
179
+ await sys.run(['systemctl', 'stop', 'caddy']);
180
+ try {
181
+ return await acmeTest(sys, { caddy: P.caddy }, { domain, relay: relayOf(P), challenge: 'http', timeoutMs: 90_000 });
182
+ } catch (error) {
183
+ return { ok: false, why: error.message };
184
+ } finally {
185
+ await sys.run(['systemctl', 'start', 'caddy']);
186
+ }
187
+ }
188
+
189
+ /* An install before the probe existed: it is added to the Caddyfile. */
190
+ export async function ensureProbe(sys, P = DOCTOR_PATHS) {
191
+ const domain = domainOf(P); const file = read(P.caddyfile);
192
+ if (!domain || !file || probeOf(P)) return false;
193
+ const token = (await sys.run(['openssl', 'rand', '-hex', '16'])).out.trim();
194
+ if (!/^[0-9a-f]{32}$/.test(token)) return false;
195
+ writeFileSync(`${P.caddyfile}.new`, file + probeBlock(domain, token, Boolean(relayOf(P))));
196
+ renameSync(`${P.caddyfile}.new`, P.caddyfile);
197
+ await sys.run(['systemctl', 'reload', 'caddy']);
198
+ return true;
199
+ }
200
+
132
201
  /* The timer of certificateWatch. Written by install.sh, and by doctor --fix on a
133
202
  machine installed before it existed (an update does not rerun install.sh). */
134
203
  export const WATCH_SERVICE = `[Unit]
@@ -144,12 +213,14 @@ Description=Clavarium - the certificate, every 20 minutes
144
213
 
145
214
  [Timer]
146
215
  OnBootSec=10min
216
+ OnActiveSec=2min
147
217
  OnUnitActiveSec=20min
148
218
 
149
219
  [Install]
150
220
  WantedBy=timers.target
151
221
  `;
152
222
  export async function installWatch(sys, P = DOCTOR_PATHS) {
223
+ await ensureProbe(sys, P);
153
224
  const svc = `${P.systemd}/clavarium-certificate.service`; const tim = `${P.systemd}/clavarium-certificate.timer`;
154
225
  if (read(svc) === WATCH_SERVICE && read(tim) === WATCH_TIMER) return false;
155
226
  writeFileSync(svc, WATCH_SERVICE); writeFileSync(tim, WATCH_TIMER);
@@ -169,7 +240,13 @@ export async function doctor(sys, { fix = false } = {}, P = DOCTOR_PATHS) {
169
240
  if (domainOf(P) && await installWatch(sys, P)) sys.print(' the certificate is now checked every 20 minutes (clavarium-certificate.timer)');
170
241
  for (const c of repairs) {
171
242
  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);
243
+ if (c.repair === 'probe' && await ensureProbe(sys, P)) sys.print(' the probe of port 80: set up');
244
+ if (c.repair === 'certificate') {
245
+ const seen = await reached(sys, domainOf(P), probeOf(P));
246
+ const staged = seen.ok || seen.silent ? await stagingTest(sys, P, domainOf(P)) : null;
247
+ if (staged?.ok) { sys.print(' validated by Let\'s Encrypt\'s test server'); await renewCertificate(sys, P); }
248
+ else sys.print(` the certificate: the real Let's Encrypt not asked, ${staged ? `its test server could not validate it: ${staged.why}` : seen.why}`);
249
+ }
173
250
  }
174
251
  if (!repairs.length) { sys.print(' nothing this command can repair: see "what to do" above'); return bad; }
175
252
  /* Let's Encrypt answers in seconds when all is well. */
package/deploy/install.sh CHANGED
@@ -543,6 +543,9 @@ fi
543
543
  # Caddy would keep that one for hours: clavarium-certificate.timer replaces it
544
544
  # with Let's Encrypt's as soon as it can (sudo clavarium doctor --certificate).
545
545
  BIND=""; [ -z "$RELAY" ] || BIND=" bind 127.0.0.1"
546
+ # The probe's token: kept on a rerun (see deploy/doctor.mjs, probeBlock).
547
+ PROBE="$(sed -n 's/.*respond "\([0-9a-f]\{32\}\)" 200.*/\1/p' /etc/caddy/Caddyfile 2>/dev/null | head -1)"
548
+ [ -n "$PROBE" ] || PROBE="$(openssl rand -hex 16)"
546
549
  cat > /etc/caddy/Caddyfile <<CADDY
547
550
  # The only thing on this machine that takes what comes from the outside.
548
551
  $DOMAIN {
@@ -560,6 +563,17 @@ $BIND
560
563
  reverse_proxy 127.0.0.1:4180
561
564
  }
562
565
  }
566
+
567
+ # Clavarium's probe: is this machine reached by its name on port 80? Asked before Let's Encrypt is.
568
+ http://$DOMAIN {
569
+ $BIND
570
+ handle /.well-known/clavarium-probe {
571
+ respond "$PROBE" 200
572
+ }
573
+ handle {
574
+ redir https://{host}{uri} 308
575
+ }
576
+ }
563
577
  CADDY
564
578
 
565
579
  step "edge: services"
@@ -691,6 +705,16 @@ TUNNEL
691
705
  systemctl daemon-reload
692
706
  systemctl enable --quiet clavarium-tunnel.service
693
707
  systemctl restart clavarium-tunnel.service
708
+ # Caddy asked Let's Encrypt as it started, BEFORE the tunnel was up: it could not
709
+ # be reached, and fell back to a certificate of its own. Asked again once the
710
+ # tunnel holds (ExitOnForwardFailure: an ssh still running after 10 s has its forwards).
711
+ if [ "$RELAY_INSTALLED" = "yes" ]; then
712
+ sleep 10
713
+ if systemctl is-active --quiet clavarium-tunnel.service; then
714
+ rm -rf -- "/var/lib/caddy/caddy/certificates/local/$DOMAIN"
715
+ systemctl restart caddy.service
716
+ fi
717
+ fi
694
718
  TUNNEL_KEY="$(cat /var/lib/clavarium-tunnel/key.pub)"
695
719
  RELAY_PRINT="$(ssh-keygen -l -f /var/lib/clavarium-tunnel/known_hosts | cut -d' ' -f2 | head -1)"
696
720
  fi
@@ -554,6 +554,10 @@ export class Updater {
554
554
  const r = await this.#run(['install', '-o', 'root', '-g', 'root', '-m', '0755', from, to], { timeoutMs: 30_000 });
555
555
  if (r.code !== 0) throw new Error(`${to}: ${lastLine(r)}`);
556
556
  }
557
+ /* What a release sets up beside itself (the certificate timer): an update does not run install.sh again.
558
+ Best effort: the update does not depend on it; `sudo clavarium doctor` says when it is missing. */
559
+ const node = join(dirname(app), 'node', 'bin', 'node');
560
+ if (existsSync(node)) await this.#run([node, join(app, 'deploy', 'clavarium.mjs'), 'doctor', '--install-watch'], { timeoutMs: 60_000 }).catch(() => {});
557
561
  }
558
562
 
559
563
  /* The Node.js the release pins (server/tools.mjs), checked against its checksum, shared between releases. */
@@ -591,7 +595,11 @@ async function main(argv) {
591
595
  switch (verb) {
592
596
  case 'check': await u.check().catch(() => {}); return print(u.about());
593
597
  case 'status': return print(u.about());
594
- case 'run': { const r = await u.runOnce(); console.log(typeof r === 'string' ? r : 'done'); return; }
598
+ case 'run': {
599
+ /* What this release sets up beside itself (the certificate timer), at its first turn: the
600
+ update that brought it ran the PREVIOUS release's code. Idempotent; never stops the turn. */
601
+ try { const [{ installWatch }, { realSys }] = await Promise.all([import('./doctor.mjs'), import('./online.mjs')]); if (await installWatch(realSys())) console.error('updater: the certificate timer set up'); } catch (error) { console.error(`updater: the certificate timer could not be set up: ${error.message}`); }
602
+ const r = await u.runOnce(); console.log(typeof r === 'string' ? r : 'done'); return; }
595
603
  case 'set': if (rest.length !== 2) throw new Refused('set <mode|channel|window|port> <value>'); return print(u.set(rest[0], rest[1]));
596
604
  case 'prepare': { const r = await u.prepare({ version: value('--version'), fromDir: value('--from-dir') }); console.log(r.id); return; }
597
605
  case 'switch': if (!VERSION.test(rest[0] ?? '') && !LOCAL.test(rest[0] ?? '')) throw new Refused('switch <release>'); u.switchTo(rest[0]); return;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.1.2",
9
+ "version": "0.1.4",
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.2",
3
+ "version": "0.1.4",
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.",