#!/bin/bash # ============================================================================= # NAS16 System Backup Script # ============================================================================= # Backs up OMV config, system /etc, web server configs (Apache/PHP), # MariaDB/MySQL databases, Webmin config, and scheduled jobs. # Writes a single dated .tar.gz archive to the NAS data drive. # Retains the last 10 backups (~30 days), auto-removes older ones. # Sends an HTML email report on completion (via OMV Postfix / Gmail relay). # # NOTE: Website files stored on NAS drives are NOT backed up here — # they are covered separately via NAS-to-NAS redundancy. # # Schedule via OMV Scheduled Jobs (cron): # 0 2 */3 * * (2:00 AM every 3 days) # # Backup destination: /export/kingdezignsnas-16/Backups/NAS16/ # Script location: /usr/scripts/omv/nas16-backup.sh # ============================================================================= set -uo pipefail # ----------------------------------------------------------------------------- # CONFIGURATION # ----------------------------------------------------------------------------- HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS16") REPORT_TO="rufus.king@kingdezigns.com" REPORT_FROM="backup-monitor@nas16.local" # Display only — Postfix uses your Gmail relay BACKUP_ROOT="/export/kingdezignsnas-16/Backups/NAS16" TMP_BASE="/export/kingdezignsnas-16/Backups/.tmp" DATE=$(date +%Y-%m-%d) TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") ARCHIVE_NAME="nas16-backup-${DATE}.tar.gz" TMP_DIR="${TMP_BASE}/nas16-backup-${TIMESTAMP}" RETAIN_COUNT=10 LOG_FILE="${BACKUP_ROOT}/backup.log" # MariaDB/MySQL connection — uses OS root auth (no password needed when run as root) DB_BACKUP_DIR_NAME="databases" # ----------------------------------------------------------------------------- # STATUS TRACKING # Each section appends a row to SECTION_ROWS_HTML and sets OVERALL_STATUS. # OVERALL_STATUS: SUCCESS | WARNING | FAILED # ----------------------------------------------------------------------------- OVERALL_STATUS="SUCCESS" SECTION_ROWS_HTML="" WARNING_COUNT=0 ERROR_COUNT=0 # Append a result row to the section table # Usage: add_section_row "Section name" "STATUS" "Detail message" # STATUS: OK | WARNING | ERROR | SKIPPED add_section_row() { local section="$1" status="$2" detail="$3" case "$status" in OK) BADGE="✓ OK" ;; WARNING) BADGE="⚠ WARNING" WARNING_COUNT=$((WARNING_COUNT + 1)) [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" ;; ERROR) BADGE="✗ ERROR" ERROR_COUNT=$((ERROR_COUNT + 1)) OVERALL_STATUS="FAILED" ;; SKIPPED) BADGE="— SKIPPED" ;; esac SECTION_ROWS_HTML+=" $section $BADGE $detail " } # ----------------------------------------------------------------------------- # LOGGING # ----------------------------------------------------------------------------- log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } log_section() { echo "" | tee -a "$LOG_FILE" echo "------------------------------------------------------------" | tee -a "$LOG_FILE" log "$1" echo "------------------------------------------------------------" | tee -a "$LOG_FILE" } # ----------------------------------------------------------------------------- # PRE-FLIGHT CHECKS # ----------------------------------------------------------------------------- log_section "NAS16 Backup Starting" if [ ! -d "/export/kingdezignsnas-16" ]; then log "ERROR: /export/kingdezignsnas-16 is not mounted or accessible. Aborting." add_section_row "Pre-flight check" "ERROR" "/export/kingdezignsnas-16 not mounted or accessible" # Send failure email immediately and exit OVERALL_STATUS="FAILED" # (email block at bottom handles this via trap — see END section) exit 1 fi mkdir -p "$BACKUP_ROOT" mkdir -p "$TMP_DIR" log "Temporary working directory: $TMP_DIR" # ----------------------------------------------------------------------------- # SECTION 1: OMV CONFIGURATION # ----------------------------------------------------------------------------- log_section "Backing up OMV configuration" OMV_DIR="${TMP_DIR}/omv-config" mkdir -p "$OMV_DIR" OMV_STATUS="OK" OMV_DETAIL="" # omv-confbak is optional — if present, export the structured XML as a bonus. # The raw config.xml copy below is the primary backup and is fully sufficient on its own. if command -v omv-confbak &>/dev/null; then if omv-confbak "${OMV_DIR}/omv-config.xml" 2>/dev/null; then log "omv-confbak completed successfully" OMV_DETAIL="omv-confbak exported + raw config.xml copied" else log "WARNING: omv-confbak failed or returned non-zero — raw config.xml still captured" OMV_DETAIL="omv-confbak failed (non-fatal) + raw config.xml copied" fi else log "omv-confbak not present — using raw config.xml (this is normal)" fi if [ -f /etc/openmediavault/config.xml ]; then cp /etc/openmediavault/config.xml "${OMV_DIR}/config.xml.raw" log "Raw OMV config.xml copied" [[ -z "$OMV_DETAIL" ]] && OMV_DETAIL="raw config.xml copied" else log "WARNING: /etc/openmediavault/config.xml not found — OMV config not backed up" OMV_STATUS="WARNING" OMV_DETAIL="config.xml not found at /etc/openmediavault/config.xml" fi add_section_row "OMV Configuration" "$OMV_STATUS" "$OMV_DETAIL" # ----------------------------------------------------------------------------- # SECTION 2: SYSTEM /etc # ----------------------------------------------------------------------------- log_section "Backing up /etc (system configuration)" ETC_DIR="${TMP_DIR}/etc" mkdir -p "$ETC_DIR" if rsync -a --quiet \ --exclude='/etc/mtab' \ --exclude='/etc/fstab.d' \ --exclude='/etc/blkid.tab' \ /etc/ "${ETC_DIR}/" 2>/dev/null; then log "/etc backed up successfully" add_section_row "System /etc" "OK" "rsync completed successfully" else log "WARNING: /etc rsync encountered errors" add_section_row "System /etc" "WARNING" "rsync completed with errors" fi # ----------------------------------------------------------------------------- # SECTION 3: APACHE CONFIGURATION # ----------------------------------------------------------------------------- log_section "Backing up Apache configuration" APACHE_DIR="${TMP_DIR}/apache" mkdir -p "$APACHE_DIR" APACHE_WARN=0 for DIR in /etc/apache2/sites-available /etc/apache2/sites-enabled \ /etc/apache2/mods-enabled /etc/apache2/conf-available \ /etc/apache2/conf-enabled /etc/apache2; do if [ -d "$DIR" ]; then rsync -a --quiet "$DIR/" "${APACHE_DIR}/$(basename "$DIR")/" \ && log "Apache $(basename "$DIR") backed up" \ || { log "WARNING: Apache $(basename "$DIR") rsync encountered errors"; APACHE_WARN=$((APACHE_WARN+1)); } else log "WARNING: $DIR not found — skipping" APACHE_WARN=$((APACHE_WARN+1)) fi done if [ -f /etc/apache2/apache2.conf ]; then cp /etc/apache2/apache2.conf "${APACHE_DIR}/apache2.conf" log "apache2.conf copied" fi if [ "$APACHE_WARN" -eq 0 ]; then add_section_row "Apache Configuration" "OK" "All config directories backed up" else add_section_row "Apache Configuration" "WARNING" "$APACHE_WARN director(ies) missing or failed" fi # ----------------------------------------------------------------------------- # SECTION 4: PHP CONFIGURATION # ----------------------------------------------------------------------------- log_section "Backing up PHP configuration" PHP_DIR="${TMP_DIR}/php" mkdir -p "$PHP_DIR" PHP_FOUND=0 PHP_VERSIONS="" PHP_WARN=0 for PHP_VER_DIR in /etc/php/*/; do if [ -d "$PHP_VER_DIR" ]; then PHP_VER=$(basename "$PHP_VER_DIR") rsync -a --quiet "$PHP_VER_DIR" "${PHP_DIR}/${PHP_VER}/" \ && log "PHP ${PHP_VER} config backed up" \ || { log "WARNING: PHP ${PHP_VER} rsync encountered errors"; PHP_WARN=$((PHP_WARN+1)); } PHP_FOUND=1 PHP_VERSIONS="${PHP_VERSIONS} ${PHP_VER}" fi done if [ "$PHP_FOUND" -eq 0 ]; then log "WARNING: No PHP config directories found under /etc/php/ — skipping" add_section_row "PHP Configuration" "SKIPPED" "No PHP installations found under /etc/php/" elif [ "$PHP_WARN" -gt 0 ]; then add_section_row "PHP Configuration" "WARNING" "Versions found:${PHP_VERSIONS} — $PHP_WARN version(s) had rsync errors" else add_section_row "PHP Configuration" "OK" "Versions backed up:${PHP_VERSIONS}" fi # ----------------------------------------------------------------------------- # SECTION 5: MARIADB / MYSQL DATABASES # ----------------------------------------------------------------------------- log_section "Backing up MariaDB/MySQL databases" DB_DIR="${TMP_DIR}/${DB_BACKUP_DIR_NAME}" mkdir -p "$DB_DIR" DB_COUNT=0 DB_FAIL=0 if command -v mysqldump &>/dev/null; then DB_CLIENT="mysqldump" elif command -v mariadb-dump &>/dev/null; then DB_CLIENT="mariadb-dump" else log "WARNING: mysqldump/mariadb-dump not found — skipping database backup" DB_CLIENT="" fi if [ -n "$DB_CLIENT" ]; then DB_LIST=$(mysql --defaults-file=/etc/mysql/debian.cnf \ -e "SHOW DATABASES;" 2>/dev/null \ | grep -Ev "^(Database|information_schema|performance_schema|sys|mysql)$" \ || true) if [ -z "$DB_LIST" ]; then log "No user databases found — nothing to dump" add_section_row "MariaDB / MySQL" "SKIPPED" "No user databases found" else while IFS= read -r DB_NAME; do [ -z "$DB_NAME" ] && continue $DB_CLIENT \ --defaults-file=/etc/mysql/debian.cnf \ --single-transaction \ --routines \ --triggers \ --events \ "$DB_NAME" > "${DB_DIR}/${DB_NAME}.sql" \ && { log "Database dumped: ${DB_NAME}.sql"; DB_COUNT=$((DB_COUNT+1)); } \ || { log "WARNING: dump failed for database: ${DB_NAME}"; DB_FAIL=$((DB_FAIL+1)); } done <<< "$DB_LIST" log "Total databases dumped: ${DB_COUNT}" gzip "${DB_DIR}"/*.sql 2>/dev/null && log "SQL dumps compressed" || true if [ "$DB_FAIL" -eq 0 ]; then add_section_row "MariaDB / MySQL" "OK" "$DB_COUNT database(s) dumped and compressed" else add_section_row "MariaDB / MySQL" "WARNING" "$DB_COUNT dumped · $DB_FAIL failed" fi fi else add_section_row "MariaDB / MySQL" "SKIPPED" "mysqldump / mariadb-dump not found" fi # ----------------------------------------------------------------------------- # SECTION 6: WEBMIN CONFIGURATION # ----------------------------------------------------------------------------- log_section "Backing up Webmin configuration" WEBMIN_DIR="${TMP_DIR}/webmin" mkdir -p "$WEBMIN_DIR" if [ -d /etc/webmin ]; then if rsync -a --quiet /etc/webmin/ "${WEBMIN_DIR}/" 2>/dev/null; then log "Webmin config backed up" add_section_row "Webmin Configuration" "OK" "rsync from /etc/webmin completed" else log "WARNING: Webmin rsync encountered errors" add_section_row "Webmin Configuration" "WARNING" "rsync completed with errors" fi else log "WARNING: /etc/webmin not found — skipping (Webmin may not be installed)" add_section_row "Webmin Configuration" "SKIPPED" "/etc/webmin not found — Webmin may not be installed" fi # ----------------------------------------------------------------------------- # SECTION 7: CRONTAB & SCHEDULED JOBS # ----------------------------------------------------------------------------- log_section "Backing up crontab and scheduled jobs" CRON_DIR="${TMP_DIR}/cron" mkdir -p "$CRON_DIR" CRON_WARN=0 crontab -l > "${CRON_DIR}/root-crontab.txt" 2>/dev/null \ && log "Root crontab saved" \ || { log "No root crontab found"; CRON_WARN=$((CRON_WARN+1)); } if [ -d /var/spool/cron/crontabs ]; then cp -r /var/spool/cron/crontabs/ "${CRON_DIR}/crontabs/" \ && log "All crontabs copied" \ || { log "WARNING: crontabs copy failed"; CRON_WARN=$((CRON_WARN+1)); } fi if [ -d /etc/cron.d ]; then cp -r /etc/cron.d/ "${CRON_DIR}/cron.d/" \ && log "cron.d copied" \ || { log "WARNING: cron.d copy failed"; CRON_WARN=$((CRON_WARN+1)); } fi if [ "$CRON_WARN" -eq 0 ]; then add_section_row "Crontab / Scheduled Jobs" "OK" "root crontab, crontabs/, cron.d/ saved" else add_section_row "Crontab / Scheduled Jobs" "WARNING" "$CRON_WARN item(s) missing or failed to copy" fi # ----------------------------------------------------------------------------- # SECTION 8: BACKUP MANIFEST # ----------------------------------------------------------------------------- log_section "Generating backup manifest" APACHE_VER=$(apache2 -v 2>/dev/null | grep "Server version" | awk '{print $3}' || echo "Unknown") PHP_VER_RUNTIME=$(php -r 'echo PHP_VERSION;' 2>/dev/null || echo "Unknown") DB_VER=$(mysql --defaults-file=/etc/mysql/debian.cnf \ -e "SELECT VERSION();" 2>/dev/null | tail -1 || echo "Unknown") WEBMIN_VER=$(dpkg -l webmin 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown") OMV_VER=$(dpkg -l openmediavault 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown") KERNEL_VER=$(uname -r) HOST_IP=$(hostname -I | awk '{print $1}') MANIFEST="${TMP_DIR}/MANIFEST.txt" cat > "$MANIFEST" </dev/null; then log "Archive created: ${BACKUP_ROOT}/${ARCHIVE_NAME}" ARCHIVE_SIZE=$(du -sh "${BACKUP_ROOT}/${ARCHIVE_NAME}" | cut -f1) log "Archive size: ${ARCHIVE_SIZE}" add_section_row "Archive Creation" "OK" "${ARCHIVE_NAME} · ${ARCHIVE_SIZE}" else log "ERROR: Archive creation failed" ARCHIVE_SIZE="N/A" add_section_row "Archive Creation" "ERROR" "tar failed — archive not created" rm -rf "$TMP_DIR" OVERALL_STATUS="FAILED" fi # ----------------------------------------------------------------------------- # SECTION 10: CLEANUP TEMP FILES # ----------------------------------------------------------------------------- log_section "Cleaning up temporary files" if rm -rf "$TMP_DIR" 2>/dev/null; then log "Temporary directory removed" else log "WARNING: Failed to remove temp directory: $TMP_DIR" fi rmdir "$TMP_BASE" 2>/dev/null && log ".tmp directory removed (empty)" || true # ----------------------------------------------------------------------------- # SECTION 11: ROTATE OLD BACKUPS # ----------------------------------------------------------------------------- log_section "Rotating old backups (keeping last ${RETAIN_COUNT})" BACKUP_LIST=$(ls -1t "${BACKUP_ROOT}"/nas16-backup-*.tar.gz 2>/dev/null || true) BACKUP_COUNT=$(echo "$BACKUP_LIST" | grep -c . || true) DELETED_COUNT=0 if [ "$BACKUP_COUNT" -gt "$RETAIN_COUNT" ]; then DELETE_LIST=$(echo "$BACKUP_LIST" | tail -n +$((RETAIN_COUNT + 1))) while IFS= read -r OLD_BACKUP; do rm -f "$OLD_BACKUP" log "Removed old backup: $(basename "$OLD_BACKUP")" DELETED_COUNT=$((DELETED_COUNT+1)) done <<< "$DELETE_LIST" add_section_row "Backup Rotation" "OK" "Kept $RETAIN_COUNT most recent · removed $DELETED_COUNT old archive(s)" else log "Backup count (${BACKUP_COUNT}) within retention limit — no rotation needed" add_section_row "Backup Rotation" "OK" "$BACKUP_COUNT / $RETAIN_COUNT slots used — no rotation needed" fi # ----------------------------------------------------------------------------- # DONE — log summary # ----------------------------------------------------------------------------- log_section "Backup Complete" log "Archive: ${BACKUP_ROOT}/${ARCHIVE_NAME}" log "Size: ${ARCHIVE_SIZE:-N/A}" log "Status: ${OVERALL_STATUS}" log "" # ----------------------------------------------------------------------------- # BUILD & SEND HTML EMAIL (pattern: nas08_zfs_scrub.sh) # ----------------------------------------------------------------------------- # Overall banner & badge case "$OVERALL_STATUS" in SUCCESS) BADGE_HTML="
✅ SUCCESS
" BANNER_HTML="✅  Backup completed successfully — no action required." ;; WARNING) BADGE_HTML="
⚠️ WARNING
" BANNER_HTML="⚠️  Backup completed with warnings — review recommended." ;; FAILED) BADGE_HTML="
🚨 FAILED
" BANNER_HTML="🚨  Backup failed — immediate attention required!" ;; esac # Archive info card — shown only when archive was created if [[ "${ARCHIVE_SIZE:-N/A}" != "N/A" ]]; then ARCHIVE_CARD="
🗜️ Archive
Filename
$ARCHIVE_NAME
Size
$ARCHIVE_SIZE
Retained
$BACKUP_COUNT / $RETAIN_COUNT
" else ARCHIVE_CARD="" fi # Software versions card VERSIONS_CARD="
🖥️ System Versions
OMV ${OMV_VER} Kernel ${KERNEL_VER}
Apache ${APACHE_VER} PHP ${PHP_VER_RUNTIME}
MariaDB / MySQL ${DB_VER} Webmin ${WEBMIN_VER}
IP Address ${HOST_IP}
" # Compose full HTML email HTML_BODY=$(cat <
$BANNER_HTML $ARCHIVE_CARD $VERSIONS_CARD
System Backup Monitor
💾 $HOSTNAME_LABEL
$REPORT_TIME  |  7 sections · $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)
$BADGE_HTML
📋 Backup Section Results
$SECTION_ROWS_HTML
Section Status Detail
📊 Run Summary
Host $HOSTNAME_LABEL
Run Time $REPORT_TIME
Archive ${ARCHIVE_NAME}
Archive Size ${ARCHIVE_SIZE:-N/A}
Backups on Disk $BACKUP_COUNT of $RETAIN_COUNT max retained
Warnings $WARNING_COUNT
Errors $ERROR_COUNT
Overall Status $OVERALL_STATUS
Automated Backup Monitor  |  $HOSTNAME_LABEL  |  KingDezigns Infrastructure
EOF ) # Send email via OMV Postfix (Gmail relay) SUBJECT="[Backup] $HOSTNAME_LABEL — $OVERALL_STATUS | $REPORT_TIME" { echo "To: $REPORT_TO" echo "From: $REPORT_FROM" echo "Subject: $SUBJECT" echo "MIME-Version: 1.0" echo "Content-Type: text/html; charset=UTF-8" echo "" echo "$HTML_BODY" } | sendmail -t log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" log "===== NAS16 Backup script finished ====="