clavarium 0.1.2 → 0.1.3

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]
@@ -150,6 +219,7 @@ OnUnitActiveSec=20min
150
219
  WantedBy=timers.target
151
220
  `;
152
221
  export async function installWatch(sys, P = DOCTOR_PATHS) {
222
+ await ensureProbe(sys, P);
153
223
  const svc = `${P.systemd}/clavarium-certificate.service`; const tim = `${P.systemd}/clavarium-certificate.timer`;
154
224
  if (read(svc) === WATCH_SERVICE && read(tim) === WATCH_TIMER) return false;
155
225
  writeFileSync(svc, WATCH_SERVICE); writeFileSync(tim, WATCH_TIMER);
@@ -169,7 +239,13 @@ export async function doctor(sys, { fix = false } = {}, P = DOCTOR_PATHS) {
169
239
  if (domainOf(P) && await installWatch(sys, P)) sys.print(' the certificate is now checked every 20 minutes (clavarium-certificate.timer)');
170
240
  for (const c of repairs) {
171
241
  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);
242
+ if (c.repair === 'probe' && await ensureProbe(sys, P)) sys.print(' the probe of port 80: set up');
243
+ if (c.repair === 'certificate') {
244
+ const seen = await reached(sys, domainOf(P), probeOf(P));
245
+ const staged = seen.ok || seen.silent ? await stagingTest(sys, P, domainOf(P)) : null;
246
+ if (staged?.ok) { sys.print(' validated by Let\'s Encrypt\'s test server'); await renewCertificate(sys, P); }
247
+ else sys.print(` the certificate: the real Let's Encrypt not asked, ${staged ? `its test server could not validate it: ${staged.why}` : seen.why}`);
248
+ }
173
249
  }
174
250
  if (!repairs.length) { sys.print(' nothing this command can repair: see "what to do" above'); return bad; }
175
251
  /* 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"
@@ -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. */
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.3",
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.3",
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.",