From 3b7f26741319c53eda3a4c44360e3d5e7664a3c3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 09:59:08 +0000 Subject: [PATCH 1/2] fix(origin): serve port 80 instead of redirecting to a certificate nobody can verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template redirected :80 to :443, with a comment saying the only problem was a missing 443 block. That reasoning was incomplete: it assumes the client can verify what it lands on, and the two biggest classes cannot. No CA will issue for an ending outside the DNS root, so the 443 block presents a self-signed certificate. TronBrowser, moshpit-proxy and the SDKs check it against the registry's published pin and are fine. curl and a stock browser have no pin to check, so they reject it — and the redirect leaves them nowhere: curl chovy.hacker -L curl: (60) SSL certificate problem: self-signed certificate Through the gateway it is worse: pit.moshcode.sh forwards the status and not the Location, so a visitor gets a 301 pointing nowhere at all. Now :80 serves the site. Both ports serve it, neither redirects to the other, and each client takes the one it can verify — pin-checking clients still get TLS, everyone else gets the page instead of an error that reads as "Moshpit is broken". HTTPS-only remains right for a name in the public DNS. It is not available for a name outside it, and pretending otherwise costs the visitor the site. Co-Authored-By: Claude Opus 5 --- nginx/moshpit-origin.conf | 43 ++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/nginx/moshpit-origin.conf b/nginx/moshpit-origin.conf index 3c9c456..7d272c5 100644 --- a/nginx/moshpit-origin.conf +++ b/nginx/moshpit-origin.conf @@ -18,11 +18,44 @@ server { listen [::]:80; server_name NAME; - # Keep this. Upgrading to HTTPS is right, and it was only ever a problem - # when there was no 443 block for the name to land on — which produced a - # certificate for whatever the default vhost happened to hold, and an error - # that looks like Moshpit is broken when it is nginx that is misconfigured. - return 301 https://$host$request_uri; + # This used to redirect to HTTPS, with a comment saying the only problem + # was a missing 443 block. That reasoning was incomplete: it assumes the + # client can verify what it lands on, and the two biggest classes cannot. + # + # No CA will issue for an ending outside the DNS root, so the 443 block + # below presents a self-signed certificate. TronBrowser, moshpit-proxy and + # the SDKs check it against the registry's published pin and are happy. + # `curl` and a stock browser have no pin to check against, so they reject + # it — and a redirect gives them no way back: + # + # curl chovy.hacker -L + # curl: (60) SSL certificate problem: self-signed certificate + # + # Through the gateway it is worse. pit.moshcode.sh forwards the status and + # not the Location, so a visitor gets a 301 pointing nowhere at all. + # + # So port 80 serves the site instead. Both ports now serve it, neither + # redirects to the other, and each client takes the one it can verify — + # pin-checking clients still get TLS, and everyone else gets the page + # rather than an error that reads as "Moshpit is broken". + # + # HTTPS-only is still the right default for a name in the public DNS. It + # is not available for a name outside it, and pretending otherwise costs + # the visitor the site. + + access_log /var/log/nginx/NAME.access.log; + error_log /var/log/nginx/NAME.error.log warn; + + root /var/www/NAME; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + location ~ /\. { + deny all; + } } server { From b750b73c27474d3c9e175be5ceaaa140e531c0b3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 10:04:28 +0000 Subject: [PATCH 2/2] feat(deploy): make /opt/moshpit a checkout, so merging ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /opt/moshpit was a hand-copied nginx/ and scripts/. A merged fix reached a box only when somebody remembered to copy it, and forgetting does not raise an error — it produces a fix that appears not to work. That cost a full afternoon today: the origin template still redirected to HTTPS on a box whose repo had stopped doing so, and every reconcile pass faithfully re-applied the stale copy over the hand-edited vhosts. The same shape bit the DNS resolver deployment twice more. Adds an updater and a 15-minute timer. It converts a hand-copied prefix into a checkout on first run rather than asking anyone to do that by hand, because an upgrade path nobody has to read about is the only kind that gets taken. The superseded tree is kept beside the new one: if somebody edited a script in place and never upstreamed it, that is the only copy holding it. Safety, in order of how badly each fails: - uncommitted changes in the prefix abort the update. Overwriting them silently is how a fix applied on the box at 3am vanishes without trace - fast-forward only; a diverged prefix is a person's problem, not a script's - a failed clone or fetch leaves the prefix exactly as it was - `nginx -t` before reload, and a failing test warns rather than aborts — the update has already landed and failing here would strand the checkout 15 minutes, not one: this reaches the network, and a merge is not urgent where an unserved name is. Randomised so a merge does not have every origin fetch at once, and Persistent so a box that was off catches up. --- scripts/moshpit-update.sh | 115 +++++++++++++++++++++++++++++++++ systemd/moshpit-update.service | 16 +++++ systemd/moshpit-update.timer | 16 +++++ 3 files changed, 147 insertions(+) create mode 100755 scripts/moshpit-update.sh create mode 100644 systemd/moshpit-update.service create mode 100644 systemd/moshpit-update.timer diff --git a/scripts/moshpit-update.sh b/scripts/moshpit-update.sh new file mode 100755 index 0000000..8139a99 --- /dev/null +++ b/scripts/moshpit-update.sh @@ -0,0 +1,115 @@ +#!/bin/sh +# Keep /opt/moshpit at the tip of this repo, so merging ships. +# +# It did not. /opt/moshpit was a hand-copied nginx/ and scripts/, which meant a +# merged fix reached the box only when somebody remembered to copy it — and the +# symptom of forgetting is not an error, it is the fix appearing not to work. +# A whole afternoon went into that: the origin template still redirected to +# HTTPS on a box whose repo had stopped doing so, and every reconcile pass +# faithfully re-applied the old one. +# +# sh scripts/moshpit-update.sh # pull and apply +# sh scripts/moshpit-update.sh --dry-run # say what would change +# +# Config, read from /etc/moshpit/update.conf (override with MOSHPIT_UPDATE_CONF): +# +# MOSHPIT_REPO=https://github.com/profullstack/moshpit-proxy.git +# MOSHPIT_BRANCH=main +# MOSHPIT_PREFIX=/opt/moshpit +# +# Converts a hand-copied prefix into a checkout on first run rather than +# demanding someone do it by hand: an upgrade path nobody has to read about is +# the only kind that gets taken. The old tree is kept next to the new one, so a +# local edit somebody forgot to upstream is recoverable rather than gone. +set -eu + +CONF="${MOSHPIT_UPDATE_CONF:-/etc/moshpit/update.conf}" +[ -f "$CONF" ] && . "$CONF" + +REPO="${MOSHPIT_REPO:-https://github.com/profullstack/moshpit-proxy.git}" +BRANCH="${MOSHPIT_BRANCH:-main}" +PREFIX="${MOSHPIT_PREFIX:-/opt/moshpit}" +DRY_RUN=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; } +die() { printf '%serror:%s %s\n' "$RED" "$OFF" "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + -h|--help) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown option: $1" ;; + esac + shift +done + +command -v git >/dev/null 2>&1 || die "git is required" + +# ---- first run: adopt a hand-copied prefix --------------------------------- + +if [ ! -d "$PREFIX/.git" ]; then + step "$PREFIX is not a checkout — converting" + if [ "$DRY_RUN" = "1" ]; then + say " ${DIM}would clone $REPO into $PREFIX, keeping the old tree beside it${OFF}" + exit 0 + fi + + TMP="$PREFIX.new.$$" + rm -rf "$TMP" + git clone --quiet --branch "$BRANCH" --depth 50 "$REPO" "$TMP" \ + || die "clone failed — $PREFIX left exactly as it was" + + if [ -e "$PREFIX" ]; then + # Kept, not deleted. If somebody edited a script in place and never + # upstreamed it, this is the only copy that has it. + OLD="$PREFIX.superseded.$(date -u +%Y%m%dT%H%M%SZ)" + mv "$PREFIX" "$OLD" + say " ${DIM}previous tree kept at $OLD${OFF}" + fi + mv "$TMP" "$PREFIX" + say " ${DIM}$PREFIX is now a checkout of $BRANCH${OFF}" + CHANGED=1 +else + # ---- normal run: fast-forward ------------------------------------------ + + before="$(git -C "$PREFIX" rev-parse HEAD)" + git -C "$PREFIX" fetch --quiet origin "$BRANCH" || die "fetch failed — nothing changed" + + # Refuse to clobber local edits. Overwriting them silently is how a fix + # somebody applied on the box at 3am disappears without trace. + if ! git -C "$PREFIX" diff --quiet || ! git -C "$PREFIX" diff --cached --quiet; then + die "$PREFIX has uncommitted changes — refusing to overwrite them. Commit, stash, or revert first." + fi + + after="$(git -C "$PREFIX" rev-parse "origin/$BRANCH")" + if [ "$before" = "$after" ]; then + say "${DIM}already at $(git -C "$PREFIX" rev-parse --short HEAD) — nothing to do${OFF}" + exit 0 + fi + + step "updating $(echo "$before" | cut -c1-7) -> $(echo "$after" | cut -c1-7)" + git -C "$PREFIX" --no-pager log --oneline "$before..$after" | sed 's/^/ /' >&2 || true + [ "$DRY_RUN" = "1" ] && exit 0 + + git -C "$PREFIX" merge --ff-only --quiet "origin/$BRANCH" \ + || die "not a fast-forward — $PREFIX has diverged and needs a look" + CHANGED=1 +fi + +# ---- apply ---------------------------------------------------------------- + +# The origin template is read fresh by every reconcile pass, so a template +# change needs no action here. Only nginx has to be told. +if [ "${CHANGED:-0}" = "1" ] && command -v nginx >/dev/null 2>&1; then + if nginx -t >/dev/null 2>&1; then + nginx -s reload >/dev/null 2>&1 || true + say "${DIM}nginx reloaded${OFF}" + else + # Loud, and not fatal: the update landed, and nginx was already broken or + # is broken by something unrelated. Failing here would strand the checkout. + say "${RED}warning:${OFF} nginx -t fails — not reloading. Run \`nginx -t\` to see why." + fi +fi diff --git a/systemd/moshpit-update.service b/systemd/moshpit-update.service new file mode 100644 index 0000000..a3a8254 --- /dev/null +++ b/systemd/moshpit-update.service @@ -0,0 +1,16 @@ +[Unit] +# Merging should ship. Before this, /opt/moshpit was a hand-copied nginx/ and +# scripts/, so a merged fix reached the box only when somebody remembered to +# copy it — and forgetting does not produce an error, it produces a fix that +# appears not to work. +Description=Update /opt/moshpit to the tip of moshpit-proxy +Documentation=https://github.com/profullstack/moshpit-proxy +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/moshpit/scripts/moshpit-update.sh +# A failed update must leave the box exactly as it was, which the script +# guarantees; systemd only needs to not retry into a loop. +Restart=no diff --git a/systemd/moshpit-update.timer b/systemd/moshpit-update.timer new file mode 100644 index 0000000..1f40842 --- /dev/null +++ b/systemd/moshpit-update.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Update /opt/moshpit periodically +Documentation=https://github.com/profullstack/moshpit-proxy + +[Timer] +# Every 15 minutes rather than every minute like reconcile: this reaches the +# network and a merge is not urgent, where an unserved name is. +OnBootSec=2min +OnUnitActiveSec=15min +# Spread across boxes so a merge does not have every origin fetch at once. +RandomizedDelaySec=2min +# A box that was off must still catch up rather than wait a full interval. +Persistent=true + +[Install] +WantedBy=timers.target