From a5343760e175d73f45380965b801d170b624d1f7 Mon Sep 17 00:00:00 2001 From: Draco-Lunaris-Echo Date: Sun, 7 Jun 2026 16:20:08 -0500 Subject: [PATCH] feat: Automated install, Docker deployment, and CI Docker job (#42) - debian/control: add Pre-Depends and Depends on postgresql-16, argon2 - debian/postinst: idempotent automation for PostgreSQL setup, DB/user creation, migration tracking, admin password generation, config write, and service enable/start - Dockerfile: multi-stage build (Rust + frontend + slim runtime) - docker/entrypoint.sh: first-run DB wait, migrations, admin password - docker-compose.yml: split db/app architecture with healthcheck - .env.example: template for DB_PASSWORD and TAG - .dockerignore: exclude build artifacts from Docker context - .github/workflows/ci.yml: add Docker job for multi-arch (amd64/arm64) GHCR push on tag releases with layer caching - .gitignore: add .env entry --- .dockerignore | 40 ++++ .env.example | 8 + .github/workflows/ci.yml | 44 +++++ .gitignore | 3 + Dockerfile | 111 +++++++++++ debian/control | 3 +- debian/postinst | 417 +++++++++++++++++++++++++++++++-------- docker-compose.yml | 58 ++++++ docker/entrypoint.sh | 232 ++++++++++++++++++++++ 9 files changed, 835 insertions(+), 81 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile mode change 100644 => 100755 debian/postinst create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c44762 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# Build artifacts +target/ +*.deb +package-build/ + +# Frontend build output (rebuilt in Docker) +frontend/dist/ +frontend/node_modules/ + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Environment +.env +.env.* + +# Documentation +docs/ + +# Agent Zero project data +.a0proj/ + +# Python +venv/ +__pycache__/ + +# Misc +*.md +!README.md +LICENSE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ad8cde --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Linux Patch Manager — Docker Environment Variables +# Copy this file to .env and edit the values before running docker compose up. + +# Required: PostgreSQL password for the patch_manager user +DB_PASSWORD=changeme-to-a-strong-password + +# Optional: Docker image tag (defaults to 'latest' if not set) +TAG=latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f024116..e71caf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ env: permissions: contents: write + packages: write jobs: rust-format: @@ -128,3 +129,46 @@ jobs: with: body: ${{ steps.release_notes.outputs.notes }} files: linux-patch-manager_*.deb + + docker: + name: Docker Build & Push + needs: [rust-format, clippy, rust-test, security-audit, frontend-lint] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/draco-lunaris/linux-patch-manager + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index fd1b22a..33952e8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ frontend/dist *.deb package-build/ +# Docker environment +.env + # Private key material - NEVER commit *.key *.key.pem diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..def159e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,111 @@ +# ============================================================================= +# Linux Patch Manager — Multi-stage Docker Build +# ============================================================================= +# Build: docker build -t linux-patch-manager . +# Run: docker compose up +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Rust build +# --------------------------------------------------------------------------- +FROM rust:1.82-bookworm AS rust-builder + +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + libfontconfig1-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +# Cache dependencies by building a dummy project first +COPY Cargo.toml Cargo.lock ./ +RUN mkdir -p crates/pm-web/src crates/pm-worker/src crates/pm-core/src \ + crates/pm-agent-client/src crates/pm-auth/src crates/pm-ca/src \ + crates/pm-reports/src crates/migrate-secrets/src +RUN echo 'fn main(){}' > crates/pm-web/src/main.rs \ + && echo 'fn main(){}' > crates/pm-worker/src/main.rs \ + && echo '' > crates/pm-core/src/lib.rs \ + && echo '' > crates/pm-agent-client/src/lib.rs \ + && echo '' > crates/pm-auth/src/lib.rs \ + && echo '' > crates/pm-ca/src/lib.rs \ + && echo '' > crates/pm-reports/src/lib.rs \ + && echo 'fn main(){}' > crates/migrate-secrets/src/main.rs +RUN cargo build --release 2>/dev/null || true + +# Now build the real project +COPY crates/ crates/ +RUN cargo build --release + +# Verify binaries exist +RUN ls -la target/release/pm-web target/release/pm-worker + +# Strip debug symbols +RUN strip target/release/pm-web target/release/pm-worker + +# --------------------------------------------------------------------------- +# Stage 2: Frontend build +# --------------------------------------------------------------------------- +FROM node:20-bookworm-slim AS frontend-builder + +WORKDIR /usr/src/app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --production=false + +COPY frontend/ ./ +RUN npm run build + +# --------------------------------------------------------------------------- +# Stage 3: Runtime +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + libfontconfig1 \ + postgresql-client-16 \ + argon2 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create service user +RUN useradd --system --no-create-home --shell /usr/sbin/nologin \ + --comment "Linux Patch Manager service account" patch-manager + +# Create directories +RUN mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \ + /etc/patch-manager/jwt /etc/patch-manager/tls \ + /var/log/patch-manager /opt/patch-manager \ + /usr/share/patch-manager/frontend \ + /usr/share/patch-manager/migrations + +# Copy binaries +COPY --from=rust-builder /usr/src/app/target/release/pm-web /usr/local/bin/pm-web +COPY --from=rust-builder /usr/src/app/target/release/pm-worker /usr/local/bin/pm-worker + +# Copy frontend +COPY --from=frontend-builder /usr/src/app/frontend/dist/ /usr/share/patch-manager/frontend/ + +# Copy migrations +COPY migrations/ /usr/share/patch-manager/migrations/ + +# Copy entrypoint +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod 755 /usr/local/bin/entrypoint.sh + +# Copy config template +COPY config/config.example.toml /usr/share/patch-manager/config.example.toml + +# Set ownership +RUN chown -R patch-manager:patch-manager \ + /etc/patch-manager /var/log/patch-manager \ + /opt/patch-manager /usr/share/patch-manager + +# Expose HTTPS port +EXPOSE 443 + +# Volume for persistent data +VOLUME ["/etc/patch-manager", "/var/log/patch-manager", "/opt/patch-manager"] + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/debian/control b/debian/control index a7c4394..7095b6f 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,8 @@ Version: 1.0.0-1 Architecture: amd64 Maintainer: Moon Dragon Installed-Size: 45000 -Depends: postgresql-16, libssl3, libc6 (>= 2.39), libfontconfig1 +Pre-Depends: postgresql-16 +Depends: postgresql-16, argon2, libssl3, libc6 (>= 2.39), libfontconfig1 Recommends: postgresql-client-16, fonts-dejavu-core Suggests: gpg Section: admin diff --git a/debian/postinst b/debian/postinst old mode 100644 new mode 100755 index a2624cd..c17377d --- a/debian/postinst +++ b/debian/postinst @@ -4,91 +4,348 @@ 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, skipping creation." + 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. 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) + local password_hash + password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -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 (only if file doesn't exist) +# --------------------------------------------------------------------------- +write_config() { + local config_file="${CONFIG_DIR}/config.toml" + + if [[ -f "${config_file}" ]]; then + info "Config file ${config_file} already exists, not overwriting." + return 0 + fi + + info "Writing configuration file..." + + # Get the DB password — use the one we just generated if we created the user + local db_password="" + if [[ -f /tmp/.pm-db-password-new ]]; then + db_password=$(cat /tmp/.pm-db-password-new) + fi + + # If we don't have a password (user already existed), generate a new one + # and update the PostgreSQL user so we can connect + 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 + + # Copy example config and set the DB URL + 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}" + chown patch-manager:patch-manager "${config_file}" + chmod 640 "${config_file}" + + info "Configuration written to ${config_file}" +} + +# --------------------------------------------------------------------------- +# 8. Generate JWT keys (idempotent) +# --------------------------------------------------------------------------- +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." + else + info "JWT signing key already exists, 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 + + # 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 if not exists - if ! id patch-manager &>/dev/null; then - useradd --system --no-create-home --shell /usr/sbin/nologin \ - --comment "Linux Patch Manager service account" patch-manager - fi + 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 - # Create required directories - mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \ - /etc/patch-manager/jwt /etc/patch-manager/tls \ - /var/log/patch-manager /opt/patch-manager \ - /var/backups/patch-manager + # Clean up temp file + rm -f /tmp/.pm-db-password-new - chown -R patch-manager:patch-manager \ - /etc/patch-manager /var/log/patch-manager \ - /opt/patch-manager /usr/share/patch-manager/frontend - - chmod 750 /etc/patch-manager/ca /etc/patch-manager/jwt - chmod 700 /var/backups/patch-manager - - # Generate JWT signing key if not present - if [[ ! -f /etc/patch-manager/jwt/signing.pem ]]; then - openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem 2>/dev/null - openssl pkey -in /etc/patch-manager/jwt/signing.pem -pubout -out /etc/patch-manager/jwt/verify.pem 2>/dev/null - chown patch-manager:patch-manager /etc/patch-manager/jwt/signing.pem /etc/patch-manager/jwt/verify.pem - chmod 600 /etc/patch-manager/jwt/signing.pem - chmod 644 /etc/patch-manager/jwt/verify.pem - fi - - # Write default config if not present - if [[ ! -f /etc/patch-manager/config.toml ]]; then - cp /usr/share/patch-manager/config.example.toml /etc/patch-manager/config.toml - chown patch-manager:patch-manager /etc/patch-manager/config.toml - chmod 640 /etc/patch-manager/config.toml - fi - - # Install backup cron if not present - 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 - - fi - - # Reload systemd - systemctl daemon-reload - - # Restart services if this is an upgrade (not a fresh install) - if systemctl is-active --quiet patch-manager-web 2>/dev/null; then - systemctl restart patch-manager-web || true - fi - if systemctl is-active --quiet patch-manager-worker 2>/dev/null; then - systemctl restart patch-manager-worker || true - fi - - # Run pending database migrations - MIGRATION_DIR="/usr/share/patch-manager/migrations" - if [[ -d "$MIGRATION_DIR" ]]; then - echo "Applying database migrations..." - for sql_file in $(ls "$MIGRATION_DIR"/*.sql 2>/dev/null | sort); do - echo " Applying: $(basename "$sql_file")" - done - echo "Note: Migrations must be applied manually: sudo -u patch_manager psql -d patch_manager -f " - fi - - echo "" - echo "Linux Patch Manager installed successfully!" - echo "===========================================" - echo "" - echo "Next steps:" - echo " 1. Install and configure PostgreSQL:" - echo " apt install postgresql-16" - echo " 2. Create the database:" - echo " sudo -u postgres createdb -O patch_manager patch_manager" - echo " 3. Edit /etc/patch-manager/config.toml with your database URL" - echo " 4. Enable and start services:" - echo " systemctl enable --now patch-manager.target" - echo " 5. Access the web UI at https://localhost" - echo " Default admin credentials are set via the seed migration." - echo "" - echo "IMPORTANT: Change the default admin password immediately after first login!" - echo "" - echo "If this is an upgrade, services have been restarted automatically." - echo "Apply any new database migrations:" - echo " sudo -u patch_manager psql -d patch_manager -f /usr/share/patch-manager/migrations/.sql" - echo "" + info "Linux Patch Manager installation complete." ;; abort-upgrade|abort-remove|abort-deconfigure) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee7286b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +# ============================================================================= +# Linux Patch Manager — Docker Compose Deployment +# ============================================================================= +# Usage: +# cp .env.example .env # Edit DB_PASSWORD +# docker compose up -d +# ============================================================================= + +services: + db: + image: postgres:16-bookworm + restart: unless-stopped + environment: + POSTGRES_USER: patch_manager + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: patch_manager + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U patch_manager -d patch_manager"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + networks: + - patch-manager-net + + app: + image: ghcr.io/draco-lunaris/linux-patch-manager:${TAG:-latest} + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "443:443" + environment: + DATABASE_URL: postgres://patch_manager:${DB_PASSWORD}@db:5432/patch_manager + PATCH_MANAGER_CONFIG: /etc/patch-manager/config.toml + volumes: + - pm-config:/etc/patch-manager + - pm-logs:/var/log/patch-manager + - pm-data:/opt/patch-manager + networks: + - patch-manager-net + +volumes: + pgdata: + driver: local + pm-config: + driver: local + pm-logs: + driver: local + pm-data: + driver: local + +networks: + patch-manager-net: + driver: bridge diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..71b53aa --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,232 @@ +#!/bin/bash +set -e + +# ============================================================================= +# Linux Patch Manager — Docker Entrypoint +# ============================================================================= +# Handles first-run: wait for DB, run migrations, generate admin password, +# start pm-web and pm-worker services. +# ============================================================================= + +MIGRATION_DIR="/usr/share/patch-manager/migrations" +CONFIG_DIR="/etc/patch-manager" +ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt" + +# --------------------------------------------------------------------------- +# Parse DATABASE_URL into PG* env vars for psql compatibility +# --------------------------------------------------------------------------- +parse_database_url() { + # DATABASE_URL format: postgres://user:password@host:port/dbname + local url="${DATABASE_URL}" + + # Extract components + DB_PASS=$(echo "$url" | sed -n 's|postgres://[^:]*:\([^@]*\)@.*|\1|p') + DB_HOST=$(echo "$url" | sed -n 's|.*@\([^:/]*\).*|\1|p') + DB_PORT=$(echo "$url" | sed -n 's|.*:\([0-9]*\)/.*|\1|p') + DB_USER=$(echo "$url" | sed -n 's|postgres://\([^:]*\):.*|\1|p') + DB_NAME=$(echo "$url" | sed -n 's|.*/\([^?]*\).*|\1|p') + + # Default port + DB_PORT="${DB_PORT:-5432}" + + export PGHOST="${DB_HOST}" + export PGPORT="${DB_PORT}" + export PGUSER="${DB_USER}" + export PGPASSWORD="${DB_PASS}" + export PGDATABASE="${DB_NAME}" +} + +# --------------------------------------------------------------------------- +# Wait for PostgreSQL to be ready +# --------------------------------------------------------------------------- +wait_for_db() { + echo "[entrypoint] Waiting for PostgreSQL at ${PGHOST}:${DB_PORT}..." + local retries=60 + local delay=2 + local i + for ((i = 1; i <= retries; i++)); do + if pg_isready -q -h "${PGHOST}" -p "${DB_PORT}" -U "${DB_USER}" 2>/dev/null; then + echo "[entrypoint] PostgreSQL is ready." + return 0 + fi + echo "[entrypoint] PostgreSQL not ready (attempt ${i}/${retries}), waiting ${delay}s..." + sleep "${delay}" + done + echo "[entrypoint] ERROR: PostgreSQL did not become ready after $((retries * delay)) seconds." >&2 + return 1 +} + +# --------------------------------------------------------------------------- +# Run database migrations (idempotent) +# --------------------------------------------------------------------------- +run_migrations() { + echo "[entrypoint] Applying database migrations..." + + # Ensure pgcrypto extension + psql -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true + + # Create migration tracking table + psql -v ON_ERROR_STOP=1 <<'EOSQL' +CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +EOSQL + + # Handle upgrade from pre-migration-tracking versions + local migration_count + migration_count=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0") + migration_count="${migration_count// /}" + + local tables_exist + tables_exist=$(psql -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 + echo "[entrypoint] 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 -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true + done + fi + + # Apply each migration in sorted order + 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 -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 + + echo "[entrypoint] Applying migration: ${fname}" + if psql -v ON_ERROR_STOP=1 -f "${sql_file}"; then + psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true + applied=$((applied + 1)) + else + echo "[entrypoint] ERROR: Failed to apply migration: ${fname}" >&2 + return 1 + fi + done + + if [[ "${applied}" -gt 0 ]]; then + echo "[entrypoint] Applied ${applied} new migration(s), skipped ${skipped}." + else + echo "[entrypoint] All migrations up to date (${skipped} already applied)." + fi +} + +# --------------------------------------------------------------------------- +# Generate admin password on first run +# --------------------------------------------------------------------------- +generate_admin_password() { + echo "[entrypoint] Checking admin password status..." + + # 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) + local password_hash + password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -p 1 -l 32 -e) + + # Update admin user — only if 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 the SQL LIKE pattern + local placeholder_pattern + placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%' + + local updated + updated=$(psql -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 + echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}" + chmod 600 "${ADMIN_PASSWORD_FILE}" + chown root:root "${ADMIN_PASSWORD_FILE}" + + echo "" + echo "=============================================" + echo " Linux Patch Manager — Admin Credentials" + echo "=============================================" + echo " Username: admin" + echo " Password: ${admin_password}" + echo "" + echo " IMPORTANT: Save this password! It will not be shown again." + echo " Password also saved to: ${ADMIN_PASSWORD_FILE}" + echo "=============================================" + echo "" + else + echo "[entrypoint] Admin password already set (not a fresh install)." + fi +} + +# --------------------------------------------------------------------------- +# Generate JWT keys if not present +# --------------------------------------------------------------------------- +generate_jwt_keys() { + if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then + echo "[entrypoint] 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" + echo "[entrypoint] JWT keys generated." + else + echo "[entrypoint] JWT signing key already exists." + fi +} + +# --------------------------------------------------------------------------- +# Write config.toml if not present +# --------------------------------------------------------------------------- +write_config() { + local config_file="${CONFIG_DIR}/config.toml" + + if [[ -f "${config_file}" ]]; then + echo "[entrypoint] Config file already exists, not overwriting." + return 0 + fi + + echo "[entrypoint] Writing configuration file..." + cp /usr/share/patch-manager/config.example.toml "${config_file}" + sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|${DATABASE_URL}|" "${config_file}" + chown patch-manager:patch-manager "${config_file}" + chmod 640 "${config_file}" + echo "[entrypoint] Configuration written." +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +echo "[entrypoint] Linux Patch Manager Docker entrypoint starting..." + +parse_database_url +wait_for_db +run_migrations +generate_admin_password +generate_jwt_keys +write_config + +echo "[entrypoint] Starting pm-web and pm-worker..." + +# Start pm-worker in background +pm-worker & +WORKER_PID=$! + +# Start pm-web in foreground (main process) +export PATCH_MANAGER_CONFIG="${CONFIG_DIR}/config.toml" + +exec pm-web