Root cause: postinst ran sqlx migrate as postgres (superuser), creating ALL database objects owned by postgres. When pm-web connects as patch_manager, it cannot ALTER TABLE during migrations because it does not own them. The reassign_ownership() function never worked because REASSIGN OWNED BY postgres TO patch_manager fails for superuser-owned objects. Fix: Create the database owned by patch_manager (already done) and run all migrations as patch_manager via PGPASSWORD auth. When all objects are owned by patch_manager from the start, pm-web can ALTER them during upgrades. Changes: - Add psql_run_as_pm() helper that authenticates as patch_manager via PGPASSWORD - Replace all psql_run_db calls in apply_migrations() with psql_run_as_pm - Remove reassign_ownership() function entirely (it never worked) - Remove reassign_ownership call from main() - Add ALTER DEFAULT PRIVILEGES FOR ROLE postgres in setup_database() as safety net for any future migration that might run as postgres - Upgrade GRANT USAGE/CREATE to GRANT ALL PRIVILEGES on schema public - Keep pgcrypto extension creation as postgres (requires superuser) - Renumber sections after removing reassign_ownership Proven on live LPM system: service active, port 443 listening, all tables owned by patch_manager.
439 lines
19 KiB
Bash
439 lines
19 KiB
Bash
#!/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 as postgres superuser
|
|
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "${DB_NAME}" "$@" 2>/dev/null
|
|
}
|
|
|
|
psql_run_as_pm() {
|
|
# Run SQL against the patch_manager database as patch_manager user
|
|
# Requires PGPASSWORD to be set in the calling environment
|
|
PGPASSWORD="${PGPASSWORD}" psql -v ON_ERROR_STOP=1 -U "${DB_USER}" -h localhost -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 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. Apply database migrations (idempotent)
|
|
# Migrations run as patch_manager so all created objects are owned by
|
|
# patch_manager — this avoids the ownership conflicts that occur when
|
|
# postgres-owned objects need ALTER TABLE by a non-superuser.
|
|
# ---------------------------------------------------------------------------
|
|
apply_migrations() {
|
|
info "Applying database migrations..."
|
|
|
|
# Get the DB password for patch_manager authentication
|
|
local db_password=""
|
|
if [[ -f /tmp/.pm-db-password-new ]]; then
|
|
db_password=$(cat /tmp/.pm-db-password-new)
|
|
else
|
|
# Fallback: extract from config
|
|
local config_file="${CONFIG_DIR}/config.toml"
|
|
if [[ -f "${config_file}" ]]; then
|
|
db_password=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1)
|
|
fi
|
|
if [[ -z "${db_password}" || "${db_password}" == "CHANGEME" ]]; then
|
|
error "Cannot determine DB password for migrations."
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
export PGPASSWORD="${db_password}"
|
|
|
|
# Ensure pgcrypto extension is available (requires superuser)
|
|
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
|
|
|
|
# Create migration tracking table if not exists (run as patch_manager)
|
|
psql_run_as_pm <<'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_as_pm -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
|
|
migration_count="${migration_count// /}"
|
|
|
|
local tables_exist
|
|
tables_exist=$(psql_run_as_pm -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_as_pm -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_as_pm -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_as_pm -f "${sql_file}"; then
|
|
psql_run_as_pm -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
|
|
applied=$((applied + 1))
|
|
else
|
|
error " Failed to apply migration: ${fname}"
|
|
unset PGPASSWORD
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
unset PGPASSWORD
|
|
|
|
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. 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. 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}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
apply_migrations
|
|
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
|