#!/bin/bash
# =============================================================================
# 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; 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 |
Status |
${FOLDER_ROWS}
▶ 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