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
188 lines
7.7 KiB
Bash
Executable File
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
|