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