Private
Public Access
1
0
Files
linux_patch_manager/scripts/backup.sh
Echo 297bf1bd83 feat(M11+M12): Email notifications, audit hardening, deployment packaging, backup/DR, integration testing
M11 - Email Notifications + Audit Logging Hardening:
- Email notifier (lettre crate) with templates for patch failure, job completion, maintenance reminders
- Audit log hash chaining (prev_hash + row_hash) for tamper-evident logging
- Periodic + on-demand audit integrity verification
- Audit logging for all config changes and certificate operations
- Frontend: email settings integration, audit integrity verification action

M12 - Deployment Packaging, Backup/DR, Integration Testing:
- scripts/backup.sh: Nightly pg_dump, CA backup (GPG), config backup (secrets excluded unless encrypted)
- scripts/setup.sh: Enhanced with backup dir, seed migration, backup cron, systemd target install
- systemd units: Restart=always, WatchdogSec, ReadWritePaths, security hardening
- systemd/patch-manager.target: Service target for coordinated lifecycle
- docs/runbooks/restore.md: Full DR runbook with RPO 24h / RTO 4h targets
- scripts/integration-test.sh: 9 test suites covering full API lifecycle
- scripts/performance-test.sh: NFR validation (dashboard <5s, CIDR /22 <10s, API <2s)
- docs/security-review.md: Comprehensive security control verification
- docs/compliance-mapping.md: HIPAA (6 sections) + PCI-DSS v4.0 (9 requirements) mapped
2026-04-24 00:45:51 +00:00

