#!/bin/bash # ============================================================================= # Added to git repository 07-26-2026 # NAS08 → NAS16 Sync Script # Source : NAS08 (192.168.150.35) /mnt/kingdezigns08/Public # Dest : NAS16 (192.168.150.40) /export/kingdezignsnas-16/Public # # Behavior: # - Copies only changed/new files from NAS08 to NAS16 # - Does NOT delete files on NAS16 that are missing from NAS08 # - Preserves file timestamps and symlinks # - Skips permission syncing (NFS all_squash incompatible with chmod) # - Skips directory timestamp syncing (NFS all_squash incompatible with utimes) # - Auto-corrects destination ownership/permissions before sync if needed # - Sends an HTML status email after every run # - Logs every run with timestamps # # Schedule via OMV Task Scheduler: 30 2 */2 * * # ============================================================================= # --- Configuration ----------------------------------------------------------- SOURCE="/mnt/kingdezigns08/Public/" DEST="/export/kingdezignsnas-16/Public/" LOG_DIR="/var/log/nas-sync" LOG_FILE="${LOG_DIR}/nas08_to_nas16_$(date +%Y-%m-%d).log" LOCK_FILE="/tmp/nas08_to_nas16.lock" REPORT_TO="rufus.king@kingdezigns.com" REPORT_FROM="nas-sync@kingdezigns16.local" NOW=$(date '+%Y-%m-%d %H:%M:%S') NFS_MOUNT="/mnt/kingdezigns08" DEST_EXPECTED_OWNER="www-data" DEST_EXPECTED_GROUP="ncshare" # --- HTML escape helper ------------------------------------------------------ html_escape() { sed 's/&/\&/g; s//\>/g; s/"/\"/g'; } # --- Safety Checks ----------------------------------------------------------- # Ensure log directory exists and is owned by the running user. mkdir -p "$LOG_DIR" if [ "$(stat -c '%U' "$LOG_DIR")" != "$(whoami)" ]; then sudo chown -R "$(whoami)":users "$LOG_DIR" 2>/dev/null || true fi # Prevent overlapping runs — but self-heal stale lock files. if [ -f "$LOCK_FILE" ]; then LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null) if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Sync already running as PID $LOCK_PID. Exiting." | tee -a "$LOG_FILE" exit 1 else echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: Stale lock file found (PID $LOCK_PID no longer running). Removing and continuing." | tee -a "$LOG_FILE" rm -f "$LOCK_FILE" fi fi echo $$ > "$LOCK_FILE" # Wait for NFS mount to be fully ready (up to 30 seconds) echo "[$(date '+%Y-%m-%d %H:%M:%S')] Checking NFS mount readiness..." | tee -a "$LOG_FILE" WAIT=0 until mountpoint -q "$NFS_MOUNT"; do sleep 5 WAIT=$((WAIT + 5)) if [ $WAIT -ge 30 ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: NFS mount $NFS_MOUNT not ready after 30 seconds. Aborting." | tee -a "$LOG_FILE" rm -f "$LOCK_FILE" exit 1 fi echo "[$(date '+%Y-%m-%d %H:%M:%S')] Waiting for NFS mount... (${WAIT}s)" | tee -a "$LOG_FILE" done echo "[$(date '+%Y-%m-%d %H:%M:%S')] NFS mount ready." | tee -a "$LOG_FILE" # Verify source is mounted and not empty if [ ! -d "$SOURCE" ] || [ -z "$(ls -A "$SOURCE" 2>/dev/null)" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Source '$SOURCE' is not mounted or is empty. Aborting to prevent data loss." | tee -a "$LOG_FILE" rm -f "$LOCK_FILE" exit 1 fi # Verify destination exists if [ ! -d "$DEST" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Destination '$DEST' does not exist. Aborting." | tee -a "$LOG_FILE" rm -f "$LOCK_FILE" exit 1 fi # --- Destination Permission Precheck ----------------------------------------- # NFS all_squash maps all writes to www-data:ncshare (anonuid=33, anongid=1001). # If the destination tree is not owned by www-data:ncshare, rsync will fail to # set permissions and timestamps. Self-correct before the sync runs so this # never requires manual intervention. PERM_CORRECTED=false echo "[$(date '+%Y-%m-%d %H:%M:%S')] Verifying destination ownership and permissions..." | tee -a "$LOG_FILE" DEST_OWNER=$(stat -c '%U' "$DEST" 2>/dev/null) DEST_GROUP=$(stat -c '%G' "$DEST" 2>/dev/null) if [ "$DEST_OWNER" != "$DEST_EXPECTED_OWNER" ] || [ "$DEST_GROUP" != "$DEST_EXPECTED_GROUP" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: Destination ownership is ${DEST_OWNER}:${DEST_GROUP} — expected ${DEST_EXPECTED_OWNER}:${DEST_EXPECTED_GROUP}. Correcting now..." | tee -a "$LOG_FILE" sudo chown -R "${DEST_EXPECTED_OWNER}:${DEST_EXPECTED_GROUP}" "$DEST" 2>&1 | tee -a "$LOG_FILE" sudo chmod -R 2770 "$DEST" 2>&1 | tee -a "$LOG_FILE" sudo setfacl -R -m "g:${DEST_EXPECTED_GROUP}:rwx" "$DEST" 2>&1 | tee -a "$LOG_FILE" sudo setfacl -R -d -m "g:${DEST_EXPECTED_GROUP}:rwx" "$DEST" 2>&1 | tee -a "$LOG_FILE" PERM_CORRECTED=true echo "[$(date '+%Y-%m-%d %H:%M:%S')] Destination permissions corrected successfully." | tee -a "$LOG_FILE" else echo "[$(date '+%Y-%m-%d %H:%M:%S')] Destination ownership OK (${DEST_OWNER}:${DEST_GROUP})." | tee -a "$LOG_FILE" fi # --- Run Sync ---------------------------------------------------------------- echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== NAS08 → NAS16 Sync Started =====" | tee -a "$LOG_FILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Source : $SOURCE" | tee -a "$LOG_FILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Dest : $DEST" | tee -a "$LOG_FILE" START_TIME=$(date +%s) # --archive : recursive, preserve symlinks, owner, group, devices # --no-perms : skip chmod — NFS all_squash prevents non-owner chmod # --omit-dir-times : skip directory utimes — NFS all_squash prevents non-owner utimes on dirs # File timestamps are still preserved exactly. # --update : skip files that are newer on destination RSYNC_OUTPUT=$(rsync \ --archive \ --no-perms \ --omit-dir-times \ --update \ --human-readable \ --itemize-changes \ --stats \ --exclude='.Trash-1000/' \ "$SOURCE" \ "$DEST" 2>&1) RSYNC_EXIT=$? END_TIME=$(date +%s) ELAPSED=$(( END_TIME - START_TIME )) ELAPSED_FMT=$(printf '%dm %ds' $((ELAPSED/60)) $((ELAPSED%60))) # Log full rsync output echo "$RSYNC_OUTPUT" >> "$LOG_FILE" # --- Parse rsync stats ------------------------------------------------------- FILES_TOTAL=$(echo "$RSYNC_OUTPUT" | grep "Number of files:" | grep -oP '[\d,]+' | head -1 | tr -d ',') FILES_TRANSFERRED=$(echo "$RSYNC_OUTPUT" | grep "Number of regular files transferred:" | grep -oP '[\d,]+' | head -1 | tr -d ',') FILES_CREATED=$(echo "$RSYNC_OUTPUT" | grep "Number of created files:" | grep -oP '[\d,]+' | head -1 | tr -d ',') TOTAL_SIZE=$(echo "$RSYNC_OUTPUT" | grep "Total file size:" | grep -oP '[0-9.,]+\s*[KMGT]?' | head -1) TRANSFERRED_SIZE=$(echo "$RSYNC_OUTPUT" | grep "Total transferred file size:" | grep -oP '[0-9.,]+\s*[KMGT]?' | head -1) TRANSFER_RATE=$(echo "$RSYNC_OUTPUT" | grep "sent.*bytes.*received" | grep -oP '[\d.,]+\s*[KMGT]?bytes/sec' | head -1) FILES_TOTAL=${FILES_TOTAL:-0} FILES_TRANSFERRED=${FILES_TRANSFERRED:-0} FILES_CREATED=${FILES_CREATED:-0} TOTAL_SIZE=${TOTAL_SIZE:-"unknown"} TRANSFERRED_SIZE=${TRANSFERRED_SIZE:-"0"} TRANSFER_RATE=${TRANSFER_RATE:-"unknown"} # --- Parse per-folder activity ----------------------------------------------- declare -A FOLDER_COUNTS declare -A FOLDER_ERRORS while IFS= read -r LINE; do if echo "$LINE" | grep -qE '^>f'; then FILEPATH=$(echo "$LINE" | sed 's/^[^ ]* //') TOP=$(echo "$FILEPATH" | cut -d'/' -f1) if [ -n "$TOP" ]; then FOLDER_COUNTS["$TOP"]=$(( ${FOLDER_COUNTS["$TOP"]:-0} + 1 )) fi fi if echo "$LINE" | grep -qE "failed:|Permission denied|No such file|error:"; then ERR_FOLDER=$(echo "$LINE" | grep -oP '"[^"]*"' | head -1 | tr -d '"' | xargs basename 2>/dev/null || echo "unknown") FOLDER_ERRORS["$ERR_FOLDER"]=$(echo "$LINE" | html_escape) fi done <<< "$RSYNC_OUTPUT" # Build folder activity rows FOLDER_ROWS="" FOLDERS_WITH_CHANGES=0 FOLDERS_WITH_ERRORS=0 for FOLDER in "${!FOLDER_COUNTS[@]}"; do COUNT=${FOLDER_COUNTS[$FOLDER]} FOLDERS_WITH_CHANGES=$((FOLDERS_WITH_CHANGES + 1)) FOLDER_ESC=$(echo "$FOLDER" | html_escape) FOLDER_ROWS+=" 📁 ${FOLDER_ESC} ${COUNT} file(s) updated " done for FOLDER in "${!FOLDER_ERRORS[@]}"; do FOLDERS_WITH_ERRORS=$((FOLDERS_WITH_ERRORS + 1)) FOLDER_ESC=$(echo "$FOLDER" | html_escape) ERR_ESC=${FOLDER_ERRORS[$FOLDER]} FOLDER_ROWS+=" ⚠ ${FOLDER_ESC} ${ERR_ESC} " done if [ -z "$FOLDER_ROWS" ]; then FOLDER_ROWS=" No folder activity — all files are already up to date. " fi # --- Determine overall status ------------------------------------------------ if [ $RSYNC_EXIT -eq 0 ] && [ "$FOLDERS_WITH_ERRORS" -eq 0 ]; then OVERALL_STATUS="SUCCESS" BANNER_BG="#1a7f4b" BANNER_ICON="✅" BANNER_MSG="Sync completed successfully — no errors detected." STATUS_BADGE_BG="#1a7f4b" CARD_HEADER_BG="#f0fdf4" elif [ "$FOLDERS_WITH_ERRORS" -gt 0 ]; then OVERALL_STATUS="WARNING" BANNER_BG="#b45309" BANNER_ICON="⚠️" BANNER_MSG="Sync completed with warnings — some folders had issues." STATUS_BADGE_BG="#b45309" CARD_HEADER_BG="#fffbeb" else OVERALL_STATUS="FAILED" BANNER_BG="#b91c1c" BANNER_ICON="🚨" BANNER_MSG="Sync failed — check the log for details." STATUS_BADGE_BG="#b91c1c" CARD_HEADER_BG="#fff1f2" fi # Include permission correction notice in subject if it ran PERM_NOTE="" if [ "$PERM_CORRECTED" = true ]; then PERM_NOTE=" [permissions auto-corrected]" fi SUBJECT="[NAS Sync] NAS08 → NAS16 — ${OVERALL_STATUS}${PERM_NOTE} — ${NOW}" # --- Log result -------------------------------------------------------------- if [ $RSYNC_EXIT -eq 0 ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Sync Completed Successfully =====" | tee -a "$LOG_FILE" else echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: rsync exited with code $RSYNC_EXIT. Check log: $LOG_FILE" | tee -a "$LOG_FILE" fi # --- Build HTML email -------------------------------------------------------- RAW_LOG_ESC=$(echo "$RSYNC_OUTPUT" | html_escape) # Permission correction notice block (only shown if correction ran) if [ "$PERM_CORRECTED" = true ]; then PERM_NOTICE_BLOCK="
⚠ Destination Permissions Auto-Corrected
Destination ownership was not www-data:ncshare. chown, chmod, and setfacl were applied automatically before the sync ran.
" else PERM_NOTICE_BLOCK="" fi HTML=$(cat <
NAS File Sync Monitor
🔄 NAS08 → NAS16
${NOW}  |  Schedule: every other day at 2:30 AM
${BANNER_ICON} ${OVERALL_STATUS}
${BANNER_ICON}  ${BANNER_MSG}
📄 Sync Results NAS08 Public → NAS16 Public ${OVERALL_STATUS}
${PERM_NOTICE_BLOCK}
Scanned
${FILES_TOTAL}
Transferred
${FILES_TRANSFERRED}
New Files
${FILES_CREATED}
Data Moved
${TRANSFERRED_SIZE}
Duration
${ELAPSED_FMT}
Source NAS08 (192.168.150.35) → ${SOURCE}
Destination NAS16 (192.168.150.40) → ${DEST}
Transfer Rate ${TRANSFER_RATE}
Total Source ${TOTAL_SIZE}
▶ Folder Activity  —  ${FOLDERS_WITH_CHANGES} updated  |  ${FOLDERS_WITH_ERRORS} error(s)
${FOLDER_ROWS}
Folder Status
▶ Full rsync Log  —  click to expand
${RAW_LOG_ESC}
📋 Report Summary
Host NAS16 (kingdezigns16)
Run Time ${NOW}
rsync Exit Code ${RSYNC_EXIT}
Permissions Auto-Corrected ${PERM_CORRECTED}
Folders Updated ${FOLDERS_WITH_CHANGES}
Folder Errors ${FOLDERS_WITH_ERRORS}
Log File ${LOG_FILE}
Automated NAS Sync Monitor  |  NAS16  |  KingDezigns Infrastructure
HTMLEOF ) # --- Send email -------------------------------------------------------------- send_email() { if command -v sendmail &>/dev/null; then { 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"; } | sendmail -t echo "[$(date '+%Y-%m-%d %H:%M:%S')] Report sent via sendmail to ${REPORT_TO}" | tee -a "$LOG_FILE" elif command -v mailx &>/dev/null; then echo "$HTML" | mailx -a "Content-Type: text/html" -s "$SUBJECT" "$REPORT_TO" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Report sent via mailx to ${REPORT_TO}" | tee -a "$LOG_FILE" elif command -v mail &>/dev/null; then echo "$HTML" | mail -a "Content-Type: text/html; charset=UTF-8" -s "$SUBJECT" "$REPORT_TO" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Report sent via mail to ${REPORT_TO}" | tee -a "$LOG_FILE" else echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: No mail transport found." | tee -a "$LOG_FILE" FALLBACK="/tmp/nas_sync_report_$(date +%Y%m%d_%H%M%S).html" echo "$HTML" > "$FALLBACK" echo "[$(date '+%Y-%m-%d %H:%M:%S')] HTML report saved to: $FALLBACK" | tee -a "$LOG_FILE" fi } send_email # --- Cleanup ----------------------------------------------------------------- rm -f "$LOCK_FILE" # Rotate logs older than 30 days find "$LOG_DIR" -name "nas08_to_nas16_*.log" -mtime +30 -delete echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done — Status: ${OVERALL_STATUS} | Transferred: ${FILES_TRANSFERRED} file(s) | Duration: ${ELAPSED_FMT}" | tee -a "$LOG_FILE" exit $RSYNC_EXIT