Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions scripts/moshpit-renew.sh
Original file line number Diff line number Diff line change
@@ -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" ]
133 changes: 120 additions & 13 deletions scripts/setup-origin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}"
Expand All @@ -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
Expand All @@ -58,6 +61,7 @@ usage: setup-origin.sh <name|--all> [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 <n> certificate lifetime (default: $DAYS)
--webroot <dir> site files (default: /var/www/<name>)
Expand All @@ -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
}

Expand Down Expand Up @@ -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 ;;
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions systemd/moshpit-renew.service
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions systemd/moshpit-renew.timer
Original file line number Diff line number Diff line change
@@ -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
94 changes: 94 additions & 0 deletions tests/setup-origin-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading