diff --git a/Makefile b/Makefile index 964bea0..be91d5a 100644 --- a/Makefile +++ b/Makefile @@ -125,6 +125,10 @@ tf-providers-plan: ## Plan after an upgrade (uses HCLOUD_TOKEN if set) @terraform -chdir=$(TF_DIR) plan $(if $(TF_TOKEN),-var="hcloud_token=$(TF_TOKEN)") +# ---- Backup (Q3 Operability & Resilience — D4) ---- +backup: ## Backup S1 OS config to /opt/backup/railiance/infra/ (age-encrypted, root required) + sudo tools/cmd/railiance-backup-s1 + # ---- Ansible ---- ansible-bootstrap: ## Run base bootstrap play (users, ssh, ufw, sops-agent) cd ansible && ansible-playbook playbooks/bootstrap.yaml -u admin diff --git a/tools/cmd/railiance-backup-s1 b/tools/cmd/railiance-backup-s1 new file mode 100755 index 0000000..97fba45 --- /dev/null +++ b/tools/cmd/railiance-backup-s1 @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# tools/cmd/railiance-backup-s1 — S1 OS & Provisioning backup +# Backs up: key OS config files applied by Ansible (sshd, ufw, fail2ban, hosts) +# Encryption: age (reuses SOPS key pair from .sops.yaml) +# Output: /opt/backup/railiance/infra/ +# No network required. Requires root to read /etc/. +set -euo pipefail + +# ── Configuration ────────────────────────────────────────────────────────────── +AGE_PUBLIC_KEY="age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4" +BACKUP_DIR="/opt/backup/railiance/infra" +KEEP=7 +TS="$(date -u +%Y%m%dT%H%M%SZ)" + +# Colour helpers (no external dependency) +ok() { printf " ✅ %-12s %s\n" "$1" "$2"; } +warn() { printf " ⚠️ %-12s %s\n" "$1" "$2"; } +bad() { printf " ❌ %-12s %s\n" "$1" "$2"; } + +mkdir -p "${BACKUP_DIR}" +printf "\nrailiance-infra (S1) backup — %s\n" "${TS}" +printf "%0.s-" $(seq 1 44); echo + +# ── Root check ───────────────────────────────────────────────────────────────── +if [[ $EUID -ne 0 ]]; then + bad "root" "this script requires root — run via: sudo make backup" + exit 1 +fi + +# ── OS config snapshot ───────────────────────────────────────────────────────── +# Captures the live state of files that Ansible manages. +# These may drift from the playbooks if manual changes were made. +ok "os-config" "snapshotting /etc config…" + +OS_FILES=( + /etc/ssh/sshd_config + /etc/ssh/sshd_config.d/ + /etc/ufw/ufw.conf + /etc/ufw/user.rules + /etc/ufw/user6.rules + /etc/fail2ban/jail.local + /etc/fail2ban/jail.d/ + /etc/hosts + /etc/hostname + /etc/apt/sources.list.d/ +) + +TMP_OS="$(mktemp -d)" +for item in "${OS_FILES[@]}"; do + [[ -e "${item}" ]] || continue + dest="${TMP_OS}$(dirname "${item}")" + mkdir -p "${dest}" + cp -a "${item}" "${dest}/" 2>/dev/null || true +done + +tar -czf - -C "${TMP_OS}" . \ + | age -r "${AGE_PUBLIC_KEY}" -o "${BACKUP_DIR}/os-config-${TS}.tar.gz.age" +rm -rf "${TMP_OS}" +ok "os-config" "encrypted → os-config-${TS}.tar.gz.age" + +# ── Installed packages list ──────────────────────────────────────────────────── +if command -v dpkg &>/dev/null; then + dpkg --get-selections \ + | age -r "${AGE_PUBLIC_KEY}" -o "${BACKUP_DIR}/packages-${TS}.txt.age" + ok "packages" "encrypted → packages-${TS}.txt.age" +fi + +# ── Prune local cache ────────────────────────────────────────────────────────── +for pattern in "os-config-*.tar.gz.age" "packages-*.txt.age"; do + find "${BACKUP_DIR}" -name "${pattern}" | sort -r | tail -n +$((KEEP + 1)) | xargs -r rm -f +done +ok "prune" "kept last ${KEEP} of each type" + +# ── Stamp ────────────────────────────────────────────────────────────────────── +echo "${TS}" > "${BACKUP_DIR}/.last-backup" + +echo +ok "done" "backup complete — ${TS}" +echo " Location: ${BACKUP_DIR}" +echo " Decrypt with: age -d -i ~/.config/sops/age/keys.txt "