From 1eb18d067041e900f6647d81efa76a7573a4ae12 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 08:13:57 +0000 Subject: [PATCH] Serve a name the moment it is pointed, with nobody touching the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-origin.sh made serving one name one command. It was still one command per name, run by hand, over ssh, and forgetting it left the name resolving to a box that answers with the wrong certificate — or refuses it outright, because a name with no published pin is a name clients will not accept. moshpit-reconcile.sh asks the registry which names point at this host and configures the ones that are not configured yet. Pointing a name in the dashboard becomes the only step there is; the site is up about a minute later. Endings are listed in config rather than discovered, because GET /api/moshpit/tlds returns 200 of the ~5,700 claimed — the default page size — so discovery would serve a subset while looking complete. Idempotent and quiet: a name is only reconfigured when its nginx block is missing or the published pin is not the key actually being served. Without that check every pass would reload nginx, forever. It also treats a mismatched pin as work to do, which is the case that silently breaks a name after a certificate is regenerated. A briefly unreachable registry skips that ending for the pass rather than looking like "nothing is pointed here", which would otherwise be indistinguishable from names having been removed. Verified end to end against the live registry: claimed auto.hacker, pointed it at this host, and the next pass issued the certificate, wrote the nginx block, published the pin and served the site — 200 with the pin enforced. The pass after that changed nothing. Co-Authored-By: Claude Opus 5 --- scripts/moshpit-reconcile.sh | 169 ++++++++++++++++++++++++++++++ systemd/moshpit-reconcile.service | 42 ++++++++ systemd/moshpit-reconcile.timer | 26 +++++ 3 files changed, 237 insertions(+) create mode 100755 scripts/moshpit-reconcile.sh create mode 100644 systemd/moshpit-reconcile.service create mode 100644 systemd/moshpit-reconcile.timer diff --git a/scripts/moshpit-reconcile.sh b/scripts/moshpit-reconcile.sh new file mode 100755 index 0000000..8aa396a --- /dev/null +++ b/scripts/moshpit-reconcile.sh @@ -0,0 +1,169 @@ +#!/bin/sh +# Serve every Moshpit name pointed at this box, without anybody touching it. +# +# `setup-origin.sh` turned serving one name into one command. This turns it +# into none: it asks the registry which names point here, and configures the +# ones that are not configured yet. Point a name at this host in the dashboard +# and it is being served a minute later — no ssh, no command, no remembering +# that a pin has to be published or the name stays refused. +# +# sh scripts/moshpit-reconcile.sh # act +# sh scripts/moshpit-reconcile.sh --dry-run # say what it would do +# sh scripts/moshpit-reconcile.sh --once # exit non-zero on any failure +# +# Config, read from /etc/moshpit/reconcile.conf (override with MOSHPIT_CONF): +# +# MOSHPIT_API_KEY=mck_... # publishes pins; from /settings +# MOSHPIT_ENDINGS="hacker rank 2600" # the endings this box answers for +# MOSHPIT_HOST=dev.profullstack.com # what a name must point at to count +# MOSHPIT_REGISTRY=https://app.moshcode.sh +# +# ENDINGS is listed rather than discovered on purpose. `GET /api/moshpit/tlds` +# returns 200 of the ~5,700 claimed endings — the default page size — so +# discovery would silently serve a subset and look like it had covered +# everything. A box that serves three endings should say so in one line. +# +# Idempotent, and quiet when there is nothing to do: a name is reconfigured +# only when its nginx block is missing or the pin the registry publishes is not +# the key this box actually serves. Without that check every run would reload +# nginx, and a timer would reload it forever. +set -eu + +CONF="${MOSHPIT_CONF:-/etc/moshpit/reconcile.conf}" +[ -f "$CONF" ] && . "$CONF" + +REGISTRY="${MOSHPIT_REGISTRY:-https://app.moshcode.sh}" +API_KEY="${MOSHPIT_API_KEY:-}" +ENDINGS="${MOSHPIT_ENDINGS:-}" +HOST="${MOSHPIT_HOST:-}" +ENABLEDIR="${MOSHPIT_ENABLEDIR:-/etc/nginx/sites-enabled}" +SETUP="${MOSHPIT_SETUP:-$(dirname "$0")/setup-origin.sh}" +DRY_RUN=0 +FAILURES=0 + +BOLD=''; DIM=''; RED=''; OFF='' +if [ -t 2 ]; then BOLD=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m'); OFF=$(printf '\033[0m'); fi +say() { printf '%s\n' "$*" >&2; } +step() { printf '%s==>%s %s\n' "$BOLD" "$OFF" "$*" >&2; } +warn() { printf '%swarning:%s %s\n' "$RED" "$OFF" "$*" >&2; FAILURES=$((FAILURES + 1)); } +die() { printf '%serror:%s %s\n' "$RED" "$OFF" "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --once) : ;; # accepted for the systemd unit; the script is always one pass + -h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown option: $1" ;; + esac + shift +done + +[ -n "$API_KEY" ] || die "no MOSHPIT_API_KEY — set it in $CONF (get one at $REGISTRY/settings)" +[ -n "$ENDINGS" ] || die "no MOSHPIT_ENDINGS — list the endings this box serves in $CONF" +[ -n "$HOST" ] || die "no MOSHPIT_HOST — set what a name must point at to count as ours" +[ -f "$SETUP" ] || die "cannot find setup-origin.sh at $SETUP (set MOSHPIT_SETUP)" + +# The addresses HOST has right now. A name may point at the hostname or at a +# literal, and both mean this box — comparing only the string would skip names +# pointed at the address and leave them silently unserved. +OURS="$HOST +$(getent ahosts "$HOST" 2>/dev/null | awk '{print $1}' | sort -u)" + +points_here() { + case "$1" in "") return 1 ;; esac + for ours in $OURS; do + [ "$1" = "$ours" ] && return 0 + done + return 1 +} + +# The pin this box serves for a name, computed the same way the registry's +# publisher computes it. Empty when nothing is listening for that name. +served_pin() { + echo | openssl s_client -connect "127.0.0.1:443" -servername "$1" 2>/dev/null \ + | openssl x509 -pubkey -noout 2>/dev/null \ + | openssl pkey -pubin -outform der 2>/dev/null \ + | openssl dgst -sha256 -binary 2>/dev/null \ + | openssl enc -base64 2>/dev/null +} + +published_pin() { + curl -fsS --max-time 15 "$REGISTRY/api/moshpit/tlds/$2/pins?label=$1" 2>/dev/null \ + | grep -o '"pin":"[^"]*"' | head -1 | cut -d'"' -f4 +} + +# One field out of a flat JSON object. Enough for these payloads, and it keeps +# this a POSIX shell script with no runtime to install on an origin box. +json_field() { grep -o "\"$1\":\"[^\"]*\"" | head -1 | cut -d'"' -f4; } + +reconcile_name() { + name="$1"; label="$2"; tld="$3" + + need="" + [ -f "$ENABLEDIR/$name" ] || need="no nginx block" + if [ -z "$need" ]; then + serving="$(served_pin "$name")" + publish="$(published_pin "$label" "$tld")" + if [ -z "$serving" ]; then + need="nothing is served for it" + elif [ -z "$publish" ]; then + need="no pin published" + elif [ "$serving" != "$publish" ]; then + # The published pin is what clients enforce, so a mismatch refuses the + # name outright. Worth naming both, because the usual cause is a + # certificate regenerated without republishing. + need="published pin does not match what is served ($publish vs $serving)" + fi + fi + + if [ -z "$need" ]; then + say " ${DIM}$name — already served${OFF}" + return 0 + fi + + step "$name — $need" + if [ "$DRY_RUN" = "1" ]; then + say " ${DIM}would run: setup-origin.sh $name --target $HOST${OFF}" + return 0 + fi + + if MOSHPIT_API_KEY="$API_KEY" sh "$SETUP" "$name" --target "$HOST" --registry "$REGISTRY" >&2; then + say " ${DIM}$name — now served${OFF}" + else + warn "$name — setup-origin.sh failed; leaving it alone and carrying on" + fi +} + +RECORDS="$(mktemp)" +trap 'rm -f "$RECORDS"' EXIT INT TERM + +step "reconciling against $REGISTRY as $HOST" + +for tld in $ENDINGS; do + body="$(curl -fsS --max-time 20 "$REGISTRY/api/moshpit/tlds/$tld/names" 2>/dev/null)" || { + # A registry that is briefly unreachable must not take the box's existing + # names down, and must not look like "no names are pointed here". + warn ".$tld — registry unreachable, skipping this pass" + continue + } + + # One record per line, so the loop never has to parse nested JSON. + # + # Via a file rather than a pipe into `while`: a piped loop runs in a subshell, + # where the failure count increments a copy and the exit status below would + # always be 0 — the script would report success for a pass that fixed nothing. + printf '%s' "$body" | tr '{' '\n' | grep '"label"' > "$RECORDS" || true + while IFS= read -r record; do + label="$(printf '%s' "$record" | json_field label)" + target="$(printf '%s' "$record" | json_field target)" + [ -n "$label" ] || continue + points_here "$target" || continue + reconcile_name "$label.$tld" "$label" "$tld" + done < "$RECORDS" +done + +if [ "$FAILURES" -gt 0 ]; then + say "" + warn "$FAILURES name(s) could not be reconciled this pass" + exit 1 +fi diff --git a/systemd/moshpit-reconcile.service b/systemd/moshpit-reconcile.service new file mode 100644 index 0000000..b664016 --- /dev/null +++ b/systemd/moshpit-reconcile.service @@ -0,0 +1,42 @@ +# Serve every Moshpit name pointed at this box. +# +# Oneshot, driven by moshpit-reconcile.timer. Each pass asks the registry which +# names point here and configures the ones that are not configured yet, so +# pointing a name in the dashboard is the only step there is. +# +# Install: +# cp systemd/moshpit-reconcile.* /etc/systemd/system/ +# systemctl enable --now moshpit-reconcile.timer + +[Unit] +Description=Serve Moshpit names pointed at this host +Documentation=https://github.com/profullstack/moshpit-proxy +# Reconfiguring nginx is the point, so do not race its startup. +After=network-online.target nginx.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/moshpit/scripts/moshpit-reconcile.sh --once + +# The API key lives here, not in this unit: unit files are world-readable and +# `systemctl cat` prints them. 0600 the config instead. +Environment=MOSHPIT_CONF=/etc/moshpit/reconcile.conf + +# A registry that is briefly unreachable is not a reason to give up until the +# next tick, but retrying forever is not either. +Restart=on-failure +RestartSec=30s +StartLimitBurst=3 + +# It writes certificates, nginx config, and reloads nginx. That needs root, so +# narrow what root can reach rather than pretending otherwise. +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/systemd/moshpit-reconcile.timer b/systemd/moshpit-reconcile.timer new file mode 100644 index 0000000..f2272ae --- /dev/null +++ b/systemd/moshpit-reconcile.timer @@ -0,0 +1,26 @@ +# How often this box checks for names newly pointed at it. +# +# A minute is the gap between pointing a name in the dashboard and the site +# being up. Passes with nothing to do cost one HTTPS request per ending and +# change nothing on disk, so the cost of checking often is close to zero — and +# the alternative is somebody ssh-ing in, which is the thing being removed. + +[Unit] +Description=Check for Moshpit names newly pointed at this host +Documentation=https://github.com/profullstack/moshpit-proxy + +[Timer] +# Soon after boot, because a box that just came up may have missed changes. +OnBootSec=30s +OnUnitActiveSec=60s + +# Without this every box installed from the same instructions hits the registry +# on the same second. +RandomizedDelaySec=15s + +# A missed window (box asleep, timer disabled) should run once on return rather +# than be skipped silently. +Persistent=true + +[Install] +WantedBy=timers.target