#!/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). # ============================================================================= 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" MIGRATION_DIR="/usr/share/patch-manager/migrations" 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 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 and database (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 # Grant permissions (idempotent) psql_run_db -c "GRANT USAGE ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true psql_run_db -c "GRANT CREATE 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 } # --------------------------------------------------------------------------- # 5. Apply database migrations (idempotent) # --------------------------------------------------------------------------- apply_migrations() { info "Applying database migrations..." # Ensure pgcrypto extension is available psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true # Create migration tracking table if not exists psql_run_db <<'MIGSQL' CREATE TABLE IF NOT EXISTS _migrations ( id SERIAL PRIMARY KEY, filename TEXT NOT NULL UNIQUE, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ); MIGSQL # Handle upgrade from pre-migration-tracking versions: # If tables exist but _migrations is empty, mark all existing migrations as applied. local migration_count migration_count=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0") migration_count="${migration_count// /}" local tables_exist tables_exist=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0") tables_exist="${tables_exist// /}" if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then info "Existing database detected — marking all shipped migrations as already applied." for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do local fname fname=$(basename "${sql_file}") psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true done fi # Apply each migration in sorted order, skipping already-applied ones local applied=0 local skipped=0 for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do local fname fname=$(basename "${sql_file}") local already_applied already_applied=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0") already_applied="${already_applied// /}" if [[ "${already_applied}" -gt 0 ]]; then skipped=$((skipped + 1)) continue fi info " Applying migration: ${fname}" if psql_run_db -f "${sql_file}"; then psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true applied=$((applied + 1)) else error " Failed to apply migration: ${fname}" return 1 fi done if [[ "${applied}" -gt 0 ]]; then info "Applied ${applied} new migration(s), skipped ${skipped} already applied." else info "All migrations up to date (${skipped} already applied)." fi } # --------------------------------------------------------------------------- # 6. Reassign database object ownership to patch_manager # --------------------------------------------------------------------------- # The postinst runs migrations as the postgres superuser, so all tables, # types, and sequences created by those migrations are owned by postgres. # The application connects as patch_manager and needs ownership to ALTER # tables during upgrades (e.g. 'must be owner of table groups'). # This function reassigns ownership of every database object to patch_manager # so the application can manage its own schema. # --------------------------------------------------------------------------- reassign_ownership() { info "Reassigning database object ownership to ${DB_USER}..." # REASSIGN OWNED BY covers all tables, enum types, sequences, and views # owned by postgres in the current database. psql_run_db -c "REASSIGN OWNED BY postgres TO ${DB_USER};" \ || warn "REASSIGN OWNED BY encountered warnings (may be harmless on fresh installs)." # Schemas are NOT covered by REASSIGN OWNED BY — handle explicitly. psql_run_db -c "ALTER SCHEMA public OWNER TO ${DB_USER};" \ || warn "Could not alter public schema owner." # Grant full privileges so patch_manager can manage all objects psql_run -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" \ || warn "Could not grant database privileges." psql_run_db -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};" \ || warn "Could not grant schema privileges." psql_run_db -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${DB_USER};" \ || warn "Could not grant table privileges." psql_run_db -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};" \ || warn "Could not grant sequence privileges." psql_run_db -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ${DB_USER};" \ || warn "Could not grant function privileges." # Ensure future objects in public schema are also owned by patch_manager psql_run_db -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${DB_USER};" \ || warn "Could not set default table privileges." psql_run_db -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${DB_USER};" \ || warn "Could not set default sequence privileges." psql_run_db -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO ${DB_USER};" \ || warn "Could not set default function privileges." info "Database object ownership reassigned to ${DB_USER}." } # --------------------------------------------------------------------------- # 8. Generate admin password and update database # --------------------------------------------------------------------------- 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 } # --------------------------------------------------------------------------- # 9. 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}" } # --------------------------------------------------------------------------- # 10. 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 } # --------------------------------------------------------------------------- # 11. 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 } # --------------------------------------------------------------------------- # 12. 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 apply_migrations reassign_ownership generate_admin_password write_config generate_jwt_keys enable_and_start_services 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