Files
punktfunk/scripts/setup-nix-cache.sh
T
enricobuehler 7e4fe80793 feat(nix): install the cache signing key and correct how its ingress is provisioned
Two corrections and one thing actually done.

DNS here is not a dashboard click. unom/infra owns the unom.io zone in OpenTofu
(terraform/cloudflare/records.tf, applied by dns-cutover.yml), and that file's
`local.hostnames` set carries its own invariant: "a name here with no vhost 404s,
a vhost with no name here never cuts over." A record added by hand in Cloudflare
is out-of-band and risks the duplicate-record round-robin the file documents a few
lines further down — the same class of trap as hand-editing ~/caddy/Caddyfile on
the box. The setup steps said "in the unom.io Cloudflare zone" as though it were a
manual change; they now name both files, the workflow that applies them, and the
one-added-record check to expect from `plan`. unom/infra#20 makes the change.

The signing key is generated and `NIX_CACHE_SIGNING_KEY` is installed as a repo
Actions secret, so its public half is no longer a placeholder:

    punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=

pinned in both docs. The publish step still writes the same value to
/punktfunk-cache.pub, so the docs can always be checked against the cache itself —
and the wizard now compares the two and warns on a mismatch, because docs that
disagree with the cache mean users reject everything it serves.

The wizard drops to four stages. DNS and the vhost were separate stages when they
looked like separate manual steps; they are one PR against one repo, so they are
one stage. The key stage now detects the installed key, prints it, and refuses to
casually regenerate — a new key invalidates every signature already published and
breaks every user pinning the old one.

Verified: shellcheck + `bash -n` clean, 4 stages against TOTAL_STAGES=4, and the
already-installed path's key extraction tested against the real README.
2026-08-18 23:49:21 +02:00

