diff --git a/.gitignore b/.gitignore index f27bfe6..4beb1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ app/kontra-bin # Obsidian cache (content repo may be opened as a vault) .obsidian/ app/src/*_templ.go + +# secrets +.env +.env.* +kontra-bin diff --git a/deploy/deploy-apply.sh b/deploy/deploy-apply.sh new file mode 100644 index 0000000..28fd7f0 --- /dev/null +++ b/deploy/deploy-apply.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Apply Kontra Nix additions on .13 and rebuild. Run as: sudo bash deploy-apply.sh --from /var/www/kontra_day/deploy +set -euo pipefail + +DEPLOY_DIR="${1:-/var/www/kontra_day/deploy}" +CFG=/etc/nixos/configuration.nix +BAK=$CFG.bak-kontra-$(date +%Y%m%d-%H%M%S) + +echo "== backup configuration.nix -> $BAK" +cp -a "$CFG" "$BAK" + +echo "== patch configuration.nix" +python3 "$DEPLOY_DIR/patch-nix.py" "$CFG" + +echo "== Nix syntax check" +if ! nix-instantiate --parse "$CFG" >/dev/null; then + echo "!! Nix syntax FAILED — restoring backup" + cp -a "$BAK" "$CFG" + exit 1 +fi +echo "OK" + +echo "== nixos-rebuild switch (may take minutes)" +nixos-rebuild switch --show-trace 2>&1 | tail -40 + +echo "== status" +systemctl --no-pager --full status kontra.service 2>/dev/null | head -12 +systemctl --no-pager list-timers kontra-pull.timer 2>/dev/null | head -4 +echo "ALL DONE" \ No newline at end of file diff --git a/deploy/docker-kontra-up.sh b/deploy/docker-kontra-up.sh new file mode 100644 index 0000000..5d35d33 --- /dev/null +++ b/deploy/docker-kontra-up.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Kontra → Docker on .13. Runs as sam (docker group). Requires root once for the Nix revert. +# Usage: bash docker-kontra-up.sh [--revert-first] +set -euo pipefail + +cd /home/sam/Docker/Containers/kontra + +if [ "${1:-}" = "--revert-first" ]; then + echo "== REVERTING Nix systemd additions (needs sudo) ==" + sudo bash /var/www/kontra_day/deploy/revert-kontra-nix.sh + echo " (ignore home-manager errors — not Kontra's concern; Kontra units are gone)" +fi + +echo "== build + start kontra container ==" +docker compose up -d --build 2>&1 | tail -25 + +echo "== wait for boot ==" +sleep 4 +echo "== container status ==" +docker ps --format "{{.Names}} | {{.Status}} | {{.Ports}}" | grep kontra || true + +echo "== direct test ==" +curl -s -o /dev/null -w "localhost:8600 -> HTTP %{http_code}\n" http://127.0.0.1:8600/ || echo "not up yet (check: docker logs kontra)" +echo "== logs tail ==" +docker logs --tail 15 kontra 2>&1 | tail -15 +echo "DONE" \ No newline at end of file diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..23aae79 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,27 @@ +# Kontra — GOTH news-opinion site. Self-contained container. +# Build context = /home/sam/Docker/Containers/kontra (has src/{go.mod,go.sum,src/...}) +FROM golang:1.26-bookworm AS build +# templ CLI for codegen (generates *_templ.go before build) +RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 +ENV PATH="/root/go/bin:${PATH}" +WORKDIR /src +COPY src/go.mod src/go.sum ./ +RUN go mod download +COPY src/src/ ./src/ +WORKDIR /src/src +RUN templ generate +RUN go build -trimpath -ldflags="-s -w" -o /kontra-bin . + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git openssh-client bash \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /kontra-bin /usr/local/bin/kontra-bin + +# entrypoint (pull loop + webserver) from runtime mount +COPY entrypoint.sh /usr/local/bin/kontra-entrypoint +RUN chmod +x /usr/local/bin/kontra-entrypoint + +EXPOSE 8600 +ENTRYPOINT ["/usr/local/bin/kontra-entrypoint", "kontra-bin", "8600"] \ No newline at end of file diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..f0cf1ed --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,18 @@ +services: + kontra: + build: + context: . + dockerfile: Dockerfile + container_name: kontra + restart: unless-stopped + ports: + - "8600:8600" + volumes: + - /var/www/kontra_day/content:/var/www/kontra_day/content + - /home/sam/.ssh/id_ed25519:/root/.ssh/id_ed25519:ro + environment: + - PORT=8600 + - KONTRA_CONTENT=/var/www/kontra_day/content + - KONTRA_MEDIA=/media + extra_hosts: + - "gitea.lab.audasmedia.com.au:192.168.20.35" \ No newline at end of file diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh new file mode 100644 index 0000000..2b2ddcd --- /dev/null +++ b/deploy/docker/entrypoint.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# Kontra container entrypoint. +# - pulls content from Gitea every 30s +# - runs the webserver; restarts on content change (binary loads content at boot) +set -eu + +BIN="${1:-kontra-bin}" +PORT="${2:-8600}" +CONTENT="/var/www/kontra_day/content" +GIT=/usr/bin/git + +# Gitea SSH key for sam (mounted into the container) +export GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -i /root/.ssh/id_ed25519" + +# git refuses repos owned by another uid (host sam=1000); trust the mounted repo. +"$GIT" config --global --add safe.directory "$CONTENT" >/dev/null 2>&1 || true +"$GIT" config --global user.email "kontra-bot@localhost" >/dev/null 2>&1 || true +"$GIT" config --global user.name "kontra-bot" >/dev/null 2>&1 || true + +# Initial clone if the mounted dir is empty (fresh deployment). +if [ ! -d "$CONTENT/.git" ]; then + echo "cloning kontra-content..." + "$GIT" clone git@gitea.lab.audasmedia.com.au:2222/sam/kontra-content.git "$CONTENT" +fi + +"$GIT" -C "$CONTENT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || true + +start_server() { + # exec: replaces THIS shell, so $! is the real binary PID; kill hits it. + KONTRA_CONTENT="$CONTENT" KONTRA_MEDIA="/media" PORT="$PORT" exec "$BIN" +} + +# First start. +start_server & +SERVER_PID=$! + +# Pull every 30s; restart server when HEAD actually changes. +PREV=$("$GIT" -C "$CONTENT" rev-parse HEAD 2>/dev/null || echo "") +while true; do + sleep 30 + if "$GIT" -C "$CONTENT" pull --ff-only origin main >/tmp/pull.log 2>&1; then + CUR=$("$GIT" -C "$CONTENT" rev-parse HEAD) + if [ -n "$PREV" ] && [ -n "$CUR" ] && [ "$PREV" != "$CUR" ]; then + echo "$(date +%T) content changed ($PREV -> $CUR); restarting server" + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + sleep 1 + start_server & + SERVER_PID=$! + fi + PREV=$CUR + else + echo "pull failed:"; cat /tmp/pull.log + fi +done \ No newline at end of file diff --git a/deploy/fix-pull-path.sh b/deploy/fix-pull-path.sh new file mode 100644 index 0000000..6e08fb4 --- /dev/null +++ b/deploy/fix-pull-path.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Fix kontra-pull ExecStart: /bin/bash -> /run/current-system/sw/bin/bash on .13 +set -euo pipefail +CFG=/etc/nixos/configuration.nix +BAK=$CFG.bak-kontra-fix-$(date +%Y%m%d-%H%M%S) +cp -a "$CFG" "$BAK" +sed -i 's|/bin/bash /var/www/kontra_day/bin/kontra-pull.sh|/run/current-system/sw/bin/bash /var/www/kontra_day/bin/kontra-pull.sh|' "$CFG" +echo "patched. verifying:" +grep -n "kontra-pull.sh" "$CFG" +nix-instantiate --parse "$CFG" >/dev/null && echo "Nix syntax OK" +echo "== nixos-rebuild switch ==" +nixos-rebuild switch --show-trace 2>&1 | tail -25 +echo "== status ==" +systemctl --no-pager status kontra-pull.service 2>/dev/null | head -8 || true diff --git a/deploy/fix-pull2.sh b/deploy/fix-pull2.sh new file mode 100644 index 0000000..869be55 --- /dev/null +++ b/deploy/fix-pull2.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Fix kontra-pull.sh: use runuser (not su) + point service at sam-writable deploy path. +set -euo pipefail +CFG=/etc/nixos/configuration.nix +BAK=$CFG.bak-kontra-fix2-$(date +%Y%m%d-%H%M%S) +cp -a "$CFG" "$BAK" +echo "backup: $BAK" + +# Replace the ExecStart path in the Nix config (pull service -> deploy/ copy) +python3 - "$CFG" <<'PYEOF' +import sys +p = sys.argv[1] +src = open(p).read() +old = 'ExecStart = "/run/current-system/sw/bin/bash /var/www/kontra_day/bin/kontra-pull.sh";' +new = 'ExecStart = "/run/current-system/sw/bin/bash /var/www/kontra_day/deploy/kontra-pull.sh";' +if old in src: + src = src.replace(old, new) +else: + print("WARN: old ExecStart not found; check manually") +open(p,'w').write(src) +PYEOF +grep -n "kontra-pull.sh" "$CFG" + +# Install fixed script into deploy/ (sam-writable) +cat > /var/www/kontra_day/deploy/kontra-pull.sh <<'SH' +#!/bin/sh +set -e +CONTENT=/var/www/kontra_day/content +GIT=/run/current-system/sw/bin/git +RUNUSER=/run/current-system/sw/bin/runuser +BEFORE=$("$RUNUSER" -u sam -- "$GIT" -C "$CONTENT" rev-parse HEAD) +"$RUNUSER" -u sam -- "$GIT" -C "$CONTENT" pull --ff-only origin main >/tmp/kontra-pull.log 2>&1 || { echo "pull failed:"; cat /tmp/kontra-pull.log; exit 1; } +AFTER=$("$RUNUSER" -u sam -- "$GIT" -C "$CONTENT" rev-parse HEAD) +if [ -n "$BEFORE" ] && [ -n "$AFTER" ] && [ "$BEFORE" != "$AFTER" ]; then + echo "content changed: $BEFORE -> $AFTER; restarting kontra" + /run/current-system/sw/bin/systemctl restart kontra.service +fi +exit 0 +SH +chmod 0755 /var/www/kontra_day/deploy/kontra-pull.sh + +# syntax check +nix-instantiate --parse "$CFG" >/dev/null && echo "Nix syntax OK" || { echo "SYNTAX FAIL"; cp -a "$BAK" "$CFG"; exit 1; } + +echo "== nixos-rebuild switch ==" +nixos-rebuild switch --show-trace 2>&1 | tail -20 +echo "== force a run + status ==" +systemctl start kontra-pull.service 2>&1 | head -2 +sleep 3 +systemctl --no-pager status kontra-pull.service 2>/dev/null | head -8 +echo "== content head ==" +cd /var/www/kontra_day/content && git log --oneline -1 +echo "DONE" \ No newline at end of file diff --git a/deploy/install-kontra.sh b/deploy/install-kontra.sh new file mode 100644 index 0000000..9a3bc5f --- /dev/null +++ b/deploy/install-kontra.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Install Kontra deploy on .13 — run as root (sudo bash install.sh). +# Copies: service units, pull script; enables service + timer. +set -euo pipefail + +DIR=/var/www/kontra_day +BIN=$DIR/bin + +echo "== placing pull script ==" +install -m 0755 -o root -g root deploy/kontra-pull.sh $BIN/kontra-pull.sh + +echo "== installing systemd units ==" +install -m 0644 deploy/kontra.service /etc/systemd/system/kontra.service +install -m 0644 deploy/kontra-pull.service /etc/systemd/system/kontra-pull.service +install -m 0644 deploy/kontra-pull.timer /etc/systemd/system/kontra-pull.timer + +echo "== daemon-reload ==" +systemctl daemon-reload + +echo "== enable+start kontra ==" +systemctl enable --now kontra.service || true +systemctl start kontra.service || true + +echo "== enable+start pull timer ==" +systemctl enable --now kontra-pull.timer || true + +echo "== status ==" +systemctl --no-pager --full status kontra.service | head -15 +systemctl --no-pager list-timers kontra-pull.timer | head -5 +echo "DONE" \ No newline at end of file diff --git a/deploy/kontra-pull.service b/deploy/kontra-pull.service new file mode 100644 index 0000000..0b7b9c5 --- /dev/null +++ b/deploy/kontra-pull.service @@ -0,0 +1,8 @@ +[Unit] +Description=Kontra deploy pull (root; pulls as sam, restarts site if changed) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/bin/bash /var/www/kontra_day/bin/kontra-pull.sh \ No newline at end of file diff --git a/deploy/kontra-pull.sh b/deploy/kontra-pull.sh new file mode 100644 index 0000000..963777e --- /dev/null +++ b/deploy/kontra-pull.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Kontra content deploy: pull from Gitea; restart site if content changed. +# Runs as ROOT (systemd oneshot). Git runs as 'sam' (uses sam's Gitea SSH key) +# via runuser (NixOS-native drop-privilege); restart needs root. +set -e +CONTENT=/var/www/kontra_day/content +GIT=/run/current-system/sw/bin/git +RUNUSER=/run/current-system/sw/bin/runuser + +BEFORE=$("$RUNUSER" -u sam -- "$GIT" -C "$CONTENT" rev-parse HEAD) +"$RUNUSER" -u sam -- "$GIT" -C "$CONTENT" pull --ff-only origin main >/tmp/kontra-pull.log 2>&1 \ + || { echo "pull failed:"; cat /tmp/kontra-pull.log; exit 1; } +AFTER=$("$RUNUSER" -u sam -- "$GIT" -C "$CONTENT" rev-parse HEAD) + +if [ -n "$BEFORE" ] && [ -n "$AFTER" ] && [ "$BEFORE" != "$AFTER" ]; then + echo "content changed: $BEFORE -> $AFTER; restarting kontra" + "/run/current-system/sw/bin/systemctl" restart kontra.service +fi +exit 0 \ No newline at end of file diff --git a/deploy/kontra-pull.timer b/deploy/kontra-pull.timer new file mode 100644 index 0000000..98b90e5 --- /dev/null +++ b/deploy/kontra-pull.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Kontra content pull timer (every 30s) + +[Timer] +OnBootSec=30s +OnUnitActiveSec=30s +AccuracySec=2s + +[Install] +WantedBy=timers.target \ No newline at end of file diff --git a/deploy/kontra.service b/deploy/kontra.service new file mode 100644 index 0000000..e86d7ae --- /dev/null +++ b/deploy/kontra.service @@ -0,0 +1,20 @@ +[Unit] +Description=Kontra — GOTH news-opinion site +After=network.target + +[Service] +Type=simple +User=sam +Group=users +WorkingDirectory=/var/www/kontra_day +Environment=KONTRA_CONTENT=/var/www/kontra_day/content +Environment=KONTRA_MEDIA=/media +Environment=PORT=8600 +# Binds :8600 (localhost-adjacent; .13 Caddy vhost reverse_proxies to it). +ExecStart=/var/www/kontra_day/bin/kontra-bin +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/deploy/patch-kontra-nix.sh b/deploy/patch-kontra-nix.sh new file mode 100644 index 0000000..292b514 --- /dev/null +++ b/deploy/patch-kontra-nix.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Apply Kontra additions to /etc/nixos/configuration.nix on .13, then rebuild. +# Run as: sudo bash patch-kontra-nix.sh +set -euo pipefail + +CFG=/etc/nixos/configuration.nix +BAK=$CFG.bak-kontra-$(date +%Y%m%d-%H%M%S) +cp -a "$CFG" "$BAK" +echo "backup: $BAK" + +python3 - "$CFG" <<'PYEOF' +import sys, io + +path = sys.argv[1] +src = open(path).read() + +# --- 1) Caddy extraConfig: append reverse_proxy block before the closing `'';` +caddy_add = ''' +http://kontra.lab.audasmedia.com.au:8000 { + reverse_proxy localhost:8600 + } +http://admin.kontra.lab.audasmedia.com.au:8000 { + reverse_proxy localhost:8600 + } +http://kontra.home.lab:8000 { + reverse_proxy localhost:8600 + } +''' + +# The extraConfig string ends with '\n '';' (line 77). Insert before that. +needle = "\n '';" +i = src.rindex(needle, 0, src.index("services.caddy")) +src = src[:i] + caddy_add + needle + src[i+len(needle):] + +# --- 2) systemd services + timer: insert after the services.caddy attr close (line 79) +services_add = ''' +# --- KONTRA (GOTH news-opinion site) --- +systemd.services.kontra = { + description = "Kontra GOTH news-opinion site"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = "sam"; + Group = "users"; + WorkingDirectory = "/var/www/kontra_day"; + Environment = [ "KONTRA_CONTENT=/var/www/kontra_day/content" "KONTRA_MEDIA=/media" "PORT=8600" ]; + ExecStart = "/var/www/kontra_day/bin/kontra-bin"; + Restart = "on-failure"; + RestartSec = "3"; + NoNewPrivileges = true; + }; +}; + +systemd.services.kontra-pull = { + description = "Pull Kontra content from Gitea (every 30s timer)"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "/bin/bash /var/www/kontra_day/bin/kontra-pull.sh"; + }; +}; + +systemd.timers.kontra-pull = { + description = "Kontra content pull timer (30s)"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "30s"; + OnUnitActiveSec = "30s"; + AccuracySec = "2s"; + }; +}; +''' +# Insert after the services.caddy closing "};" (line 79). Find "};" following services.caddy. +anchor = "services.caddy" +idx = src.index(anchor) +close = src.find("};", idx) +close2 = src.find("};", close + 2) # services attr closes with one more "};"? see structure +# The caddy attr is " services.caddy = {\n ... \n '';\n};" -- one close. Insert after it. +insert_at = close + 2 +src = src[:insert_at] + "\n" + services_add + src[insert_at:] + +open(path, "w").write(src) +print("patched configuration.nix") +PY + +echo "== sanity: syntax check ==" +nix-instantiate --parse "$CFG" >/dev/null && echo "Nix syntax OK" || { echo "NIX SYNTAX FAILED — restoring backup"; cp -a "$BAK" "$CFG"; exit 1; } + +echo "== nixos-rebuild switch (this may take a while) ==" +nixos-rebuild switch --show-trace 2>&1 | tail -30 +echo "DONE" \ No newline at end of file diff --git a/deploy/patch-nix.py b/deploy/patch-nix.py new file mode 100644 index 0000000..f2611d3 --- /dev/null +++ b/deploy/patch-nix.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Apply Kontra additions to a copy of configuration.nix. Test on /tmp, then run on .13.""" +import sys + +path = sys.argv[1] +src = open(path).read() + +# --- 1) Caddy extraConfig: append reverse_proxy block before closing ''; --- +caddy_add = """ +http://kontra.lab.audasmedia.com.au:8000 { + reverse_proxy localhost:8600 + } +http://admin.kontra.lab.audasmedia.com.au:8000 { + reverse_proxy localhost:8600 + } +http://kontra.home.lab:8000 { + reverse_proxy localhost:8600 + } +""" +needle = "\n'';" +idx_caddy = src.index("services.caddy") +i = src.index(needle, idx_caddy) +src = src[:i] + caddy_add + needle + src[i + len(needle):] + +# --- Step 2) systemd service + timer after services.caddy closes --- +services_add = """ +# --- KONTRA (GOTH news-opinion site) --- +systemd.services.kontra = { + description = "Kontra GOTH news-opinion site"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = "sam"; + Group = "users"; + WorkingDirectory = "/var/www/kontra_day"; + Environment = [ "KONTRA_CONTENT=/var/www/kontra_day/content" "KONTRA_MEDIA=/media" "PORT=8600" ]; + ExecStart = "/var/www/kontra_day/bin/kontra-bin"; + Restart = "on-failure"; + RestartSec = "3"; + NoNewPrivileges = true; + }; +}; + +systemd.services.kontra-pull = { + description = "Pull Kontra content from Gitea (30s timer)"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "/run/current-system/sw/bin/bash /var/www/kontra_day/deploy/kontra-pull.sh"; + }; +}; + +systemd.timers.kontra-pull = { + description = "Kontra content pull timer (30s)"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "30s"; + OnUnitActiveSec = "30s"; + AccuracySec = "2s"; + }; +}; +""" +anchor = "services.caddy" +idx = src.index(anchor) +close = src.find("};", idx) # closes services.caddy attr +insert_at = close + 2 +src = src[:insert_at] + "\n" + services_add + src[insert_at:] + +open(path, "w").write(src) +print("patched:", path) \ No newline at end of file diff --git a/deploy/revert-kontra-nix.sh b/deploy/revert-kontra-nix.sh new file mode 100644 index 0000000..9431947 --- /dev/null +++ b/deploy/revert-kontra-nix.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Revert ALL Kontra additions from /etc/nixos/configuration.nix on .13. +# Restores the untouched pre-Kontra backup, then rebuilds (removes systemd kontra + pull + caddy vhosts). +# Run as: sudo bash revert-kontra-nix.sh +set -euo pipefail + +CFG=/etc/nixos/configuration.nix +PRE=/etc/nixos/configuration.nix.bak-kontra-20260908-195305 # first backup = pre-Kontra (no additions) + +if [ ! -f "$PRE" ]; then + echo "!! pre-Kontra backup not found at $PRE — aborting (nothing reverted)." + exit 1 +fi + +# Safety: confirm the CURRENT file actually contains Kontra markers before overwriting +if ! grep -q "KONTRA" "$CFG"; then + echo "!! Current configuration.nix has no Kontra markers. Leaving as-is." + exit 0 +fi + +echo "== restoring pre-Kontra configuration.nix" +cp -a "$PRE" "$CFG" +echo " restored. Kontra markers now: $(grep -c CONTRA "$CFG" || true)" + +echo "== Nix syntax check" +nix-instantiate --parse "$CFG" >/dev/null && echo "OK" || { echo "SYNTAX FAIL — no change made"; exit 1; } + +echo "== nixos-rebuild switch (removes kontra service + timer + caddy vhosts)" +nixos-rebuild switch --show-trace 2>&1 | tail -20 + +echo "== confirm removal" +systemctl --no-pager list-units --all 2>/dev/null | grep -i kontra || echo " no kontra units (clean)" +echo "DONE — Kontra Nix additions reverted." \ No newline at end of file