#!/bin/bash set -e # ============================================================================= # Linux Patch Manager — Post-install script # ============================================================================= # Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb # results in a running service with a printed admin password. # All steps are idempotent (safe to re-run on upgrade). # # Migrations are handled by the application via sqlx on startup. # This script only creates the database, user, and grants. # ============================================================================= RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } DB_NAME="patch_manager" DB_USER="patch_manager" CONFIG_DIR="/etc/patch-manager" ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt" # --------------------------------------------------------------------------- # PostgreSQL helpers # --------------------------------------------------------------------------- psql_run() { # Run SQL as the postgres superuser sudo -u postgres psql -v ON_ERROR_STOP=1 "$@" 2>/dev/null } psql_run_db() { # Run SQL against the patch_manager database as postgres superuser sudo -u postgres psql -v ON_ERROR_STOP=1 -d "${DB_NAME}" "$@" 2>/dev/null } # --------------------------------------------------------------------------- # 1. Create service user (idempotent) # --------------------------------------------------------------------------- create_service_user() { if ! id patch-manager &>/dev/null; then useradd --system --no-create-home --shell /usr/sbin/nologin \ --comment "Linux Patch Manager service account" patch-manager info "Service user 'patch-manager' created." else info "Service user 'patch-manager' already exists." fi } # --------------------------------------------------------------------------- # 2. Create required directories (idempotent) # --------------------------------------------------------------------------- create_directories() { mkdir -p "${CONFIG_DIR}/ca" "${CONFIG_DIR}/certs" \ "${CONFIG_DIR}/jwt" "${CONFIG_DIR}/tls" \ /var/log/patch-manager /opt/patch-manager \ /var/backups/patch-manager chown -R patch-manager:patch-manager \ "${CONFIG_DIR}" /var/log/patch-manager \ /opt/patch-manager /usr/share/patch-manager/frontend chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt" chmod 700 /var/backups/patch-manager } # --------------------------------------------------------------------------- # 3. Wait for PostgreSQL to be ready # --------------------------------------------------------------------------- wait_for_postgresql() { info "Waiting for PostgreSQL to be ready..." local retries=30 local delay=2 local i for ((i = 1; i <= retries; i++)); do if pg_isready -q 2>/dev/null; then info "PostgreSQL is ready." return 0 fi warn "PostgreSQL not ready yet (attempt ${i}/${retries}), waiting ${delay}s..." sleep "${delay}" done error "PostgreSQL did not become ready after $((retries * delay)) seconds." return 1 } # --------------------------------------------------------------------------- # 4. Create PostgreSQL user, database, and grants (idempotent) # --------------------------------------------------------------------------- setup_database() { info "Setting up PostgreSQL database and user..." # Generate a random password for the DB user local db_password db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32) # Create role if not exists local role_exists role_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" 2>/dev/null || echo "") if [[ "${role_exists}" != "1" ]]; then psql_run -c "CREATE ROLE ${DB_USER} LOGIN PASSWORD '${db_password}';" info "PostgreSQL user '${DB_USER}' created." # Store password for config generation echo "${db_password}" > /tmp/.pm-db-password-new else info "PostgreSQL user '${DB_USER}' already exists." # Recover the DB password: try from existing config, or generate new. local config_file="${CONFIG_DIR}/config.toml" local existing_pw="" if [[ -f "${config_file}" ]]; then # Extract password from URL: postgres://user:PASSWORD@host/db # Use @localhost anchor so passwords containing @ are extracted correctly. existing_pw=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1) fi if [[ -n "${existing_pw}" && "${existing_pw}" != "CHANGEME" ]]; then # Config has a real password — sync it to PostgreSQL so the app can connect. psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${existing_pw}';" 2>/dev/null || true echo "${existing_pw}" > /tmp/.pm-db-password-new info "Synced DB password from existing config to PostgreSQL." else # No config or CHANGEME — generate a fresh password and update PostgreSQL. db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32) psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true echo "${db_password}" > /tmp/.pm-db-password-new info "Generated new DB password for existing user." fi fi # Create database if not exists local db_exists db_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null || echo "") if [[ "${db_exists}" != "1" ]]; then psql_run -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" info "Database '${DB_NAME}' created." else info "Database '${DB_NAME}' already exists, skipping creation." fi # Ensure pgcrypto extension is available (required by application migrations) psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true # Grant full permissions so patch_manager owns and manages all objects psql_run_db -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true psql_run_db -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" 2>/dev/null || true # If any future migration runs as postgres, ensure objects are still accessible by patch_manager psql_run_db -c "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${DB_USER};" 2>/dev/null || true psql_run_db -c "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${DB_USER};" 2>/dev/null || true psql_run_db -c "ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO ${DB_USER};" 2>/dev/null || true } # --------------------------------------------------------------------------- # 5. Write config.toml with DB URL # --------------------------------------------------------------------------- # Handles three scenarios: # 1. No config file → create from example with real DB password # 2. Config exists with CHANGEME → replace CHANGEME with real DB password # 3. Config exists with real password → leave it alone (upgrade) # --------------------------------------------------------------------------- write_config() { local config_file="${CONFIG_DIR}/config.toml" # Resolve the DB password to use: from setup_database() or generate fresh. local db_password="" if [[ -f /tmp/.pm-db-password-new ]]; then db_password=$(cat /tmp/.pm-db-password-new) fi if [[ -f "${config_file}" ]]; then # Check if the config still has the CHANGEME placeholder if grep -q 'CHANGEME' "${config_file}"; then if [[ -z "${db_password}" ]]; then # No password from setup_database() — generate a fresh one db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32) psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true fi info "Replacing CHANGEME placeholder in existing config with real DB password." sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}" else info "Config file ${config_file} already exists with a real password, leaving it unchanged." return 0 fi else # No config file — create from example if [[ -z "${db_password}" ]]; then db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32) psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true fi info "Writing configuration file..." cp /usr/share/patch-manager/config.example.toml "${config_file}" sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}" fi chown patch-manager:patch-manager "${config_file}" chmod 640 "${config_file}" info "Configuration written to ${config_file}" } # --------------------------------------------------------------------------- # 6. Generate JWT keys (idempotent) # Only generates if missing; regenerates verify.pem from signing.pem if lost. # --------------------------------------------------------------------------- generate_jwt_keys() { if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then info "Generating Ed25519 JWT signing key..." openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem" chmod 600 "${CONFIG_DIR}/jwt/signing.pem" chmod 644 "${CONFIG_DIR}/jwt/verify.pem" info "JWT keys generated." elif [[ ! -f "${CONFIG_DIR}/jwt/verify.pem" ]]; then info "Regenerating missing JWT verification key from existing signing key..." openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/verify.pem" chmod 644 "${CONFIG_DIR}/jwt/verify.pem" info "JWT verification key regenerated." else info "JWT keys already exist, skipping." fi } # --------------------------------------------------------------------------- # 7. Enable and start services # --------------------------------------------------------------------------- enable_and_start_services() { systemctl daemon-reload # Enable the target (which pulls in web + worker) systemctl enable patch-manager.target 2>/dev/null || true # Enable individual services so they survive a reboot systemctl enable patch-manager-web.service patch-manager-worker.service 2>/dev/null || true # Start or restart services if systemctl is-active --quiet patch-manager.target 2>/dev/null; then info "Restarting patch-manager services (upgrade)..." systemctl restart patch-manager.target 2>/dev/null || true else info "Starting patch-manager services..." systemctl start patch-manager.target 2>/dev/null || true fi } # --------------------------------------------------------------------------- # 8. Wait for pm-web service to be healthy # The application runs database migrations via sqlx on startup. # We must wait for it to be ready before generating the admin password, # since the users table won't exist until migrations complete. # --------------------------------------------------------------------------- wait_for_service_healthy() { info "Waiting for pm-web service to become healthy (migrations run on startup)..." local retries=30 local delay=1 local i for ((i = 1; i <= retries; i++)); do # Check if the service is active if systemctl is-active --quiet patch-manager-web.service 2>/dev/null; then # Service is active — try a health check on the HTTPS port if curl -skf https://localhost:443/ >/dev/null 2>&1 || \ curl -skf https://localhost:8443/ >/dev/null 2>&1; then info "pm-web is healthy." return 0 fi fi warn "pm-web not ready yet (attempt ${i}/${retries}), waiting ${delay}s..." sleep "${delay}" done error "pm-web did not become healthy after $((retries * delay)) seconds." error "Admin password generation may fail because the users table may not exist yet." return 1 } # --------------------------------------------------------------------------- # 9. Generate admin password and update database # Must run AFTER the service has started and run migrations, # because the users table is created by sqlx migrations. # --------------------------------------------------------------------------- generate_admin_password() { info "Generating admin password..." # Generate a random 24-character password local admin_password admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24) # Hash with argon2 (PHC format, compatible with the application) # Generate a random 16-character salt (argon2 requires minimum 8 characters) local admin_salt admin_salt=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 16) local password_hash password_hash=$(echo -n "${admin_password}" | argon2 "${admin_salt}" -id -t 3 -m 16 -p 1 -l 32 -e) # Update admin user password in database # Only update if the placeholder hash is still present # The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA # Using single-quoted variable to preserve $ signs in SQL LIKE pattern local placeholder_pattern placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%' local updated updated=$(psql_run_db -t -A -c \ "UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \ WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \ RETURNING id;" 2>/dev/null || echo "") if [[ -n "${updated}" ]]; then # Write admin password to file (mode 600, owned by root) echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}" chmod 600 "${ADMIN_PASSWORD_FILE}" chown root:root "${ADMIN_PASSWORD_FILE}" echo "" echo -e "${CYAN}=============================================${NC}" echo -e "${CYAN} Linux Patch Manager — Admin Credentials${NC}" echo -e "${CYAN}=============================================${NC}" echo -e " Username: ${GREEN}admin${NC}" echo -e " Password: ${GREEN}${admin_password}${NC}" echo "" echo -e " ${YELLOW}IMPORTANT: Save this password! It will not be shown again.${NC}" echo -e " Password also saved to: ${ADMIN_PASSWORD_FILE}" echo -e "${CYAN}=============================================${NC}" echo "" else info "Admin password already set (not a fresh install). Password file not regenerated." fi } # --------------------------------------------------------------------------- # 10. Install backup cron (idempotent) # --------------------------------------------------------------------------- install_backup_cron() { if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then (crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab - info "Nightly backup cron installed." fi } # ============================================================================= # Main # ============================================================================= case "$1" in configure) create_service_user create_directories wait_for_postgresql setup_database write_config generate_jwt_keys enable_and_start_services wait_for_service_healthy generate_admin_password install_backup_cron # Clean up temp file rm -f /tmp/.pm-db-password-new info "Linux Patch Manager installation complete." ;; abort-upgrade|abort-remove|abort-deconfigure) ;; *) echo "postinst called with unknown argument \`$1'" >&2 ;; esac exit 0