Add automatic tab-completion setup and old config-backup pruning

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.<timestamp> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
This commit is contained in:
Claude
2026-08-11 04:48:11 +00:00
parent 91340c5f2f
commit c50704e1b3
4 changed files with 196 additions and 0 deletions
+25
View File
@@ -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.<timestamp>`, `/etc/fstab.backup.<timestamp>`, 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
+78
View File
@@ -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() {
+24
View File
@@ -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
+69
View File
@@ -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 <name>.backup.<timestamp>, 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)"