From c50704e1b32cf2297a07c17a7013be166d87e50b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 04:48:11 +0000 Subject: [PATCH] Add automatic tab-completion setup and old config-backup pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things surfaced from actual use this session: 1. Tab completion (tools/setup-completion.bash, added earlier) required manually editing ~/.bashrc — easy to skip or get wrong (confirmed live: the source line never actually landed the first time). base now wires it in automatically (idempotent, checked by grep first), matching how it already touches ~/.bashrc for SSH Host aliases. 2. No pruning existed anywhere for the *.backup. files ~60 different services create before overwriting a live config (Caddyfile, /etc/fstab, etc) — every one of them backs up, none clean up, so they accumulate forever on a box reconfigured regularly. tools/prune-old-backups.sh prunes by file mtime (not by parsing the timestamp out of the filename — robust to the %Y%m%d-%H%M%S vs %Y%m%d_%H%M%S inconsistency across services), always keeping the single newest backup per distinct file regardless of age. Verified both the normal case (mixed old/new, prunes only the old ones) and the edge case (every backup for a file is old, keeps the newest one anyway) against real fixtures. base offers it as a daily systemd timer (prompted, since it deletes files — unlike the tab-completion wiring, which doesn't). Also added logrotate for Caddy's own access logs (/var/log/caddy/*.log), which had no rotation at all and grow unbounded on an active box. Uses copytruncate specifically: the log directory is bind-mounted into the running Caddy container and read live by CrowdSec, so truncating in place avoids either of them needing to notice or react to a rotation happening. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- README.md | 25 ++++++++++++ services/base.sh | 78 ++++++++++++++++++++++++++++++++++++++ services/caddy.sh | 24 ++++++++++++ tools/prune-old-backups.sh | 69 +++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 tools/prune-old-backups.sh diff --git a/README.md b/README.md index 3406e6a..3066e00 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,10 @@ sudo ./setup.sh --unattended base # non-interactive, use defaults ### Tab completion +Set up automatically by `base` (checks `~/.bashrc` first, so a rerun never +adds it twice) — open a new shell, or `source ~/.bashrc`, and it's active. +To add it manually on a box that installed `base` before this existed: + ```bash echo "source $(pwd)/tools/setup-completion.bash" >> ~/.bashrc source ~/.bashrc @@ -313,6 +317,27 @@ docker compose pull && docker compose up -d # update docker compose down # stop ``` +## Old config backup pruning + +Every service in this repo backs up a live config before overwriting it — +`Caddyfile.backup.`, `/etc/fstab.backup.`, and so on — +but nothing cleans those up afterward, so they build up on any box +reconfigured regularly. `base` offers a daily systemd timer +(`prune-old-backups`) that removes anything older than 30 days, always +keeping at least the single newest backup per file regardless of age — a +box left alone for months never ends up with zero backups for something. + +```bash +sudo bash tools/prune-old-backups.sh [KEEP_DAYS] # run by hand, default 30 +systemctl status prune-old-backups.timer # check the schedule +sudo systemctl disable --now prune-old-backups.timer # turn it off +``` + +Caddy's own access logs (`/var/log/caddy/*.log`) are handled separately — +`caddy` sets up `logrotate` for those directly (14 days, `copytruncate` so +neither the running container nor CrowdSec's log tailing has to notice a +rotation happened). + ## SSH key import (GitHub/Launchpad) and disabling password login Imports your public keys from GitHub and/or Launchpad (Canonical/Ubuntu's diff --git a/services/base.sh b/services/base.sh index 5637d63..346b4b1 100644 --- a/services/base.sh +++ b/services/base.sh @@ -22,6 +22,8 @@ install_base() { echo "[DRY-RUN] Would offer Caddy reverse proxy install (full repo only)" echo "[DRY-RUN] Would offer CrowdSec intrusion prevention install (full repo only)" echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config" + echo "[DRY-RUN] Would add setup.sh tab completion to ~/.bashrc (if not already there)" + echo "[DRY-RUN] Would offer daily pruning of old *.backup.* config files (systemd timer)" return 0 fi @@ -82,6 +84,82 @@ install_base() { # ── SSH Host aliases ───────────────────────────────────────────────────── _base_setup_ssh_aliases + + # ── setup.sh tab completion ───────────────────────────────────────────── + _base_setup_tab_completion + + # ── Old config-backup pruning ──────────────────────────────────────────── + _base_setup_backup_pruning +} + +# Wires tools/setup-completion.bash into ACTUAL_USER's shell automatically — +# no reason to make everyone find and run this by hand when base already +# touches ~/.bashrc for other things. Idempotent (checked by grep before +# appending), so reruns don't pile up duplicate source lines. +_base_setup_tab_completion() { + local comp_script="$HERE/tools/setup-completion.bash" + [ -f "$comp_script" ] || return 0 + + local bashrc="$ACTUAL_HOME/.bashrc" + [ -f "$bashrc" ] || return 0 + grep -qF "$comp_script" "$bashrc" 2>/dev/null && return 0 + + # No DRY_RUN check here — install_base()'s own top-level one (above) + # already returns before this helper is ever called in that mode, + # unlike install_glow()'s check further down, which is independently + # invokable (sudo ./setup.sh glow --dry-run) and genuinely reachable. + { + echo "" + echo "# ubuntu-post-install: setup.sh tab completion" + echo "source $comp_script" + } >> "$bashrc" + chown "$ACTUAL_USER:$ACTUAL_USER" "$bashrc" 2>/dev/null || true + log_success "setup.sh tab completion added to $bashrc (takes effect in new shells, or: source $bashrc)" +} + +# Every service in this repo backs up a live config before overwriting it +# (Caddyfile, /etc/fstab, ...) but none of them ever clean those up +# afterward — see tools/prune-old-backups.sh's own header for the full +# reasoning. Offers a daily systemd timer that prunes anything older than +# 30 days, always keeping at least the single newest backup per file +# regardless of age. +_base_setup_backup_pruning() { + command -v systemctl >/dev/null 2>&1 || return 0 + systemctl list-unit-files prune-old-backups.timer --no-legend 2>/dev/null | grep -q . && return 0 + + local prune_script="$HERE/tools/prune-old-backups.sh" + [ -f "$prune_script" ] || return 0 + + echo "" + local ENABLE_PRUNE="" + prompt_yn "Automatically prune old config backups (Caddyfile.backup.*, fstab.backup.*, etc — keeps 30 days, always keeps at least the newest one)? (y/n):" "y" ENABLE_PRUNE + [[ "$ENABLE_PRUNE" =~ ^[Yy]$ ]] || return 0 + + cat > /etc/systemd/system/prune-old-backups.service << UNIT +[Unit] +Description=Prune old *.backup.* config backups (Caddyfile, fstab, etc) + +[Service] +Type=oneshot +ExecStart=/bin/bash ${prune_script} 30 +UNIT + cat > /etc/systemd/system/prune-old-backups.timer << 'UNIT' +[Unit] +Description=Daily backup pruning + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=timers.target +UNIT + systemctl daemon-reload + if systemctl enable --now prune-old-backups.timer >/dev/null 2>&1; then + log_success "Old config backups will be pruned daily, keeping 30 days (systemd timer: prune-old-backups)" + else + log_warning "Couldn't enable the pruning timer — run $prune_script manually to prune old backups." + fi } _base_setup_nvidia_gpu() { diff --git a/services/caddy.sh b/services/caddy.sh index 7ba0e83..d50c810 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -233,6 +233,7 @@ install_caddy() { echo "[DRY-RUN] Would write a starter $CADDY_DIR/Caddyfile (if none exists)" echo "[DRY-RUN] Would write $CADDY_DIR/README.md" echo "[DRY-RUN] Would open 80/tcp, 443/tcp, 443/udp in UFW (Docker's own iptables rules let this traffic through either way, but ufw status should actually reflect it)" + echo "[DRY-RUN] Would configure logrotate for /var/log/caddy/*.log (14 days, copytruncate)" echo "[DRY-RUN] Would optionally start Caddy (docker compose up -d)" return 0 fi @@ -385,6 +386,29 @@ CADDYFILE chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" echo " ✓ Caddy configured at $CADDY_DIR" + # Every site's access log (one JSON file per domain, per the "log {}" + # block configure_caddy_for_service writes) grows forever otherwise — + # nothing in this repo ever rotated them. copytruncate, not the usual + # rename+recreate rotation: the log directory is bind-mounted into the + # running Caddy container (still holding the file open) and read live + # by CrowdSec, so truncating in place avoids both "Caddy keeps writing + # to the old, now-unlinked file" and "CrowdSec's tail loses the file" + # — neither has to detect or react to a rotation at all this way. + if [ -d /etc/logrotate.d ]; then + cat > /etc/logrotate.d/caddy << 'LOGROTATE' +/var/log/caddy/*.log { + daily + rotate 14 + compress + delaycompress + missingok + notifempty + copytruncate +} +LOGROTATE + echo " ✓ Access log rotation configured (/var/log/caddy/*.log, 14 days, copytruncate)" + fi + # Every other service in this repo opens its own UFW rule; this file # never did — Docker manipulates iptables directly for published # container ports (the ports: mapping above), which bypasses UFW's own diff --git a/tools/prune-old-backups.sh b/tools/prune-old-backups.sh new file mode 100644 index 0000000..f7f7e71 --- /dev/null +++ b/tools/prune-old-backups.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# tools/prune-old-backups.sh — Deletes old *.backup.* files this repo's +# services create before overwriting a live config (Caddyfile, /etc/fstab, +# etc — the pattern is always .backup., though the exact +# timestamp format varies by service: %Y%m%d-%H%M%S vs %Y%m%d_%H%M%S). +# Nothing in this repo ever cleans these up at the point they're created — +# every service backs up before a destructive write, none of them prune +# afterward, so they accumulate forever on any box reconfigured regularly. +# +# Prunes by file mtime (not by parsing the timestamp out of the filename — +# robust to the format inconsistency above, since it never has to parse it +# at all), and always keeps the single newest backup per distinct file +# regardless of age, so a box that hasn't been touched in months never +# ends up with zero backups for something. +# +# Usage: +# sudo bash prune-old-backups.sh [KEEP_DAYS] (default 30) + +set -uo pipefail + +KEEP_DAYS="${1:-30}" +[[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] || { echo "KEEP_DAYS must be a number" >&2; exit 1; } + +ACTUAL_USER="${SUDO_USER:-${USER:-root}}" +ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")" +DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + +SEARCH_DIRS=() +[ -d "$DOCKER_DIR" ] && SEARCH_DIRS+=("$DOCKER_DIR") +[ -d /etc ] && SEARCH_DIRS+=(/etc) + +if [ "${#SEARCH_DIRS[@]}" -eq 0 ]; then + echo "Nothing to scan (no $DOCKER_DIR, no /etc)." + exit 0 +fi + +# Pass 1: find every distinct "base" (the path with .backup.TIMESTAMP +# stripped) and remember its single newest match — that one is always +# kept below, regardless of age. +declare -A NEWEST_PER_BASE +while IFS= read -r -d '' f; do + base="${f%.backup.*}" + cur="${NEWEST_PER_BASE[$base]:-}" + if [ -z "$cur" ] || [ "$f" -nt "$cur" ]; then + NEWEST_PER_BASE["$base"]="$f" + fi +done < <(find "${SEARCH_DIRS[@]}" -maxdepth 6 -type f -name "*.backup.*" -print0 2>/dev/null) + +DELETED=0 +KEPT_AS_NEWEST=0 + +# Pass 2: delete only files older than KEEP_DAYS, skipping any that are +# the sole/newest backup for their base (the safety net above). +while IFS= read -r -d '' f; do + is_newest=false + for keep in "${NEWEST_PER_BASE[@]}"; do + if [ "$f" = "$keep" ]; then + is_newest=true + break + fi + done + if [ "$is_newest" = true ]; then + KEPT_AS_NEWEST=$((KEPT_AS_NEWEST + 1)) + continue + fi + rm -f "$f" && DELETED=$((DELETED + 1)) +done < <(find "${SEARCH_DIRS[@]}" -maxdepth 6 -type f -name "*.backup.*" -mtime "+${KEEP_DAYS}" -print0 2>/dev/null) + +echo "Backup pruning: removed $DELETED file(s) older than ${KEEP_DAYS} days (kept $KEPT_AS_NEWEST as the newest-per-file safety net)"