diff --git a/README.md b/README.md index 5b19f36..a0178f4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Automatically clean up old WordPress backup files to free up disk space. **Features:** - Scans all WordPress installations in Plesk vhosts - Removes backups older than a specified number of days (default: 365 days) +- Always keeps a minimum number of most recent backups per domain (default: 3) +- Optional email report of backups found/removed per domain - Dry-run mode to preview deletions without removing files - Configurable retention period via environment variables - Safe deletion with proper error handling @@ -221,6 +223,17 @@ DAYS=180 ./remove-old-wordpress-backups/remove-wordpress-backups.sh # Preview custom retention period before deleting DAYS=180 ./remove-old-wordpress-backups/remove-wordpress-backups.sh --dry-run +# Always keep at least 5 most recent backups per domain, even if older than DAYS +MIN_KEEP=5 ./remove-old-wordpress-backups/remove-wordpress-backups.sh + +# Email a per-domain report of backups found/removed (requires the 'mail' command) +EMAIL_TO="admin@example.com" ./remove-old-wordpress-backups/remove-wordpress-backups.sh + +# Email the report via an SMTP relay when no local MTA is available (uses curl) +EMAIL_TO="admin@example.com" SMTP_SERVER="mail.example.com" SMTP_PORT=587 SMTP_SECURE=starttls \ + SMTP_AUTH_USER="relay-user" SMTP_AUTH_PASS="relay-pass" \ + ./remove-old-wordpress-backups/remove-wordpress-backups.sh + # Run with auto-update enabled AUTO_UPDATE=true ./remove-old-wordpress-backups/remove-wordpress-backups.sh @@ -327,8 +340,19 @@ pci-dss-scan\pci-dss-scan.bat https://example.com **Environment Variables:** - `DAYS` - Number of days to keep backups (default: `365`) - Example: `DAYS=180` keeps backups for 6 months +- `MIN_KEEP` - Minimum number of most recent backups to always keep per domain, regardless of age (default: `3`) + - Example: `MIN_KEEP=5` never deletes a domain's 5 newest backups even if older than `DAYS` - `DRY_RUN` - Set to `true` to enable dry-run mode (default: `false`) - Example: `DRY_RUN=true` previews deletions without removing files +- `EMAIL_TO` - Email address to receive the per-domain report (default: unset, no email sent) + - Uses the local `mail` command if available; falls back to the SMTP relay settings below via `curl` if not + - Report includes, per domain: backups found, backups removed (or would-remove in dry-run), and filenames removed +- `EMAIL_SUBJECT` - Subject line for the email report (default: `WordPress Backup Cleanup Report - `) +- `SMTP_SERVER` - SMTP relay hostname, used only if the local `mail` command is unavailable (default: unset) +- `SMTP_PORT` - SMTP relay port (default: `25`) +- `SMTP_AUTH_USER` / `SMTP_AUTH_PASS` - SMTP credentials, leave unset for an unauthenticated relay (default: unset) +- `SMTP_SECURE` - SMTP security: blank for plain, `ssl` for implicit TLS (typically port 465), `starttls` for explicit STARTTLS (typically port 587) (default: blank) +- `SMTP_FROM` - Sender address (default: `plesk-monitor@`) **Command-line Options:** - `--dry-run` or `-n` - Preview deletions without removing files diff --git a/remove-old-wordpress-backups/remove-wordpress-backups.sh b/remove-old-wordpress-backups/remove-wordpress-backups.sh index 15fef00..15395e1 100644 --- a/remove-old-wordpress-backups/remove-wordpress-backups.sh +++ b/remove-old-wordpress-backups/remove-wordpress-backups.sh @@ -5,6 +5,8 @@ # - Scans all WordPress installations in Plesk vhosts for backup files # - Removes backups older than specified retention period # - Configurable retention via DAYS environment variable (default: 365 days) +# - Keeps a minimum number of most recent backups per domain regardless of age +# - Optional email report summarizing backups found/removed per domain # - Dry-run mode to preview deletions without removing files # - Safe deletion with proper error handling and validation # - Detailed logging with timestamps @@ -13,7 +15,16 @@ # Usage: ./remove-wordpress-backups.sh [--dry-run] [--update|--self-update] or DAYS=180 ./remove-wordpress-backups.sh # Environment Variables: # DAYS - Number of days to keep backups (default: 365) +# MIN_KEEP - Minimum number of most recent backups to keep per domain (default: 3) # DRY_RUN - Set to "true" to enable dry-run mode (default: false) +# EMAIL_TO - Email address to send the per-domain report to (default: unset, no email sent) +# EMAIL_SUBJECT - Subject line for the email report (default: "WordPress Backup Cleanup Report - ") +# SMTP_SERVER - SMTP relay host, used via curl if local 'mail' command is unavailable (default: unset) +# SMTP_PORT - SMTP relay port (default: 25) +# SMTP_AUTH_USER - SMTP username, leave unset for unauthenticated relay (default: unset) +# SMTP_AUTH_PASS - SMTP password (default: unset) +# SMTP_SECURE - SMTP security: blank/"ssl"/"starttls" (default: blank/plain) +# SMTP_FROM - Sender address (default: plesk-monitor@) # AUTO_UPDATE - Set to "true" to enable automatic updates (default: false) # UPDATE_CHECK_INTERVAL - Hours between update checks (default: 24) # GITHUB_BRANCH - GitHub branch to update from (default: main) @@ -169,9 +180,19 @@ fi ## CONFIGURATION ### DAYS="${DAYS:-365}" +MIN_KEEP="${MIN_KEEP:-3}" BACKUP_PATH="/var/www/vhosts/*/wordpress-backups" FIND_CMD="/bin/find" RM_CMD="/bin/rm" +EMAIL_TO="${EMAIL_TO:-}" +EMAIL_SUBJECT="${EMAIL_SUBJECT:-}" +# SMTP fallback for servers without a local MTA (used only if 'mail' command is unavailable) +SMTP_SERVER="${SMTP_SERVER:-}" +SMTP_PORT="${SMTP_PORT:-25}" +SMTP_AUTH_USER="${SMTP_AUTH_USER:-}" +SMTP_AUTH_PASS="${SMTP_AUTH_PASS:-}" +SMTP_SECURE="${SMTP_SECURE:-}" +SMTP_FROM="${SMTP_FROM:-}" ### ## FUNCTIONS @@ -189,6 +210,12 @@ validate_configuration() { log_message "ERROR: DAYS must be a positive integer (provided: ${DAYS})" return 1 fi + + # Validate MIN_KEEP parameter is a non-negative integer + if ! echo "${MIN_KEEP}" | grep -qE '^[0-9]+$'; then + log_message "ERROR: MIN_KEEP must be a non-negative integer (provided: ${MIN_KEEP})" + return 1 + fi # Verify required commands exist if [ ! -x "${FIND_CMD}" ]; then @@ -200,10 +227,144 @@ validate_configuration() { log_message "ERROR: rm command not found at: ${RM_CMD}" return 1 fi + + # EMAIL_TO is optional, but if set we need a way to actually send it: + # either a local MTA via the 'mail' command, or SMTP relay settings for curl. + if [ -n "${EMAIL_TO}" ] && ! command -v mail >/dev/null 2>&1 && [ -z "${SMTP_SERVER}" ]; then + log_message "WARNING: EMAIL_TO is set but no 'mail' command or SMTP_SERVER was found. Report will not be emailed." + fi return 0 } +# Per-domain scan results, populated by scan_domains(). Parallel arrays indexed by domain. +DOMAIN_NAMES=() +DOMAIN_FOUND=() +DOMAIN_REMOVED_FILES=() # newline-separated basenames of files removed (or eligible, in dry-run) for that domain +TO_DELETE=() # full paths of every file eligible for deletion, across all domains + +# Function to scan every domain's backup directory: records how many backups +# exist, and which ones are eligible for deletion (older than DAYS, beyond the +# MIN_KEEP newest). +scan_domains() { + local dir domain total i removed_list + for dir in ${BACKUP_PATH}; do + [ -d "${dir}" ] || continue + domain=$(basename "$(dirname "${dir}")") + + # Newest-first list of backup files in this domain's directory + local files=() + while IFS= read -r line; do + files+=("${line#* }") + done < <(${FIND_CMD} "${dir}" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn) + + total=${#files[@]} + removed_list="" + + # Anything beyond the MIN_KEEP newest is eligible if it's also older than DAYS + if [ "${total}" -gt "${MIN_KEEP}" ]; then + for ((i = MIN_KEEP; i < total; i++)); do + if [ -n "$(${FIND_CMD} "${files[$i]}" -mtime +"${DAYS}" 2>/dev/null)" ]; then + TO_DELETE+=("${files[$i]}") + removed_list+="$(basename "${files[$i]}")"$'\n' + fi + done + fi + + DOMAIN_NAMES+=("${domain}") + DOMAIN_FOUND+=("${total}") + DOMAIN_REMOVED_FILES+=("${removed_list}") + done +} + +# Function to build the plain-text per-domain report used both on-screen and in the email +build_report() { + local action="Removed" + [ "${DRY_RUN}" = "true" ] && action="Would remove (dry-run)" + + local i domain found removed_files removed_count + for i in "${!DOMAIN_NAMES[@]}"; do + domain="${DOMAIN_NAMES[$i]}" + found="${DOMAIN_FOUND[$i]}" + removed_files="${DOMAIN_REMOVED_FILES[$i]}" + removed_count=0 + [ -n "${removed_files}" ] && removed_count=$(printf '%s' "${removed_files}" | grep -c .) + + echo "Domain: ${domain}" + echo " Backups found: ${found}" + echo " ${action}: ${removed_count}" + if [ "${removed_count}" -gt 0 ]; then + printf '%s' "${removed_files}" | sed '/^$/d;s/^/ - /' + fi + echo "" + done +} + +# Function to send the report via an SMTP relay using curl, for servers with no local MTA. +# SMTP_SECURE: blank for plain, "ssl" for implicit TLS (typically port 465), "starttls" for +# explicit STARTTLS (typically port 587) — same convention as monitor-aspnet.bat. +send_via_smtp() { + local subject="$1" body="$2" + local from="${SMTP_FROM:-plesk-monitor@$(hostname -f 2>/dev/null || hostname)}" + local scheme="smtp" + [ "${SMTP_SECURE}" = "ssl" ] && scheme="smtps" + + local msg_file + msg_file=$(mktemp) + { + echo "From: ${from}" + echo "To: ${EMAIL_TO}" + echo "Subject: ${subject}" + echo "Date: $(date -R)" + echo "" + echo "${body}" + } > "${msg_file}" + + local -a curl_args=(--silent --show-error + --url "${scheme}://${SMTP_SERVER}:${SMTP_PORT}" + --mail-from "${from}" --mail-rcpt "${EMAIL_TO}" + --upload-file "${msg_file}") + [ "${SMTP_SECURE}" = "starttls" ] && curl_args+=(--ssl-reqd) + [ -n "${SMTP_AUTH_USER}" ] && curl_args+=(--user "${SMTP_AUTH_USER}:${SMTP_AUTH_PASS}") + + local rc=0 + curl "${curl_args[@]}" || rc=$? + rm -f "${msg_file}" + return "${rc}" +} + +# Function to email the report when EMAIL_TO is configured. Prefers the local +# 'mail' command; falls back to a direct SMTP relay via curl when no local +# MTA is present but SMTP_SERVER is configured. +send_email_report() { + local body="$1" + + [ -n "${EMAIL_TO}" ] || return 0 + + local subject="${EMAIL_SUBJECT:-WordPress Backup Cleanup Report - $(hostname -s 2>/dev/null || hostname)}" + + if command -v mail >/dev/null 2>&1; then + if echo "${body}" | mail -s "${subject}" "${EMAIL_TO}"; then + log_message "Report emailed to ${EMAIL_TO}" + return 0 + fi + log_message "WARNING: 'mail' command failed to send report to ${EMAIL_TO}" + return 1 + fi + + if [ -n "${SMTP_SERVER}" ]; then + if send_via_smtp "${subject}" "${body}"; then + log_message "Report emailed to ${EMAIL_TO} via SMTP (${SMTP_SERVER}:${SMTP_PORT})" + return 0 + fi + log_message "WARNING: Failed to send email report to ${EMAIL_TO} via SMTP (${SMTP_SERVER}:${SMTP_PORT})" + return 1 + fi + + log_message "WARNING: Cannot send email report - no 'mail' command and no SMTP_SERVER configured." + return 1 +} + # Function to remove old WordPress backups remove_wordpress_backups() { log_message "============================================================================" @@ -213,6 +374,7 @@ remove_wordpress_backups() { log_message "MODE: DRY-RUN (no files will be deleted)" fi log_message "Retention period: ${DAYS} days" + log_message "Minimum backups kept per domain: ${MIN_KEEP}" log_message "Search path: ${BACKUP_PATH}" echo "" @@ -224,38 +386,43 @@ remove_wordpress_backups() { return 0 fi - log_message "Scanning for backup files older than ${DAYS} days..." - - # Count files before deletion - local file_count - file_count=$(${FIND_CMD} ${BACKUP_PATH} -type f -mtime +"${DAYS}" 2>/dev/null | wc -l) || file_count=0 - + log_message "Scanning for backup files older than ${DAYS} days (keeping ${MIN_KEEP} newest per domain)..." + + scan_domains + + local file_count=${#TO_DELETE[@]} + local report + report=$(build_report) + if [ "${file_count}" -eq 0 ]; then - log_message "No backup files older than ${DAYS} days found. Nothing to delete." + log_message "No eligible backup files found. Nothing to delete." + echo "" + echo "${report}" + send_email_report "${report}" return 0 fi log_message "Found ${file_count} backup file(s) to delete." + echo "" + echo "${report}" - # In dry-run mode, list files that would be deleted + # In dry-run mode, don't delete - just report what would happen if [ "${DRY_RUN}" = "true" ]; then - echo "" - log_message "Files that would be deleted (dry-run mode):" - log_message "------------------------------------------------------------" - ${FIND_CMD} ${BACKUP_PATH} -type f -mtime +"${DAYS}" -exec ls -lh {} \; 2>/dev/null | while read -r line; do - echo " $line" - done - echo "" log_message "Dry-run complete. ${file_count} file(s) would be deleted." log_message "Run without --dry-run flag to actually delete these files." + send_email_report "${report}" return 0 fi # Remove old backup files - if ${FIND_CMD} ${BACKUP_PATH} -type f -mtime +"${DAYS}" -exec ${RM_CMD} -f {} + 2>/dev/null; then + if printf '%s\0' "${TO_DELETE[@]}" | xargs -0 "${RM_CMD}" -f; then log_message "Successfully removed ${file_count} old backup file(s)." + send_email_report "${report}" else log_message "ERROR: Failed to remove some backup files. Check permissions." + send_email_report "${report} + +WARNING: one or more files could not be removed - check server permissions/logs." return 1 fi