#!/usr/bin/env bash ######################################################################################### #### config-backup.sh — Snapshot system configs into a timestamped tarball #### #### Backs up /etc, crontabs, package lists, systemd units, and firewall rules #### #### Dry-run by default — nothing is written without --force #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./config-backup.sh #### #### ./config-backup.sh --force #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── BACKUP_DIR="${BACKUP_DIR:-/var/backups/config-snapshots}" DRY_RUN="${DRY_RUN:-true}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME INCLUDE_PATHS=() EXCLUDE_PATHS=() STAGING_DIR="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${DIM}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Helpers ─────────────────────────────────────────────────────────── section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } human_bytes() { local bytes="$1" if [[ "$bytes" -ge 1073741824 ]]; then awk "BEGIN { printf \"%.1f GiB\", $bytes / 1073741824 }" elif [[ "$bytes" -ge 1048576 ]]; then awk "BEGIN { printf \"%.1f MiB\", $bytes / 1048576 }" elif [[ "$bytes" -ge 1024 ]]; then awk "BEGIN { printf \"%.1f KiB\", $bytes / 1024 }" else echo "${bytes} B" fi } cleanup_staging() { if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then rm -rf "$STAGING_DIR" verbose "Cleaned up staging directory" fi } is_excluded() { local path="$1" for exc in "${EXCLUDE_PATHS[@]}"; do if [[ "$path" == "$exc" || "$path" == "$exc"/* ]]; then return 0 fi done return 1 } # ══════════════════════════════════════════════════════════════════════ # COLLECT ITEMS # ══════════════════════════════════════════════════════════════════════ collect_etc() { section_header "/etc Configuration" if [[ ! -d /etc ]]; then warn "/etc not found" return fi if is_excluded "/etc"; then log "Skipping /etc (excluded)" return fi local etc_size etc_size=$(du -sb /etc 2>/dev/null | awk '{print $1}' || echo "0") field "Size:" "$(human_bytes "$etc_size")" local etc_files etc_files=$(find /etc -type f 2>/dev/null | wc -l) field "Files:" "$etc_files" if [[ "$DRY_RUN" == "false" ]]; then cp -a /etc "$STAGING_DIR/etc" 2>/dev/null || warn "Some /etc files could not be copied" log "Collected /etc" else log "[DRY-RUN] Would collect /etc" fi } collect_crontabs() { section_header "User Crontabs" local crontab_dir="/var/spool/cron/crontabs" local count=0 if [[ -d "$crontab_dir" ]]; then count=$(find "$crontab_dir" -type f 2>/dev/null | wc -l) field "User crontabs:" "$count" if [[ "$VERBOSE" == "true" && "$count" -gt 0 ]]; then find "$crontab_dir" -type f 2>/dev/null | while IFS= read -r f; do printf " %s\n" "$(basename "$f")" done fi if [[ "$DRY_RUN" == "false" && "$count" -gt 0 ]]; then mkdir -p "$STAGING_DIR/crontabs" cp -a "$crontab_dir"/* "$STAGING_DIR/crontabs/" 2>/dev/null || warn "Some crontabs could not be copied" log "Collected user crontabs" fi else field "User crontabs:" "0 (${crontab_dir} not found)" fi # Root crontab via crontab -l if crontab -l &>/dev/null; then if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$STAGING_DIR/crontabs" crontab -l > "$STAGING_DIR/crontabs/root-crontab-l.txt" 2>/dev/null || true fi field "Root crontab:" "present" else field "Root crontab:" "none" fi if [[ "$DRY_RUN" == "true" && "$count" -gt 0 ]]; then log "[DRY-RUN] Would collect crontabs" fi } collect_package_list() { section_header "Package List" if command -v dpkg &>/dev/null; then local dpkg_count dpkg_count=$(dpkg -l 2>/dev/null | grep -c "^ii" || true) field "dpkg packages:" "$dpkg_count" if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$STAGING_DIR/packages" dpkg --get-selections > "$STAGING_DIR/packages/dpkg-selections.txt" 2>/dev/null || true dpkg -l > "$STAGING_DIR/packages/dpkg-list.txt" 2>/dev/null || true log "Collected dpkg package list" else log "[DRY-RUN] Would collect dpkg package list" fi fi if command -v rpm &>/dev/null; then local rpm_count rpm_count=$(rpm -qa 2>/dev/null | wc -l || echo "0") field "rpm packages:" "$rpm_count" if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$STAGING_DIR/packages" rpm -qa --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' > "$STAGING_DIR/packages/rpm-list.txt" 2>/dev/null || true log "Collected rpm package list" else log "[DRY-RUN] Would collect rpm package list" fi fi if ! command -v dpkg &>/dev/null && ! command -v rpm &>/dev/null; then log "No package manager detected (dpkg/rpm)" fi } collect_systemd_units() { section_header "Systemd Units" if ! command -v systemctl &>/dev/null; then log "systemd not available" return fi local enabled_count enabled_count=$(systemctl list-unit-files --state=enabled --no-legend 2>/dev/null | wc -l) field "Enabled units:" "$enabled_count" local custom_count=0 for unit_dir in /etc/systemd/system /etc/systemd/user; do if [[ -d "$unit_dir" ]]; then local dir_count dir_count=$(find "$unit_dir" -maxdepth 1 -name "*.service" -o -name "*.timer" 2>/dev/null | wc -l) custom_count=$((custom_count + dir_count)) fi done field "Custom unit files:" "$custom_count" if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$STAGING_DIR/systemd" systemctl list-unit-files --no-legend > "$STAGING_DIR/systemd/unit-files.txt" 2>/dev/null || true for unit_dir in /etc/systemd/system /etc/systemd/user; do if [[ -d "$unit_dir" ]]; then cp -a "$unit_dir" "$STAGING_DIR/systemd/" 2>/dev/null || true fi done log "Collected systemd units" else log "[DRY-RUN] Would collect systemd units" fi } collect_firewall_rules() { section_header "Firewall Rules" local fw_found=false if command -v iptables &>/dev/null; then fw_found=true local ipt_rules ipt_rules=$(iptables -S 2>/dev/null | wc -l || echo "0") field "iptables rules:" "$ipt_rules" if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$STAGING_DIR/firewall" iptables-save > "$STAGING_DIR/firewall/iptables.rules" 2>/dev/null || warn "Could not save iptables rules" log "Collected iptables rules" fi fi if command -v nft &>/dev/null; then fw_found=true local nft_tables nft_tables=$(nft list tables 2>/dev/null | wc -l || echo "0") field "nftables tables:" "$nft_tables" if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$STAGING_DIR/firewall" nft list ruleset > "$STAGING_DIR/firewall/nftables.rules" 2>/dev/null || warn "Could not save nftables rules" log "Collected nftables rules" fi fi if [[ "$fw_found" == "false" ]]; then log "No firewall tools detected (iptables, nftables)" elif [[ "$DRY_RUN" == "true" ]]; then log "[DRY-RUN] Would collect firewall rules" fi } collect_custom_includes() { if [[ ${#INCLUDE_PATHS[@]} -eq 0 ]]; then return fi section_header "Custom Includes" for inc_path in "${INCLUDE_PATHS[@]}"; do if [[ ! -e "$inc_path" ]]; then warn "Include path not found: $inc_path" continue fi local inc_size inc_size=$(du -sb "$inc_path" 2>/dev/null | awk '{print $1}' || echo "0") field "$inc_path:" "$(human_bytes "$inc_size")" if [[ "$DRY_RUN" == "false" ]]; then local dest_dir="$STAGING_DIR/custom${inc_path}" mkdir -p "$(dirname "$dest_dir")" cp -a "$inc_path" "$dest_dir" 2>/dev/null || warn "Could not copy $inc_path" fi done if [[ "$DRY_RUN" == "true" ]]; then log "[DRY-RUN] Would collect custom paths" else log "Collected custom paths" fi } # ══════════════════════════════════════════════════════════════════════ # CREATE TARBALL # ══════════════════════════════════════════════════════════════════════ create_tarball() { local timestamp hostname_val tarball_name tarball_path timestamp=$(date '+%Y%m%d-%H%M%S') hostname_val=$(hostname -s 2>/dev/null || hostname) tarball_name="config-backup-${hostname_val}-${timestamp}.tar.gz" tarball_path="${BACKUP_DIR}/${tarball_name}" section_header "Creating Backup" field "Output directory:" "$BACKUP_DIR" field "Tarball:" "$tarball_name" if [[ "$DRY_RUN" == "true" ]]; then # Estimate total size local est_size=0 if [[ -d /etc ]] && ! is_excluded "/etc"; then est_size=$((est_size + $(du -sb /etc 2>/dev/null | awk '{print $1}' || echo 0))) fi for inc_path in "${INCLUDE_PATHS[@]}"; do if [[ -e "$inc_path" ]]; then est_size=$((est_size + $(du -sb "$inc_path" 2>/dev/null | awk '{print $1}' || echo 0))) fi done field_color "Estimated size:" "${YELLOW}~$(human_bytes "$est_size") (uncompressed)${RESET}" echo "" echo -e " ${YELLOW}Dry-run mode — no backup created${RESET}" echo -e " Run with --force to create the backup" return fi # Create output directory mkdir -p "$BACKUP_DIR" || { err "Cannot create ${BACKUP_DIR}"; exit 1; } # Create tarball from staging local staging_size staging_size=$(du -sb "$STAGING_DIR" 2>/dev/null | awk '{print $1}' || echo "0") field "Staging size:" "$(human_bytes "$staging_size")" tar -czf "$tarball_path" -C "$STAGING_DIR" . 2>/dev/null || { err "Failed to create tarball"; exit 1; } # Validate tarball log "Validating tarball..." local file_count file_count=$(tar -tzf "$tarball_path" 2>/dev/null | wc -l) if [[ "$file_count" -eq 0 ]]; then err "Tarball validation failed — archive appears empty" exit 1 fi local tarball_size tarball_size=$(stat -c%s "$tarball_path" 2>/dev/null || echo "0") field_color "Status:" "${GREEN}Success${RESET}" field "Archive size:" "$(human_bytes "$tarball_size")" field "Files archived:" "$file_count" field "Location:" "$tarball_path" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors echo "" echo -e "${BOLD}Config Backup — $(hostname -f 2>/dev/null || hostname)${RESET}" if [[ "$DRY_RUN" == "true" ]]; then echo -e "Safety: ${YELLOW}dry-run (use --force to create backup)${RESET}" else echo -e "Safety: ${RED}LIVE — backup will be created${RESET}" fi echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" # Create staging directory for live runs if [[ "$DRY_RUN" == "false" ]]; then STAGING_DIR=$(mktemp -d "/tmp/config-backup-XXXXXX") trap cleanup_staging EXIT verbose "Staging directory: $STAGING_DIR" fi collect_etc collect_crontabs collect_package_list collect_systemd_units collect_firewall_rules collect_custom_includes create_tarball echo "" } main "$@"