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 +82 -5
- package/deploy/install.sh +24 -0
- package/deploy/updater.mjs +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
package/deploy/updater.mjs
CHANGED
|
@@ -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': {
|
|
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;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
9
|
+
"version": "0.1.4",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|