diff --git a/scripts/moshpit-renew.sh b/scripts/moshpit-renew.sh new file mode 100755 index 0000000..51a5586 --- /dev/null +++ b/scripts/moshpit-renew.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Renew the certificates the registry signed for this box, before they run out. +# +# setup-origin.sh asks the pit for a 30-day leaf and installs moshpit-renew.timer +# to run this daily. For every name under $CERTDIR whose certificate was signed +# by the registry (issuer is not the name itself) and expires within +# MOSHPIT_RENEW_DAYS (10), it re-runs setup-origin.sh with the key it kept, which +# asks for a fresh leaf, writes the chain, and reloads nginx. Self-signed names +# are left alone: their certificates last years and have nothing to renew from. +# +# Needs the API key setup-origin.sh saved at $RENEW_ENV (root-only). Without +# that file this does nothing and says so, which is the state of a box that +# never had a registry-signed certificate. +set -eu + +RENEW_ENV="${MOSHPIT_RENEW_ENV:-/etc/moshpit/renew.env}" +if [ ! -f "$RENEW_ENV" ]; then + echo "moshpit-renew: no $RENEW_ENV — nothing here was signed by the registry" + exit 0 +fi +# shellcheck disable=SC1090 +. "$RENEW_ENV" +CERTDIR="${MOSHPIT_CERTDIR:-/etc/ssl/moshpit}" +REGISTRY="${MOSHPIT_REGISTRY:-https://app.moshcode.sh}" +API_KEY="${MOSHPIT_API_KEY:-}" +DAYS="${MOSHPIT_RENEW_DAYS:-10}" +SETUP="${MOSHPIT_SETUP_ORIGIN:-$(dirname "$0")/setup-origin.sh}" + +[ -n "$API_KEY" ] || { echo "moshpit-renew: $RENEW_ENV has no MOSHPIT_API_KEY"; exit 1; } +[ -f "$SETUP" ] || { echo "moshpit-renew: $SETUP is missing"; exit 1; } + +renewed=0 +failed=0 +for crt in "$CERTDIR"/*.crt; do + [ -f "$crt" ] || continue + name=$(basename "$crt" .crt) + case "$name" in moshpit-root-ca|moshpit-*) continue ;; esac + [ -f "$CERTDIR/$name.key" ] || continue + issuer=$(openssl x509 -in "$crt" -noout -issuer 2>/dev/null || true) + case "$issuer" in + *"CN=$name"*|*"CN = $name"*) continue ;; # self-signed: nothing to renew from + esac + if openssl x509 -in "$crt" -noout -checkend $((DAYS * 86400)) >/dev/null 2>&1; then + echo "moshpit-renew: $name — fine ($(openssl x509 -in "$crt" -noout -enddate | sed 's/notAfter=//'))" + continue + fi + echo "moshpit-renew: $name — renewing" + if MOSHPIT_API_KEY="$API_KEY" MOSHPIT_REGISTRY="$REGISTRY" MOSHPIT_CERTDIR="$CERTDIR" \ + sh "$SETUP" "$name" --no-trust; then + renewed=$((renewed + 1)) + else + echo "moshpit-renew: $name — renewal failed" + failed=$((failed + 1)) + fi +done +echo "moshpit-renew: $renewed renewed, $failed failed" +[ "$failed" = "0" ] diff --git a/scripts/setup-origin.sh b/scripts/setup-origin.sh index 4c2c472..4cea467 100755 --- a/scripts/setup-origin.sh +++ b/scripts/setup-origin.sh @@ -15,20 +15,21 @@ # # sudo sh scripts/setup-origin.sh --all # every name this box serves # -# Self-signed is the design, not a shortcut. No CA will issue for a Moshpit TLD, -# so identity comes from the registry publishing SHA-256(SubjectPublicKeyInfo) -# for the name and clients checking the key they were handed against it. Which -# means the last step is not optional: until the pin is published, every client -# refuses the name rather than trusting it on sight. -# -# With MOSHPIT_API_KEY set, that last step stops being manual: +# The registry signs. pit.moshcode.sh runs a certificate authority for the +# names it holds (moshcode apps/pwa/docs/moshpit-ca.md): with MOSHPIT_API_KEY +# set this sends it a CSR for the name and serves the chain it returns, so any +# client that trusts the pit's root -- TronBrowser, a box that ran `moshcode dns +# enable` -- accepts the name with no pin lookup and no per-name import. The +# leaf lasts 30 days; a timer this script installs renews it. The pin is still +# published (the key is the same), so clients that check pins keep working. # # MOSHPIT_API_KEY=... sh setup-origin.sh chovy.hacker --target dev.profullstack.com # -# publishes the pin and sets the target over the registry API, so the whole of -# "serve this name" is one command. Get a key at app.moshcode.sh/settings. -# Without the key nothing changes -- it prints the pin and tells you where to -# paste it, exactly as before. +# Without a key, or with --self-signed, the certificate is self-signed as it +# always was: identity then comes from the registry publishing +# SHA-256(SubjectPublicKeyInfo) and clients checking the key against it, and +# the script prints the pin and where to paste it. Get a key at +# app.moshcode.sh/settings. set -eu NAME="${1:-}" @@ -40,6 +41,8 @@ DAYS="${MOSHPIT_DAYS:-825}" TEMPLATE="${MOSHPIT_TEMPLATE:-$(dirname "$0")/../nginx/moshpit-origin.conf}" API_KEY="${MOSHPIT_API_KEY:-}" REGISTRY="${MOSHPIT_REGISTRY:-https://app.moshcode.sh}" +SELF_SIGNED="${MOSHPIT_SELF_SIGNED:-0}" +RENEW_ENV="${MOSHPIT_RENEW_ENV:-/etc/moshpit/renew.env}" TARGET="" DRY_RUN=0 TRUST_LOCAL=1 @@ -58,6 +61,7 @@ usage: setup-origin.sh [options] --all re-issue every name this box already has a key for --no-trust do not trust the certificate on this machine + --self-signed keep the self-signed certificate; do not ask the registry to sign --dry-run write nothing, print what would happen --days certificate lifetime (default: $DAYS) --webroot site files (default: /var/www/) @@ -70,7 +74,7 @@ IPv6 address or a hostname. A hostname is how a name reaches IPv4 clients, since the address behind it is resolved normally. environment: MOSHPIT_CERTDIR, MOSHPIT_SITEDIR, MOSHPIT_ENABLEDIR, MOSHPIT_WEBROOT, - MOSHPIT_API_KEY, MOSHPIT_REGISTRY + MOSHPIT_API_KEY, MOSHPIT_REGISTRY, MOSHPIT_SELF_SIGNED, MOSHPIT_RENEW_ENV EOF } @@ -106,6 +110,7 @@ while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1 ;; --no-trust) TRUST_LOCAL=0 ;; + --self-signed) SELF_SIGNED=1 ;; --all) die "--all goes first: sh $0 --all [options]" ;; --days) DAYS="${2:?--days needs a number}"; shift ;; --webroot) WEBROOT="${2:?--webroot needs a path}"; shift ;; @@ -215,6 +220,66 @@ else fi fi +# ------------------------------------------------ a certificate from the pit + +# The self-signed certificate above is the fallback; this replaces it with one +# the registry signed, when there is a key to ask with and the registry has a +# CA. Same key, so the pin does not move. The chain (leaf, issuer, root) is +# written over $CRT: nginx's ssl_certificate takes a chain file, and serving +# the intermediate is what lets a client that holds only the root verify. +SIGNED=0 +if [ "$SELF_SIGNED" = "0" ] && [ -n "$API_KEY" ]; then + step "asking the registry to sign $NAME" + _tld="${NAME#*.}" + _label="${NAME%%.*}" + if [ "$DRY_RUN" = "1" ]; then + say " ${DIM}(dry run) would POST a CSR to $REGISTRY/api/moshpit/tlds/$_tld/certs and serve the chain it returns${OFF}" + elif ! have curl; then + warn "curl is required to ask the registry for a certificate — keeping the self-signed one" + else + _ca=$(curl -sS --max-time 10 "$REGISTRY/api/moshpit/ca" 2>/dev/null || true) + case "$_ca" in + *'"enabled":true'*) + # One line per PEM line, escaped for JSON by hand: the CSR is base64 + # and dashes, nothing else, so a newline is the only character at issue. + _csr=$(openssl req -new -key "$KEY" -subj "/CN=$NAME" 2>/dev/null | awk '{printf "%s\\n", $0}') + _resp=$(curl -sS --max-time 30 -X POST "$REGISTRY/api/moshpit/tlds/$_tld/certs" \ + -H "authorization: Bearer $API_KEY" -H "content-type: application/json" \ + -d "{\"label\":\"$_label\",\"csr\":\"$_csr\"}" -w '\n%{http_code}' 2>&1 || true) + _code=$(printf '%s' "$_resp" | tail -n1) + _body=$(printf '%s' "$_resp" | sed '$d') + case "$_code" in + 201) + printf '%s' "$_body" | sed -n 's/.*"chain":"\([^"]*\)".*/\1/p' | sed 's/\\n/\ +/g' > "$CRT.new" + if [ "$(grep -c 'BEGIN CERTIFICATE' "$CRT.new" 2>/dev/null)" -ge 2 ] \ + && openssl x509 -in "$CRT.new" -noout -checkhost "$NAME" 2>/dev/null | grep -q 'match'; then + mv "$CRT.new" "$CRT" + chmod 644 "$CRT" + SIGNED=1 + _until=$(openssl x509 -in "$CRT" -noout -enddate 2>/dev/null | sed 's/notAfter=//') + say " ${DIM}signed by the pit — serving the chain from $CRT, until $_until${OFF}" + printf '%s' "$_body" | sed -n 's/.*"root":"\([^"]*\)".*/\1/p' | sed 's/\\n/\ +/g' > "$CERTDIR/moshpit-root-ca.crt" + # What the renewal timer needs, root-only, never in the site dir. + mkdir -p "$(dirname "$RENEW_ENV")" + ( umask 077; printf 'MOSHPIT_API_KEY=%s\nMOSHPIT_REGISTRY=%s\nMOSHPIT_CERTDIR=%s\n' "$API_KEY" "$REGISTRY" "$CERTDIR" > "$RENEW_ENV" ) + else + rm -f "$CRT.new" + warn "the registry's answer did not parse as a chain for $NAME — keeping the self-signed certificate" + fi ;; + 503) + say " ${DIM}the registry has no CA configured — keeping the self-signed certificate${OFF}" ;; + *) + warn "the registry refused to sign $NAME ($_code): $_body" + warn "keeping the self-signed certificate; the pin below still covers it" ;; + esac ;; + *) + say " ${DIM}the registry publishes no CA yet — keeping the self-signed certificate${OFF}" ;; + esac + fi +fi + # ------------------------------------------------------------------ nginx step "writing the nginx server block" @@ -310,7 +375,30 @@ if [ "$TRUST_LOCAL" = "1" ] && [ "$DRY_RUN" = "0" ]; then # bounded shape must not be installed merely because this run meant to write # one. An older CA:TRUE certificate arriving here is precisely the case to # refuse: trusted as an anchor, its key could vouch for any name at all. - if openssl x509 -in "$CRT" -noout -ext basicConstraints 2>/dev/null | grep -q 'CA:FALSE'; then + if [ "$SIGNED" = "1" ]; then + # Signed by the pit: the thing to trust here is its root, once, the same + # file and name `moshcode dns enable` installs. The leaf is ordinary. + case "$(uname -s)" in + Darwin) + if security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$CERTDIR/moshpit-root-ca.crt" 2>/dev/null; then + say " ${DIM}Moshpit Root CA trusted in the system keychain${OFF}" + else + warn "could not add the Moshpit Root CA to the system keychain" + fi ;; + *) + if have update-ca-certificates; then + if mkdir -p /usr/local/share/ca-certificates \ + && cp "$CERTDIR/moshpit-root-ca.crt" /usr/local/share/ca-certificates/moshpit-root-ca.crt \ + && update-ca-certificates >/dev/null 2>&1; then + say " ${DIM}Moshpit Root CA trusted in the system store — curl https://$NAME verifies here now${OFF}" + else + warn "could not install the Moshpit Root CA into the system trust store" + fi + else + warn "no update-ca-certificates here — skipping local trust" + fi ;; + esac + elif openssl x509 -in "$CRT" -noout -ext basicConstraints 2>/dev/null | grep -q 'CA:FALSE'; then case "$(uname -s)" in Darwin) if security add-trusted-cert -d -r trustRoot \ @@ -339,6 +427,25 @@ if [ "$TRUST_LOCAL" = "1" ] && [ "$DRY_RUN" = "0" ]; then fi fi +# ------------------------------------------------------------- renewal + +# A 30-day leaf without a renewal is an outage with a date on it. The timer +# runs moshpit-renew.sh daily, which re-runs this script for any registry-signed +# name within ten days of expiry. Installed here, by the run that made the +# first signed certificate, rather than left as a step for someone to remember. +if [ "$SIGNED" = "1" ] && [ "$DRY_RUN" = "0" ] && have systemctl; then + _units="$(dirname "$0")/../systemd" + if [ -f "$_units/moshpit-renew.timer" ] && [ ! -f /etc/systemd/system/moshpit-renew.timer ]; then + step "installing the renewal timer" + if cp "$_units/moshpit-renew.service" "$_units/moshpit-renew.timer" /etc/systemd/system/ 2>/dev/null \ + && systemctl daemon-reload 2>/dev/null && systemctl enable --now moshpit-renew.timer >/dev/null 2>&1; then + say " ${DIM}moshpit-renew.timer — daily; renews within ten days of expiry${OFF}" + else + warn "could not enable moshpit-renew.timer — the certificate for $NAME expires in 30 days unless this is re-run" + fi + fi +fi + # ------------------------------------------------------------------ the pin step "the pin to publish" diff --git a/systemd/moshpit-renew.service b/systemd/moshpit-renew.service new file mode 100644 index 0000000..3987055 --- /dev/null +++ b/systemd/moshpit-renew.service @@ -0,0 +1,16 @@ +[Unit] +# A 30-day certificate without a renewal is an outage with a date on it. The +# registry signs short leaves on purpose (no revocation to run); this is the +# other half of that bargain, and setup-origin.sh installs it the first time +# it gets a registry-signed certificate, so nobody has to remember to. +Description=Renew registry-signed Moshpit certificates on this box +Documentation=https://github.com/profullstack/moshpit-proxy +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/moshpit/scripts/moshpit-renew.sh +# A failed renewal leaves the old certificate in place; the next daily run +# tries again, and there are ten days of them before it matters. +Restart=no diff --git a/systemd/moshpit-renew.timer b/systemd/moshpit-renew.timer new file mode 100644 index 0000000..f00a91f --- /dev/null +++ b/systemd/moshpit-renew.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Renew registry-signed Moshpit certificates daily +Documentation=https://github.com/profullstack/moshpit-proxy + +[Timer] +# Daily is plenty: renewal starts ten days before expiry, so there are ten +# chances before a missed one costs anything. +OnBootSec=5min +OnUnitActiveSec=1d +# Spread across boxes so the registry is not asked by every origin at once. +RandomizedDelaySec=1h +# A box that was off must still catch up rather than wait a full day. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/tests/setup-origin-registry.test.ts b/tests/setup-origin-registry.test.ts new file mode 100644 index 0000000..bfd170a --- /dev/null +++ b/tests/setup-origin-registry.test.ts @@ -0,0 +1,94 @@ +// The registry-signed path of setup-origin.sh, and the renewal script, without +// root, nginx or the network: a dry run says what it would ask the registry, +// --self-signed keeps the old behaviour, and moshpit-renew.sh only touches +// certificates the registry signed and only when they are about to run out. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { tempDir } from "./helpers.ts"; + +const run = promisify(execFile); +const setup = fileURLToPath(new URL("../scripts/setup-origin.sh", import.meta.url)); +const renew = fileURLToPath(new URL("../scripts/moshpit-renew.sh", import.meta.url)); + +async function sh(script: string, args: string[], env: NodeJS.ProcessEnv = {}) { + try { + const { stdout, stderr } = await run("sh", [script, ...args], { env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr }; + } catch (err: any) { + return { code: err.code ?? 1, stdout: String(err.stdout || ""), stderr: String(err.stderr || "") }; + } +} + +/** A certificate issued by `issuerCn` for `name` (self-signed when they match). */ +async function cert(dir: string, name: string, { days = 30, issuerCn = name } = {}) { + const key = join(dir, `${name}.key`); + const crt = join(dir, `${name}.crt`); + if (issuerCn === name) { + await run("openssl", ["req", "-x509", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", String(days), "-subj", `/CN=${name}`, "-keyout", key, "-out", crt]); + return; + } + const caKey = join(dir, "issuer.key"); + const caCrt = join(dir, "issuer.crt"); + await run("openssl", ["req", "-x509", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", "3650", "-subj", `/CN=${issuerCn}`, "-keyout", caKey, "-out", caCrt]); + const csr = join(dir, `${name}.csr`); + await run("openssl", ["req", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", `/CN=${name}`, "-keyout", key, "-out", csr]); + await run("openssl", ["x509", "-req", "-in", csr, "-CA", caCrt, "-CAkey", caKey, "-CAcreateserial", "-days", String(days), "-out", crt]); +} + +describe("setup-origin.sh — the registry-signed path", () => { + test("a dry run with an API key says it would ask the registry to sign, and where", async () => { + const r = await sh(setup, ["blue.eggs", "--dry-run", "--api-key", "k", "--registry", "https://registry.test"]); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stderr + r.stdout, /asking the registry to sign blue\.eggs/); + assert.match(r.stderr + r.stdout, /https:\/\/registry\.test\/api\/moshpit\/tlds\/eggs\/certs/); + }); + + test("--self-signed skips the registry even with a key", async () => { + const r = await sh(setup, ["blue.eggs", "--dry-run", "--api-key", "k", "--self-signed"]); + assert.equal(r.code, 0, r.stderr); + assert.doesNotMatch(r.stderr + r.stdout, /asking the registry to sign/); + }); + + test("without a key nothing is asked of the registry, as before", async () => { + const r = await sh(setup, ["blue.eggs", "--dry-run"]); + assert.equal(r.code, 0, r.stderr); + assert.doesNotMatch(r.stderr + r.stdout, /asking the registry to sign/); + }); +}); + +describe("moshpit-renew.sh", () => { + test("says so and exits 0 when this box was never signed by the registry", async () => { + const dir = await tempDir(); + const r = await sh(renew, [], { MOSHPIT_RENEW_ENV: join(dir, "absent.env") }); + assert.equal(r.code, 0); + assert.match(r.stdout, /nothing here was signed by the registry/); + }); + + test("leaves self-signed and still-valid certificates alone, renews the one about to expire", async () => { + const dir = await tempDir(); + const certdir = join(dir, "certs"); + await mkdir(certdir); + await cert(certdir, "self.eggs"); // self-signed: never touched + await cert(certdir, "fresh.eggs", { days: 25, issuerCn: "Moshpit Issuing CA" }); // signed, fine + await cert(certdir, "soon.eggs", { days: 3, issuerCn: "Moshpit Issuing CA" }); // signed, about to expire + await writeFile(join(dir, "renew.env"), `MOSHPIT_API_KEY=k\nMOSHPIT_REGISTRY=https://registry.test\nMOSHPIT_CERTDIR=${certdir}\n`); + // A stand-in for setup-origin.sh that records what it was asked to renew. + const fake = join(dir, "fake-setup.sh"); + await writeFile(fake, `#!/bin/sh\necho "$1" >> "${join(dir, "renewed.txt")}"\n`); + const r = await sh(renew, [], { MOSHPIT_RENEW_ENV: join(dir, "renew.env"), MOSHPIT_SETUP_ORIGIN: fake }); + assert.equal(r.code, 0, r.stdout + r.stderr); + assert.match(r.stdout, /fresh\.eggs — fine/); + assert.match(r.stdout, /soon\.eggs — renewing/); + assert.doesNotMatch(r.stdout, /self\.eggs/); + assert.equal((await readFile(join(dir, "renewed.txt"), "utf8")).trim(), "soon.eggs"); + assert.match(r.stdout, /1 renewed, 0 failed/); + }); +});