a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
508 lines
18 KiB
Bash
508 lines
18 KiB
Bash
#!/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 <<EOF
|
|
${SCRIPT_NAME} — Snapshot system configs into a timestamped tarball
|
|
|
|
USAGE:
|
|
${SCRIPT_NAME} [OPTIONS]
|
|
|
|
OPTIONS:
|
|
--output-dir DIR Output directory (default: ${BACKUP_DIR})
|
|
--include PATH Additional path to include (can be repeated)
|
|
--exclude PATH Path to exclude (can be repeated)
|
|
--force Actually create backup (disables dry-run)
|
|
--verbose Enable debug output
|
|
--no-color Disable colored output
|
|
--help Show this help
|
|
|
|
WHAT IS BACKED UP:
|
|
/etc System configuration files
|
|
User crontabs From /var/spool/cron/crontabs/
|
|
Package lists dpkg/rpm package lists
|
|
Systemd units Custom unit files from /etc/systemd/
|
|
Firewall rules iptables/nftables rule dumps
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
BACKUP_DIR Output directory (default: /var/backups/config-snapshots)
|
|
DRY_RUN Dry-run mode (default: true)
|
|
COLOR Color mode: auto, always, never (default: auto)
|
|
|
|
EXAMPLES:
|
|
# Dry-run (show what would be backed up)
|
|
./config-backup.sh
|
|
|
|
# Create backup
|
|
sudo ./config-backup.sh --force
|
|
|
|
# Custom output directory
|
|
sudo ./config-backup.sh --force --output-dir /tmp/backups
|
|
|
|
# Include additional paths
|
|
sudo ./config-backup.sh --force --include /opt/myapp/config
|
|
|
|
# Exclude paths
|
|
sudo ./config-backup.sh --force --exclude /etc/ssl/private
|
|
EOF
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# ARGUMENT PARSING
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--output-dir)
|
|
BACKUP_DIR="$2"; shift 2 ;;
|
|
--include)
|
|
INCLUDE_PATHS+=("$2"); shift 2 ;;
|
|
--exclude)
|
|
EXCLUDE_PATHS+=("$2"); shift 2 ;;
|
|
--force)
|
|
DRY_RUN="false"; shift ;;
|
|
--verbose)
|
|
VERBOSE="true"; shift ;;
|
|
--no-color)
|
|
COLOR="never"; shift ;;
|
|
--help|-h)
|
|
setup_colors
|
|
usage
|
|
exit 0 ;;
|
|
*)
|
|
err "Unknown option: $1"
|
|
echo "Run ${SCRIPT_NAME} --help for usage" >&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 "$@"
|