422 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# A wizard — walks a human through a manual procedure step by step.
# Generated by the /wizard skill.
#
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
# it. Author the per-step stages below the marker.
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# Wizard library — delightful, consistent UX. Identical across every wizard.
# ──────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
else
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
fi
# Author sets this at the top of the stages section.
TOTAL_STAGES=0
_STAGE_INDEX=0
ENV_FILE="${ENV_FILE:-.env}"
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
WRITTEN_SECRET=() # secret NAMEs set this run
SKIPPED=() # things we couldn't do (e.g. gh missing)
# _clear — wipe the terminal so only the current step is on screen. No-op when
# output isn't a terminal, so piped logs stay readable.
_clear() {
[[ -t 1 ]] || return 0
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
}
# banner "Title" — opening frame: what this wizard does.
banner() {
_clear
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET"
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
printf ' later — it remembers values already saved.%s\n' "$RESET"
pause "Ready to start?"
}
# stage "Name" — clear the screen, then announce a stage and show progress.
# Clearing keeps only the current step on screen.
stage() {
_clear
_STAGE_INDEX=$((_STAGE_INDEX + 1))
printf '\n%s%s▸ Stage %s/%s · %s%s\n' \
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET"
}
# say "..." — a plain instruction line.
say() { printf ' %s\n' "$1"; }
# step "..." — a numbered-feeling action the human takes in the browser.
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
# open_url URL — open in the human's browser, cross-platform incl. WSL.
open_url() {
local url="$1"
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
else warn "couldn't open a browser — visit it manually: $url"; fi
} >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url"
}
# pause "msg" — wait for the human to confirm they've done the manual part.
pause() {
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
read -r _ || true
}
# confirm "question" — y/N gate; returns success on yes.
confirm() {
local reply=""
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
read -r reply || true
[[ "$reply" =~ ^[Yy] ]]
}
# _existing KEY — current value of KEY in ENV_FILE, if any.
_existing() {
[[ -f "$ENV_FILE" ]] || return 1
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
printf '%s' "${line#*=}"
}
# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as
# a default on re-runs (Enter keeps it). Visible input (non-secret).
ask() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -r input || true
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# ask_secret KEY "Prompt" — like ask, but input is hidden.
ask_secret() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -rs input || true
printf '\n'
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces
# any existing line). Idempotent.
write_env() {
local key="$1" value="$2" tmp
touch "$ENV_FILE"
tmp=$(mktemp)
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$value" >> "$tmp"
mv "$tmp" "$ENV_FILE"
WRITTEN_ENV+=("$key")
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
}
# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back
# to a warning (and records it) if gh is unavailable or unauthenticated.
set_secret() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
WRITTEN_SECRET+=("$name")
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
warn "skipped GitHub secret $name — gh not ready; set it later"
}
# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret).
set_var() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub variable $name")
warn "skipped GitHub variable $name — gh not ready; set it later"
}
# finish — clear, then a closing summary of everything configured.
finish() {
_clear
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
if (( ${#SKIPPED[@]} )); then
printf '\n'; warn "still to do by hand:"
for s in "${SKIPPED[@]}"; do note " - $s"; done
fi
printf '\n'
}
# ──────────────────────────────────────────────────────────────────────────
# STAGES — bring the punktfunk Nix binary cache at https://nix.unom.io live.
#
# Everything here is a step only a human can take: merging an infra PR, running an apply,
# dispatching a deploy. The wizard opens each page, says exactly what to do, and then
# VERIFIES the result before moving on — the failure signatures are easy to confuse:
#
# TLS handshake failure -> the vhost is not applied (Caddy has no cert for that name)
# 502 / 503 -> vhost fine, the container behind :3250 is not running
# 404 -> healthy, the cache is simply empty
# 200 -> serving content
#
# Safe to re-run: every stage detects work already done and skips it.
# Full context: packaging/nix/README.md § "Cache infrastructure (maintainers)".
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=4
CACHE_HOST="nix.unom.io"
CACHE_PORT=3250
KEY_NAME="punktfunk-cache-1"
GITEA="https://git.unom.io"
GITEA_REPO="$GITEA/unom/punktfunk"
INFRA_REPO="$GITEA/unom/infra"
# probe PATH — HTTP status for a path on the cache, or 000 if it cannot be reached at all.
probe() { curl -sS -o /dev/null -w '%{http_code}' -m 15 "https://$CACHE_HOST$1" 2>/dev/null || echo 000; }
# nix_key ARGS… — run `nix key …` from a local nix if there is one, otherwise from the
# official image. Keeps this usable on a machine with no nix (the maintainer box is macOS).
nix_key() {
if command -v nix >/dev/null 2>&1; then
nix --extra-experimental-features nix-command key "$@"
elif command -v docker >/dev/null 2>&1; then
docker run --rm -i nixos/nix nix --extra-experimental-features nix-command key "$@"
else
return 127
fi
}
banner "punktfunk Nix binary cache — bring-up"
# ── 1 ─────────────────────────────────────────────────────────────────────
stage "Ingress — DNS + the Caddy vhost (unom/infra)"
say "Both halves live in unom/infra and must move together: terraform/cloudflare/records.tf"
say "owns the DNS record, caddy/Caddyfile owns the vhost. That file's own rule:"
note " \"a name here with no vhost 404s, a vhost with no name here never cuts over.\""
printf '\n'
warn "Neither is a click."
note " A record added in the Cloudflare dashboard is out-of-band and risks the duplicate-record"
note " round-robin records.tf documents. ~/caddy/Caddyfile on unom-1 looks like the config but is"
note " a copy deploy-all.sh rsyncs from the repo — a vhost added there lasts until the next deploy."
printf '\n'
TARGET_IP="$(dig +short flatpak.unom.io | tail -n1)"
[ -n "$TARGET_IP" ] || TARGET_IP="167.233.145.172"
CURRENT="$(dig +short "$CACHE_HOST" | tail -n1)"
if [ -n "$CURRENT" ]; then
printf ' %s✓%s %s already resolves to %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
[ "$CURRENT" = "$TARGET_IP" ] || warn "expected $TARGET_IP (where flatpak.unom.io points) — check for a stale duplicate record"
else
open_url "$INFRA_REPO/pulls"
step "Merge the 'Serve nix.unom.io' PR (adds \"nix\" to local.hostnames + the vhost)."
step "Run dns-cutover.yml with target=hcloud, action=plan."
step "The plan must show exactly ONE added record: cloudflare_record.a[\"nix\"]."
warn "If it shows anything else, stop — that zone config is shared with every unom site."
step "Re-run it with action=apply."
step "Then run deploy-all so the box picks up the new Caddyfile."
pause "Applied? Press Enter to verify DNS"
i=0
while [ "$i" -lt 10 ]; do
CURRENT="$(dig +short "$CACHE_HOST" | tail -n1)"
[ -n "$CURRENT" ] && break
printf ' %swaiting for DNS (TTL is 300s)…%s\n' "$DIM" "$RESET"
sleep 10
i=$((i + 1))
done
if [ -n "$CURRENT" ]; then
printf ' %s✓%s %s -> %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
else
warn "$CACHE_HOST still does not resolve."
SKIPPED+=("DNS record for $CACHE_HOST (unom/infra records.tf + dns-cutover apply)")
confirm "Continue anyway?" || exit 1
fi
fi
# The certificate is the proof the vhost half landed. Diagnose by SNI: Caddy 308s EVERY Host
# on :80 to https, including names it has never heard of, so probing port 80 proves nothing.
printf ' %schecking for a certificate…%s\n' "$DIM" "$RESET"
TLS_OUT="$(openssl s_client -connect "${CACHE_HOST}:443" -servername "$CACHE_HOST" \
</dev/null 2>&1 | grep -E '^subject=|alert' | head -n3 || true)"
if printf '%s' "$TLS_OUT" | grep -q '^subject='; then
printf ' %s✓%s Caddy is serving a certificate for %s\n' "$GREEN" "$RESET" "$CACHE_HOST"
else
warn "No certificate for $CACHE_HOST yet:"
printf ' %s%s%s\n' "$DIM" "${TLS_OUT:-(no response)}" "$RESET"
note " Caddy issues one automatically once the name resolves AND the vhost is deployed."
note " If DNS is good, the Caddyfile half has not reached the box — re-run deploy-all."
SKIPPED+=("Caddy vhost for $CACHE_HOST")
confirm "Continue anyway?" || exit 1
fi
# ── 2 ─────────────────────────────────────────────────────────────────────
stage "Start the cache container on unom-1"
CODE="$(probe /nix-cache-info)"
if [ "$CODE" = 404 ] || [ "$CODE" = 200 ]; then
printf ' %s✓%s Container already answering (HTTP %s)\n' "$GREEN" "$RESET" "$CODE"
else
say "deploy-services.yml ships the compose file + Caddyfile + prune.sh and starts the"
say "container on port $CACHE_PORT. It serves an EMPTY cache until the first publish."
open_url "$GITEA_REPO/actions?workflow=deploy-services.yml"
step "Run workflow -> leave the input blank -> Run."
step "Wait for the nix-cache job to go green."
pause "Green? Press Enter to verify"
CODE="$(probe /nix-cache-info)"
case "$CODE" in
404) printf ' %s✓%s Up and empty — 404 on every path, exactly right for an empty cache\n' "$GREEN" "$RESET" ;;
200) printf ' %s✓%s Up and already holding content\n' "$GREEN" "$RESET" ;;
502|503)
warn "Caddy answered $CODE — the vhost is live but nothing is listening on :$CACHE_PORT."
note " Check the nix-cache job, or docker compose ps on unom-1."
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
confirm "Continue anyway?" || exit 1 ;;
*)
warn "Unexpected response ($CODE) from https://$CACHE_HOST/nix-cache-info"
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
confirm "Continue anyway?" || exit 1 ;;
esac
fi
# ── 3 ─────────────────────────────────────────────────────────────────────
stage "Signing key"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
README_MD="$REPO_ROOT/packaging/nix/README.md"
INSTALL_MD="$REPO_ROOT/docs-site/content/docs/install.md"
if grep -q "$KEY_NAME:<" "$README_MD" 2>/dev/null; then
say "The docs still carry a placeholder, so no key has been installed yet."
say "Generating an ed25519 key pair…"
SECRET_KEY="$(nix_key generate-secret --key-name "$KEY_NAME" 2>/dev/null || true)"
if [ -z "$SECRET_KEY" ]; then
warn "Could not run nix here (no local nix, and no docker to fall back to)."
note " nix key generate-secret --key-name $KEY_NAME"
ask_secret SECRET_KEY "Paste the secret key line:"
fi
if [ -z "$SECRET_KEY" ]; then
SKIPPED+=("NIX_CACHE_SIGNING_KEY + the public key in the docs")
else
PUBLIC_KEY="$(printf '%s' "$SECRET_KEY" | nix_key convert-secret-to-public 2>/dev/null || true)"
printf '\n %sSecret key — paste into Gitea now; nothing here keeps a copy:%s\n\n' "$BOLD" "$RESET"
printf ' %s\n\n' "$SECRET_KEY"
open_url "$GITEA_REPO/settings/actions/secrets"
step "Add Secret -> Name: NIX_CACHE_SIGNING_KEY"
step "Value: the whole line above, including the '$KEY_NAME:' prefix."
pause "Stored? Press Enter"
WRITTEN_SECRET+=("NIX_CACHE_SIGNING_KEY (Gitea)")
SECRET_KEY=""
if [ -n "$PUBLIC_KEY" ]; then
printf '\n %sPublic key%s — what users pin:\n\n %s\n\n' "$BOLD" "$RESET" "$PUBLIC_KEY"
for f in "$README_MD" "$INSTALL_MD"; do
[ -f "$f" ] || continue
tmp="$(mktemp)"
sed "s|${KEY_NAME}:<[^>]*>|${PUBLIC_KEY}|g" "$f" > "$tmp" && mv "$tmp" "$f"
printf ' %s✓ pinned in%s %s\n' "$GREEN" "$RESET" "${f#"$REPO_ROOT"/}"
done
say "Commit those two files — without the key nobody can trust the cache."
fi
fi
else
PUBLIC_KEY="$(grep -om1 "$KEY_NAME:[A-Za-z0-9+/=]*" "$README_MD" 2>/dev/null || true)"
printf ' %s✓%s A key is already installed and pinned in the docs\n' "$GREEN" "$RESET"
[ -n "$PUBLIC_KEY" ] && printf ' %s\n' "$PUBLIC_KEY"
printf '\n'
warn "Do not regenerate it casually."
note " A new key invalidates every signature already published, and every user pinning the"
note " old one starts failing. Rotating means updating the docs and telling users."
pause "Press Enter for the last stage"
fi
# ── 4 ─────────────────────────────────────────────────────────────────────
stage "Publish — land the flake on main and verify"
say "The publish tier runs on a push to main touching the flake, Cargo.*, or packaging/nix."
printf '\n'
note " It builds the whole Rust workspace AND gamescope inside the nix sandbox — sccache"
note " cannot reach in there, so budget roughly an hour for the first run."
note " If it reddens on 'Build the bun packages', that is the known intermittent OOM"
note " (exit 137) rather than a real break — re-run the job."
printf '\n'
open_url "$GITEA_REPO/actions?workflow=nix.yml"
step "Merge any outstanding cache PR, or push a flake-touching commit to main."
step "Watch the nix workflow's 'Sign + publish to nix.unom.io' step."
pause "Published? Press Enter to verify the live cache"
CODE="$(probe /nix-cache-info)"
if [ "$CODE" = 200 ]; then
printf ' %s✓%s nix-cache-info is being served\n' "$GREEN" "$RESET"
LIVE_PUB="$(curl -sS -m 15 "https://$CACHE_HOST/punktfunk-cache.pub" 2>/dev/null || true)"
if [ -n "$LIVE_PUB" ]; then
printf ' %s✓%s published key: %s\n' "$GREEN" "$RESET" "$LIVE_PUB"
if [ -n "${PUBLIC_KEY:-}" ] && [ "$LIVE_PUB" != "$PUBLIC_KEY" ]; then
warn "That does NOT match the key pinned in the docs:"
note " docs: ${PUBLIC_KEY}"
note " cache: ${LIVE_PUB}"
note " Users following the docs would reject everything this cache serves."
SKIPPED+=("public key mismatch between the docs and $CACHE_HOST")
fi
fi
# The one failure mode that breaks USERS rather than us: nix reads any non-404 as a hard
# error, not as a cache miss, so a miss MUST 404.
MISS="$(probe /0000000000000000000000000000000000.narinfo)"
if [ "$MISS" = 404 ]; then
printf ' %s✓%s a miss returns 404 — nix falls through to cache.nixos.org correctly\n' "$GREEN" "$RESET"
else
warn "a miss returns $MISS, not 404 — every user build would fail on any package this"
warn "cache does not hold. Check for a proxy or auth layer in front of Caddy."
SKIPPED+=("404-on-miss behaviour at $CACHE_HOST")
fi
else
warn "https://$CACHE_HOST/nix-cache-info returned $CODE — nothing published yet."
SKIPPED+=("first publish to $CACHE_HOST")
fi
finish
printf ' %sUsers now add, on NixOS:%s\n\n' "$BOLD" "$RESET"
printf ' nix.settings = {\n'
printf ' substituters = [ "https://%s" ];\n' "$CACHE_HOST"
printf ' trusted-public-keys = [ "%s" ];\n' "${PUBLIC_KEY:-$KEY_NAME:…}"
printf ' };\n\n'
note " Full instructions: packaging/nix/README.md § Binary cache"
printf '\n'