188 lines
7.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# Linux Patch Manager — Nightly Backup Script
# =============================================================================
# Run via cron or systemd timer.
# Performs:
# 1. pg_dump of the patch_manager database
# 2. CA material backup (/etc/patch-manager/ca/)
# 3. Config backup (/etc/patch-manager/config.toml, jwt keys, tls certs)
# - Secrets are excluded unless GPG_RECIPIENT is set for encryption
# =============================================================================
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $(date +%Y-%m-%dT%H:%M:%S) $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $(date +%Y-%m-%dT%H:%M:%S) $*"; }
error() { echo -e "${RED}[ERROR]${NC} $(date +%Y-%m-%dT%H:%M:%S) $*" >&2; }
# ---------------------------------------------------------------------------
# Configuration (override via environment or config file)
# ---------------------------------------------------------------------------
BACKUP_DIR="${BACKUP_DIR:-/var/backups/patch-manager}"
DB_NAME="${DB_NAME:-patch_manager}"
DB_USER="${DB_USER:-patch_manager}"
CONFIG_DIR="/etc/patch-manager"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
GPG_RECIPIENT="${GPG_RECIPIENT:-}" # Set to GPG key ID to encrypt secret backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
[[ $EUID -ne 0 ]] && error "This script must be run as root."
command -v pg_dump >/dev/null 2>&1 || error "pg_dump not found. Install postgresql-client."
command -v gpg >/dev/null 2>&1 || { [[ -n "${GPG_RECIPIENT}" ]] && error "GPG_RECIPIENT set but gpg not found." || true; }
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
BACKUP_SUCCESS=true
# ---------------------------------------------------------------------------
# 1. Database backup (pg_dump, custom format for parallel restore)
# ---------------------------------------------------------------------------
info "Starting database backup..."
DB_FILE="${BACKUP_DIR}/patch_manager_db_${TIMESTAMP}.dump"
if sudo -u postgres pg_dump -Fc -f "${DB_FILE}" "${DB_NAME}" 2>/dev/null; then
chmod 600 "${DB_FILE}"
chown root:root "${DB_FILE}"
SIZE=$(du -h "${DB_FILE}" | cut -f1)
info "Database backup complete: ${DB_FILE} (${SIZE})"
else
error "Database backup FAILED."
BACKUP_SUCCESS=false
fi
# ---------------------------------------------------------------------------
# 2. CA material backup
# ---------------------------------------------------------------------------
info "Starting CA material backup..."
CA_DIR="${CONFIG_DIR}/ca"
if [[ -d "${CA_DIR}" ]]; then
CA_FILE="${BACKUP_DIR}/patch_manager_ca_${TIMESTAMP}.tar.gz.gpg"
CA_TAR=$(mktemp /tmp/pm_ca_backup_XXXXXX.tar.gz)
tar -czf "${CA_TAR}" -C "$(dirname "${CA_DIR}")" ca/ 2>/dev/null
if [[ -n "${GPG_RECIPIENT}" ]]; then
# Encrypt with GPG — CA key is the most critical secret
if gpg --batch --encrypt --recipient "${GPG_RECIPIENT}" --output "${CA_FILE}" "${CA_TAR}" 2>/dev/null; then
chmod 600 "${CA_FILE}"
CA_SIZE=$(du -h "${CA_FILE}" | cut -f1)
info "CA backup (encrypted): ${CA_FILE} (${CA_SIZE})"
else
error "CA backup GPG encryption FAILED."
BACKUP_SUCCESS=false
fi
rm -f "${CA_TAR}"
else
# No GPG recipient — store unencrypted with strict permissions
warn "GPG_RECIPIENT not set. CA backup stored UNENCRYPTED with strict permissions."
CA_FILE_UNENCRYPTED="${BACKUP_DIR}/patch_manager_ca_${TIMESTAMP}.tar.gz"
mv "${CA_TAR}" "${CA_FILE_UNENCRYPTED}"
chmod 600 "${CA_FILE_UNENCRYPTED}"
chown root:root "${CA_FILE_UNENCRYPTED}"
CA_SIZE=$(du -h "${CA_FILE_UNENCRYPTED}" | cut -f1)
info "CA backup (unencrypted): ${CA_FILE_UNENCRYPTED} (${CA_SIZE})"
fi
else
warn "CA directory not found at ${CA_DIR}, skipping CA backup."
fi
# ---------------------------------------------------------------------------
# 3. Config backup (excluding secrets unless encrypted destination)
# ---------------------------------------------------------------------------
info "Starting config backup..."
CONFIG_FILE="${BACKUP_DIR}/patch_manager_config_${TIMESTAMP}.tar.gz"
CONFIG_FILE_GPG="${BACKUP_DIR}/patch_manager_config_${TIMESTAMP}.tar.gz.gpg"
CONFIG_TAR=$(mktemp /tmp/pm_config_backup_XXXXXX.tar.gz)
# Build file list — always include non-secret config files
CONFIG_FILES=(
"${CONFIG_DIR}/config.toml"
"${CONFIG_DIR}/jwt/verify.pem"
)
# Include TLS cert (public) if present
if [[ -f "${CONFIG_DIR}/tls/tls.crt" ]]; then
CONFIG_FILES+=("${CONFIG_DIR}/tls/tls.crt")
fi
# Build tar from existing files only
EXISTING_FILES=()
for f in "${CONFIG_FILES[@]}"; do
[[ -f "${f}" ]] && EXISTING_FILES+=("${f}")
done
if [[ ${#EXISTING_FILES[@]} -gt 0 ]]; then
tar -czf "${CONFIG_TAR}" "${EXISTING_FILES[@]}" 2>/dev/null
# If GPG_RECIPIENT is set, include secrets in the backup (encrypted)
if [[ -n "${GPG_RECIPIENT}" ]]; then
# Add secret files to a separate encrypted archive
SECRET_FILES=()
[[ -f "${CONFIG_DIR}/jwt/signing.pem" ]] && SECRET_FILES+=("${CONFIG_DIR}/jwt/signing.pem")
[[ -f "${CONFIG_DIR}/tls/tls.key" ]] && SECRET_FILES+=("${CONFIG_DIR}/tls/tls.key")
[[ -f "${CONFIG_DIR}/config.toml" ]] && SECRET_FILES+=("${CONFIG_DIR}/config.toml") # May contain DB URL
if [[ ${#SECRET_FILES[@]} -gt 0 ]]; then
# Re-create tar with secrets included
ALL_FILES=("${EXISTING_FILES[@]}" "${SECRET_FILES[@]}")
# Deduplicate
ALL_FILES_UNIQUE=( $(echo "${ALL_FILES[@]}" | tr ' ' '\n' | sort -u) )
rm -f "${CONFIG_TAR}"
tar -czf "${CONFIG_TAR}" "${ALL_FILES_UNIQUE[@]}" 2>/dev/null
fi
gpg --batch --encrypt --recipient "${GPG_RECIPIENT}" --output "${CONFIG_FILE_GPG}" "${CONFIG_TAR}" 2>/dev/null
chmod 600 "${CONFIG_FILE_GPG}"
rm -f "${CONFIG_TAR}"
CFG_SIZE=$(du -h "${CONFIG_FILE_GPG}" | cut -f1)
info "Config backup (encrypted, secrets included): ${CONFIG_FILE_GPG} (${CFG_SIZE})"
else
# No encryption — secrets excluded, only public config
mv "${CONFIG_TAR}" "${CONFIG_FILE}"
chmod 600 "${CONFIG_FILE}"
chown root:root "${CONFIG_FILE}"
CFG_SIZE=$(du -h "${CONFIG_FILE}" | cut -f1)
info "Config backup (secrets excluded): ${CONFIG_FILE} (${CFG_SIZE})"
fi
else
warn "No config files found, skipping config backup."
fi
# ---------------------------------------------------------------------------
# 4. Retention cleanup
# ---------------------------------------------------------------------------
info "Cleaning up backups older than ${RETENTION_DAYS} days..."
DELETED_COUNT=0
for pattern in "patch_manager_db_" "patch_manager_ca_" "patch_manager_config_"; do
while IFS= read -r -d '' old_file; do
rm -f "${old_file}"
((DELETED_COUNT++)) || true
done < <(find "${BACKUP_DIR}" -name "${pattern}*" -mtime +"${RETENTION_DAYS}" -print0)
done
info "Removed ${DELETED_COUNT} expired backup(s)."
# ---------------------------------------------------------------------------
# 5. Summary
# ---------------------------------------------------------------------------
if [[ "${BACKUP_SUCCESS}" == true ]]; then
info "=== Backup completed successfully ==="
info "Backup directory: ${BACKUP_DIR}"
info "Total size: $(du -sh "${BACKUP_DIR}" | cut -f1)"
info "RPO target: 24 hours (nightly schedule)"
exit 0
else
error "=== Backup completed WITH ERRORS ==="
exit 1
fi