Private
Public Access
1
0

Compare commits

..

2 Commits

Author SHA1 Message Date
b4921d51a0 fix(docker): use ubuntu:24.04 runtime instead of debian:bookworm-slim
Some checks failed
CI Pipeline / Rust Format Check (pull_request) Successful in 6s
CI Pipeline / Clippy Lints (pull_request) Successful in 54s
CI Pipeline / Rust Unit Tests (pull_request) Failing after 1m20s
CI Pipeline / Security Audit (pull_request) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (pull_request) Successful in 15s
CI Pipeline / Build .deb & Release (pull_request) Has been skipped
The project targets Ubuntu 24.04, not Debian Bookworm. Ubuntu 24.04
includes PostgreSQL 16 in default repos, eliminating the need for the
PGDG APT repo workaround. Also fixes libssl3 → libssl3t64 package name
for the time64 transition in Ubuntu 24.04.
2026-06-07 17:34:05 -05:00
455013db8e fix(docker): add PostgreSQL APT repo for postgresql-client-16
Some checks failed
CI Pipeline / Rust Format Check (pull_request) Successful in 4s
CI Pipeline / Clippy Lints (pull_request) Successful in 52s
CI Pipeline / Rust Unit Tests (pull_request) Failing after 1m21s
CI Pipeline / Security Audit (pull_request) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (pull_request) Successful in 14s
CI Pipeline / Build .deb & Release (pull_request) Has been skipped
Debian Bookworm default repos only ship PostgreSQL 15. The Docker
runtime stage needs postgresql-client-16 for the entrypoint script,
so add the official PGDG APT repository.

- Add PGDG GPG key and sources.list entry for bookworm-pgdg
- Install ca-certificates and curl first (needed for repo setup)
- Purge gnupg2 after use to keep image lean
- Verify argon2 package name is correct for Bookworm (it is)
2026-06-07 17:21:37 -05:00
17 changed files with 293 additions and 547 deletions

View File

@ -9,7 +9,6 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions: permissions:
contents: write contents: write
@ -20,7 +19,7 @@ jobs:
name: Rust Format name: Rust Format
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt components: rustfmt
@ -31,7 +30,7 @@ jobs:
name: Clippy name: Clippy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: clippy components: clippy
@ -44,7 +43,7 @@ jobs:
name: Rust Tests name: Rust Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Install system dependencies - name: Install system dependencies
@ -55,7 +54,7 @@ jobs:
name: Security Audit name: Security Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-audit && cargo audit - run: cargo install cargo-audit && cargo audit
@ -63,11 +62,11 @@ jobs:
name: Secret scanning name: Secret scanning
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Gitleaks - name: Gitleaks
uses: gitleaks/gitleaks-action@v3 uses: gitleaks/gitleaks-action@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -75,9 +74,9 @@ jobs:
name: Frontend Lint name: Frontend Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
- name: Install & Lint - name: Install & Lint
@ -88,7 +87,7 @@ jobs:
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint] needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Free disk space - name: Free disk space
@ -104,7 +103,7 @@ jobs:
- name: Strip binaries - name: Strip binaries
run: strip target/release/pm-web target/release/pm-worker run: strip target/release/pm-web target/release/pm-worker
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
- name: Build frontend - name: Build frontend
@ -126,7 +125,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Upload to GitHub Release - name: Upload to GitHub Release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
body: ${{ steps.release_notes.outputs.notes }} body: ${{ steps.release_notes.outputs.notes }}
files: linux-patch-manager_*.deb files: linux-patch-manager_*.deb
@ -136,18 +135,17 @@ jobs:
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint] needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -155,7 +153,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/draco-lunaris/linux-patch-manager images: ghcr.io/draco-lunaris/linux-patch-manager
tags: | tags: |
@ -165,10 +163,10 @@ jobs:
type=sha type=sha
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.1.14" version = "1.1.0"
edition = "2021" edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"] authors = ["Echo <echo@moon-dragon.us>"]
license = "MIT" license = "MIT"

View File

@ -6,36 +6,20 @@
# ============================================================================= # =============================================================================
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 1: Rust build (Ubuntu 24.04 + rustup) # Stage 1: Rust build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS rust-builder FROM rust:1.82-bookworm AS rust-builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
build-essential \
curl \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
libfontconfig1-dev \ libfontconfig1-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Rust via rustup (stable channel, provides 1.85+)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Cache dependencies by building a dummy project first # Cache dependencies by building a dummy project first
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/pm-web/Cargo.toml crates/pm-web/Cargo.toml
COPY crates/pm-worker/Cargo.toml crates/pm-worker/Cargo.toml
COPY crates/pm-core/Cargo.toml crates/pm-core/Cargo.toml
COPY crates/pm-agent-client/Cargo.toml crates/pm-agent-client/Cargo.toml
COPY crates/pm-auth/Cargo.toml crates/pm-auth/Cargo.toml
COPY crates/pm-ca/Cargo.toml crates/pm-ca/Cargo.toml
COPY crates/pm-reports/Cargo.toml crates/pm-reports/Cargo.toml
COPY crates/migrate-secrets/Cargo.toml crates/migrate-secrets/Cargo.toml
RUN mkdir -p crates/pm-web/src crates/pm-worker/src crates/pm-core/src \ 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-agent-client/src crates/pm-auth/src crates/pm-ca/src \
crates/pm-reports/src crates/migrate-secrets/src crates/pm-reports/src crates/migrate-secrets/src
@ -51,7 +35,6 @@ RUN cargo build --release 2>/dev/null || true
# Now build the real project # Now build the real project
COPY crates/ crates/ COPY crates/ crates/
COPY migrations/ migrations/
RUN cargo build --release RUN cargo build --release
# Verify binaries exist # Verify binaries exist
@ -61,21 +44,9 @@ RUN ls -la target/release/pm-web target/release/pm-worker
RUN strip target/release/pm-web target/release/pm-worker RUN strip target/release/pm-web target/release/pm-worker
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 2: Frontend build (Ubuntu 24.04 + Node.js 20) # Stage 2: Frontend build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS frontend-builder FROM node:20-bookworm-slim AS frontend-builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20 via NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app/frontend WORKDIR /usr/src/app/frontend
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
@ -93,7 +64,6 @@ RUN apt-get update && apt-get install -y \
ca-certificates \ ca-certificates \
libssl3t64 \ libssl3t64 \
libfontconfig1 \ libfontconfig1 \
openssl \
postgresql-client-16 \ postgresql-client-16 \
argon2 \ argon2 \
curl \ curl \

View File

@ -49,8 +49,7 @@ health_check_poll_interval_secs = 300
# Maximum concurrent mTLS agent calls (Tokio Semaphore) # Maximum concurrent mTLS agent calls (Tokio Semaphore)
max_concurrent_agent_calls = 64 max_concurrent_agent_calls = 64
# Worker heartbeat write interval (seconds). Default: 300 = 5 minutes # Worker heartbeat write interval (seconds)
heartbeat_interval_secs = 300
# WS relay HTTP polling fallback interval (seconds). When WebSocket connection to # WS relay HTTP polling fallback interval (seconds). When WebSocket connection to
# an agent fails, the relay falls back to polling the agent's HTTP API at this # an agent fails, the relay falls back to polling the agent's HTTP API at this

View File

@ -101,8 +101,7 @@ pub struct WorkerConfig {
pub health_check_poll_interval_secs: u64, pub health_check_poll_interval_secs: u64,
/// Maximum concurrent agent calls /// Maximum concurrent agent calls
pub max_concurrent_agent_calls: usize, pub max_concurrent_agent_calls: usize,
/// Worker heartbeat interval in seconds (default: 300 = 5 min) /// Worker heartbeat interval in seconds
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval_secs: u64, pub heartbeat_interval_secs: u64,
/// WS relay HTTP polling fallback interval in seconds (default: 10) /// WS relay HTTP polling fallback interval in seconds (default: 10)
pub ws_relay_poll_interval_secs: u64, pub ws_relay_poll_interval_secs: u64,
@ -256,10 +255,6 @@ fn default_health_check_poll_interval() -> u64 {
300 300
} }
fn default_heartbeat_interval() -> u64 {
300
}
fn default_sso_callback_url() -> String { fn default_sso_callback_url() -> String {
"http://localhost:5173/auth/sso/callback".to_string() "http://localhost:5173/auth/sso/callback".to_string()
} }

78
debian/changelog vendored
View File

@ -1,81 +1,3 @@
linux-patch-manager (1.1.14-1) unstable; urgency=low
* Release v1.1.14
-- git-echo <git-echo@moon-dragon.us> Wed, 10 Jun 2026 10:02:44 -0500
linux-patch-manager (1.1.13-1) unstable; urgency=low
* Release v1.1.13
-- git-echo <git-echo@moon-dragon.us> Wed, 10 Jun 2026 09:16:34 -0500
linux-patch-manager (1.1.12-1) unstable; urgency=low
* Release v1.1.12
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 22:14:03 -0500
linux-patch-manager (1.1.11-1) unstable; urgency=low
* Release v1.1.11
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 15:57:10 -0500
linux-patch-manager (1.1.10-1) unstable; urgency=low
* Release v1.1.10
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 14:11:31 -0500
linux-patch-manager (1.1.9-1) unstable; urgency=low
* Release v1.1.9
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 13:05:59 -0500
linux-patch-manager (1.1.8-1) unstable; urgency=low
* Release v1.1.8
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 11:47:58 -0500
linux-patch-manager (1.1.7-1) unstable; urgency=low
* Release v1.1.7
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 09:11:11 -0500
linux-patch-manager (1.1.6-1) unstable; urgency=low
* Release v1.1.6
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 08:10:52 -0500
linux-patch-manager (1.1.5-1) unstable; urgency=low
* Release v1.1.5
-- git-echo <git-echo@moon-dragon.us> Mon, 08 Jun 2026 20:15:50 -0500
linux-patch-manager (1.1.4-1) unstable; urgency=low
* Release v1.1.4
-- git-echo <git-echo@moon-dragon.us> Mon, 08 Jun 2026 17:30:35 -0500
linux-patch-manager (1.1.2-1) unstable; urgency=low
* Release v1.1.2
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 21:19:18 -0500
linux-patch-manager (1.1.1-1) unstable; urgency=low
* Release v1.1.1
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 18:55:59 -0500
linux-patch-manager (1.1.0-1) unstable; urgency=low linux-patch-manager (1.1.0-1) unstable; urgency=low
* Release v1.1.0 * Release v1.1.0

5
debian/control vendored
View File

@ -1,9 +1,10 @@
Package: linux-patch-manager Package: linux-patch-manager
Version: 1.1.14-1 Version: 1.1.0-1
Architecture: amd64 Architecture: amd64
Maintainer: Moon Dragon <echo@moon-dragon.us> Maintainer: Moon Dragon <echo@moon-dragon.us>
Installed-Size: 45000 Installed-Size: 45000
Depends: postgresql-16, openssl, curl, cron | cron-daemon, util-linux, 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 Recommends: postgresql-client-16, fonts-dejavu-core
Suggests: gpg Suggests: gpg
Section: admin Section: admin

408
debian/postinst vendored
View File

@ -5,13 +5,8 @@ set -e
# Linux Patch Manager — Post-install script # Linux Patch Manager — Post-install script
# ============================================================================= # =============================================================================
# Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb # Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb
# results in a running HTTPS service. The service generates and prints the # results in a running service with a printed admin password.
# initial admin password to its journal on first startup.
#
# All steps are idempotent (safe to re-run on upgrade). # All steps are idempotent (safe to re-run on upgrade).
# Migrations are handled by the application via sqlx on startup.
# This script: system user, dirs, DB/role, config, JWT keys, CA + web TLS
# cert, sqlx checksum repair (for upgrades from <= 1.1.7), services.
# ============================================================================= # =============================================================================
RED='\033[0;31m' RED='\033[0;31m'
@ -27,36 +22,32 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
DB_NAME="patch_manager" DB_NAME="patch_manager"
DB_USER="patch_manager" DB_USER="patch_manager"
CONFIG_DIR="/etc/patch-manager" CONFIG_DIR="/etc/patch-manager"
SERVICE_USER="patch-manager" MIGRATION_DIR="/usr/share/patch-manager/migrations"
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
# Secure temp file for passing the DB password between functions.
# (Was a predictable, world-readable /tmp/.pm-db-password-new.)
PM_TMP="$(umask 077 && mktemp /run/pm-postinst.XXXXXX)"
cleanup() { rm -f "${PM_TMP}"; }
trap cleanup EXIT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# PostgreSQL helpers — runuser instead of sudo (sudo is not guaranteed # PostgreSQL helpers
# to exist and is discouraged in maintainer scripts).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
psql_run() { psql_run() {
runuser -u postgres -- psql -v ON_ERROR_STOP=1 "$@" 2>/dev/null # Run SQL as the postgres superuser
sudo -u postgres psql -v ON_ERROR_STOP=1 "$@" 2>/dev/null
} }
psql_run_db() { psql_run_db() {
runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d "${DB_NAME}" "$@" 2>/dev/null # 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) # 1. Create service user (idempotent)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
create_service_user() { create_service_user() {
if ! id "${SERVICE_USER}" &>/dev/null; then if ! id patch-manager &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin \ useradd --system --no-create-home --shell /usr/sbin/nologin \
--comment "Linux Patch Manager service account" "${SERVICE_USER}" --comment "Linux Patch Manager service account" patch-manager
info "Service user '${SERVICE_USER}' created." info "Service user 'patch-manager' created."
else else
info "Service user '${SERVICE_USER}' already exists." info "Service user 'patch-manager' already exists."
fi fi
} }
@ -69,72 +60,57 @@ create_directories() {
/var/log/patch-manager /opt/patch-manager \ /var/log/patch-manager /opt/patch-manager \
/var/backups/patch-manager /var/backups/patch-manager
chown -R "${SERVICE_USER}:${SERVICE_USER}" \ chown -R patch-manager:patch-manager \
"${CONFIG_DIR}" /var/log/patch-manager /opt/patch-manager "${CONFIG_DIR}" /var/log/patch-manager \
/opt/patch-manager /usr/share/patch-manager/frontend
# Frontend assets stay root-owned and read-only: the service must not be
# able to rewrite the JavaScript it serves (persistence vector if pm-web
# is ever compromised). pm-web only reads these files.
chown -R root:root /usr/share/patch-manager/frontend
chmod -R a+rX /usr/share/patch-manager/frontend
chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt" chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt"
chmod 700 /var/backups/patch-manager chmod 700 /var/backups/patch-manager
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. Wait for PostgreSQL to be ready (non-fatal failure is impossible here: # 3. Wait for PostgreSQL to be ready
# postgresql-16 is a hard dependency and its packaging starts the cluster,
# but give it time on slow first boots).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
wait_for_postgresql() { wait_for_postgresql() {
info "Waiting for PostgreSQL to be ready..." info "Waiting for PostgreSQL to be ready..."
local retries=30
local delay=2
local i local i
for ((i = 1; i <= 30; i++)); do for ((i = 1; i <= retries; i++)); do
if pg_isready -q 2>/dev/null; then if pg_isready -q 2>/dev/null; then
info "PostgreSQL is ready." info "PostgreSQL is ready."
return 0 return 0
fi fi
warn "PostgreSQL not ready yet (attempt ${i}/30), waiting 2s..." warn "PostgreSQL not ready yet (attempt ${i}/${retries}), waiting ${delay}s..."
sleep 2 sleep "${delay}"
done done
error "PostgreSQL did not become ready after 60 seconds." error "PostgreSQL did not become ready after $((retries * delay)) seconds."
return 1 return 1
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. Create PostgreSQL user, database, and grants (idempotent) # 4. Create PostgreSQL user and database (idempotent)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
setup_database() { setup_database() {
info "Setting up PostgreSQL database and user..." info "Setting up PostgreSQL database and user..."
# Generate a random password for the DB user
local db_password local db_password
db_password=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32) db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
# Create role if not exists
local role_exists local role_exists
role_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" 2>/dev/null || echo "") 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 if [[ "${role_exists}" != "1" ]]; then
psql_run -c "CREATE ROLE ${DB_USER} LOGIN PASSWORD '${db_password}';" psql_run -c "CREATE ROLE ${DB_USER} LOGIN PASSWORD '${db_password}';"
info "PostgreSQL user '${DB_USER}' created." info "PostgreSQL user '${DB_USER}' created."
printf '%s' "${db_password}" > "${PM_TMP}" # Store password for config generation
echo "${db_password}" > /tmp/.pm-db-password-new
else else
info "PostgreSQL user '${DB_USER}' already exists." info "PostgreSQL user '${DB_USER}' already exists, skipping creation."
local config_file="${CONFIG_DIR}/config.toml"
local existing_pw=""
if [[ -f "${config_file}" ]]; then
existing_pw=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1)
fi
if [[ -n "${existing_pw}" && "${existing_pw}" != "CHANGEME" ]]; then
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${existing_pw}';" 2>/dev/null || true
printf '%s' "${existing_pw}" > "${PM_TMP}"
info "Synced DB password from existing config to PostgreSQL."
else
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
printf '%s' "${db_password}" > "${PM_TMP}"
info "Generated new DB password for existing user."
fi
fi fi
# Create database if not exists
local db_exists local db_exists
db_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null || echo "") 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 if [[ "${db_exists}" != "1" ]]; then
@ -144,178 +120,180 @@ setup_database() {
info "Database '${DB_NAME}' already exists, skipping creation." info "Database '${DB_NAME}' already exists, skipping creation."
fi fi
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true # Grant permissions (idempotent)
psql_run_db -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true 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 psql_run_db -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" 2>/dev/null || true
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. Repair sqlx migration checksums (upgrade from <= 1.1.7 only). # 5. Apply database migrations (idempotent)
#
# Commit 4cac290 (v1.1.8) rewrote migrations 001/003/007/011/016 in place.
# sqlx::migrate! validates the SHA-384 checksum recorded in _sqlx_migrations
# against the embedded file on every startup, so any database migrated
# before 1.1.8 fails with "migration N was previously applied but has been
# modified" and pm-web crash-loops.
#
# The schema produced by old and new versions of these files is identical
# (the rewrite only added IF-NOT-EXISTS guards), so it is safe to update the
# recorded checksum to match the current embedded content. We only touch
# rows whose checksum matches the known OLD value — fresh installs and
# already-repaired databases are untouched.
#
# NEVER edit an applied migration again; append a new one instead.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
repair_migration_checksums() { apply_migrations() {
# Skip if the migrations table doesn't exist yet (fresh install). info "Applying database migrations..."
local has_table
has_table=$(psql_run_db -t -A -c "SELECT to_regclass('public._sqlx_migrations') IS NOT NULL;" 2>/dev/null || echo "f")
[[ "${has_table}" == "t" ]] || { info "No _sqlx_migrations table (fresh install) — checksum repair not needed."; return 0; }
info "Checking for stale sqlx migration checksums (pre-1.1.8 databases)..." # Ensure pgcrypto extension is available
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
# version | old sha384 | new sha384 # Create migration tracking table if not exists
local repairs=( psql_run_db <<'MIGSQL'
"1|3e7492a3fdedf177d0467b495717b1b3f75894cabbd57a8aa0a0fa2189bacb8832ce69f400356c5f717cc3cd87c47685|2828d99226ba5017e754a8a72bce4f7f6d5535172817c2ba90d8ea9b93ff488787ec840cf43cc0eca7e1f526c8a1c0ef" CREATE TABLE IF NOT EXISTS _migrations (
"3|097ed9107821f2de4dd3f18b1c1b25aa3110ca4134fe8c37b81049d064357266a8bae05e84edf163bb77a88335943640|e7b6d63f244764184f127234e5b7735c9ea9ee810bd465b06bc9c74370358b56061cec137b7afee91c5417211561164c" id SERIAL PRIMARY KEY,
"7|f3218a5b94ae9e009655e66cd4808a7abebdfae81675bccfbb079567df192f95cd62eab3b6c4d60b06e8a7dcbb1d9297|95c087779e952333094e6d9e058aaa18dc8a898b480152ba7a6d780ccfd3c562525834ea49f19999b1266b0acb9b68e9" filename TEXT NOT NULL UNIQUE,
"11|5bb3dabf7caf0a7c31998e74b03b312d1867a948643084b9253acabe0388bc00831d2a157379eacae971298fa9bc481e|f07ac98ae905b9a9730ec302aafbcce3d56eacf80a8fb0904a9cfd0e7c36fdc38bb21129054d6f0765131ce6fa1cdc2e" applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
"16|e5450925e570cbd522cf5edd6990f5e8e010466c494bc39484fcf72651037440083a104eea0d217d65c5625b91bfb514|11d77a7097fe3a212786c55cf5c0b64c44a792ce18ace665aec4336efee1544f41568c0ba4605b9daedbf4ac12e91e6f" );
) MIGSQL
local entry version old new updated # Handle upgrade from pre-migration-tracking versions:
for entry in "${repairs[@]}"; do # If tables exist but _migrations is empty, mark all existing migrations as applied.
IFS='|' read -r version old new <<< "${entry}" local migration_count
updated=$(psql_run_db -q -t -A -c \ migration_count=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
"UPDATE _sqlx_migrations SET checksum = '\\x${new}'::bytea \ migration_count="${migration_count// /}"
WHERE version = ${version} AND checksum = '\\x${old}'::bytea \
RETURNING version;" 2>/dev/null || echo "") local tables_exist
if [[ -n "${updated}" ]]; then 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")
info "Repaired checksum for migration ${version}." 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 fi
done 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. Write config.toml with DB URL # 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() { write_config() {
local config_file="${CONFIG_DIR}/config.toml" local config_file="${CONFIG_DIR}/config.toml"
local db_password=""
[[ -s "${PM_TMP}" ]] && db_password=$(cat "${PM_TMP}")
if [[ -f "${config_file}" ]]; then if [[ -f "${config_file}" ]]; then
if grep -q 'CHANGEME' "${config_file}"; then info "Config file ${config_file} already exists, not overwriting."
if [[ -z "${db_password}" ]]; then
db_password=$(openssl rand -base64 48 | 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 return 0
fi fi
else
if [[ -z "${db_password}" ]]; then
db_password=$(openssl rand -base64 48 | 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..." 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}" # 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 fi
chown "${SERVICE_USER}:${SERVICE_USER}" "${config_file}" # 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}" chmod 640 "${config_file}"
info "Configuration written to ${config_file}" info "Configuration written to ${config_file}"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. Generate JWT keys (idempotent) # 8. Generate JWT keys (idempotent)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
generate_jwt_keys() { generate_jwt_keys() {
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
info "Generating Ed25519 JWT signing key..." info "Generating Ed25519 JWT signing key..."
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null 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 openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem" chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
chmod 600 "${CONFIG_DIR}/jwt/signing.pem" chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem" chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT keys generated." 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 "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}/jwt/verify.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT verification key regenerated."
else else
info "JWT keys already exist, skipping." info "JWT signing key already exists, skipping."
fi
}
# ---------------------------------------------------------------------------
# 8. Generate internal CA and CA-signed web TLS certificate (idempotent).
#
# This was previously only done by scripts/setup.sh, which the package does
# not ship — so .deb installs had no /etc/patch-manager/tls/web.{crt,key},
# pm-web fell back to PLAIN HTTP on port 443, and the old HTTPS-only
# health check could never pass. Generating these here makes the package
# self-contained and HTTPS-by-default.
# ---------------------------------------------------------------------------
generate_tls_certs() {
local ca_key="${CONFIG_DIR}/ca/ca.key"
local ca_cert="${CONFIG_DIR}/ca/ca.crt"
local tls_cert="${CONFIG_DIR}/tls/web.crt"
local tls_key="${CONFIG_DIR}/tls/web.key"
local tls_csr="${CONFIG_DIR}/tls/web.csr"
if [[ ! -f "${ca_cert}" ]]; then
info "Generating internal Certificate Authority (ECDSA P-256, 10-year validity)..."
openssl ecparam -genkey -name prime256v1 -noout -out "${ca_key}"
# Convert SEC1 → PKCS#8 (the Rust pm-ca crate only parses PKCS#8).
openssl pkcs8 -topk8 -nocrypt -in "${ca_key}" -out "${ca_key}.tmp" && mv "${ca_key}.tmp" "${ca_key}"
openssl req -new -x509 -key "${ca_key}" -out "${ca_cert}" \
-days 3650 \
-subj "/CN=Patch Manager Root CA/O=Patch Manager" \
-addext "basicConstraints=critical,CA:true" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
chown "${SERVICE_USER}:${SERVICE_USER}" "${ca_key}" "${ca_cert}"
chmod 600 "${ca_key}"
chmod 644 "${ca_cert}"
info "Internal CA generated."
else
info "Internal CA already exists, skipping."
fi
if [[ ! -f "${tls_cert}" ]]; then
info "Generating CA-signed web server certificate (valid 365 days)..."
local fqdn short host_ip san
fqdn=$(hostname -f 2>/dev/null || echo "localhost")
short=$(hostname -s 2>/dev/null || echo "localhost")
host_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || true)
san="DNS:${fqdn},DNS:${short},DNS:localhost,IP:127.0.0.1,IP:::1"
[[ -n "${host_ip}" ]] && san="${san},IP:${host_ip}"
openssl ecparam -genkey -name prime256v1 -noout -out "${tls_key}"
openssl req -new -key "${tls_key}" -out "${tls_csr}" \
-subj "/CN=${fqdn}/O=Patch Manager" \
-addext "subjectAltName=${san}"
openssl x509 -req -in "${tls_csr}" -CA "${ca_cert}" -CAkey "${ca_key}" \
-CAcreateserial -days 365 -out "${tls_cert}" \
-extfile <(printf "subjectAltName=%s\nextendedKeyUsage=serverAuth\n" "${san}")
rm -f "${tls_csr}"
chown "${SERVICE_USER}:${SERVICE_USER}" "${tls_cert}" "${tls_key}"
chmod 644 "${tls_cert}"
chmod 600 "${tls_key}"
info "CA-signed web server certificate generated for ${fqdn}."
else
info "TLS certificate already exists, skipping."
fi fi
} }
@ -324,9 +302,11 @@ generate_tls_certs() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
enable_and_start_services() { enable_and_start_services() {
systemctl daemon-reload systemctl daemon-reload
systemctl enable patch-manager.target 2>/dev/null || true
systemctl enable patch-manager-web.service patch-manager-worker.service 2>/dev/null || true
# 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 if systemctl is-active --quiet patch-manager.target 2>/dev/null; then
info "Restarting patch-manager services (upgrade)..." info "Restarting patch-manager services (upgrade)..."
systemctl restart patch-manager.target 2>/dev/null || true systemctl restart patch-manager.target 2>/dev/null || true
@ -337,45 +317,7 @@ enable_and_start_services() {
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 10. Verify the service came up — ADVISORY ONLY, never fails the install. # 10. Install backup cron (idempotent)
# A failed probe prints diagnostics instead of leaving dpkg half-configured.
# ---------------------------------------------------------------------------
report_service_status() {
info "Waiting up to 30s for pm-web to come up (migrations run on startup)..."
local i
for ((i = 1; i <= 30; i++)); do
if curl -skf https://localhost:443/ >/dev/null 2>&1 || \
curl -skf https://localhost:8443/ >/dev/null 2>&1 || \
curl -sf http://localhost:443/ >/dev/null 2>&1 || \
curl -sf http://localhost:8443/ >/dev/null 2>&1; then
echo ""
echo -e "${CYAN}=================================================================${NC}"
echo -e "${GREEN} Linux Patch Manager is up.${NC}"
echo ""
echo -e " Web UI: ${GREEN}https://$(hostname -f 2>/dev/null || echo localhost)/${NC}"
echo -e " Username: ${GREEN}admin${NC}"
echo -e " Password: generated on first startup — retrieve it with:"
echo -e " ${YELLOW}journalctl -u patch-manager-web | grep -A2 'INITIAL ADMIN PASSWORD' | tail -3${NC}"
echo ""
echo -e " You will be forced to change it on first login."
echo -e " The internal CA cert (for trusting the web UI) is at:"
echo -e " ${CONFIG_DIR}/ca/ca.crt"
echo -e "${CYAN}=================================================================${NC}"
echo ""
return 0
fi
sleep 1
done
warn "pm-web did not respond within 30s. The install itself succeeded;"
warn "the service may still be starting, or it may have failed. Check:"
warn " systemctl status patch-manager-web.service"
warn " journalctl -u patch-manager-web -n 50"
return 0
}
# ---------------------------------------------------------------------------
# 11. Install backup cron (idempotent)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
install_backup_cron() { install_backup_cron() {
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
@ -393,14 +335,16 @@ case "$1" in
create_directories create_directories
wait_for_postgresql wait_for_postgresql
setup_database setup_database
repair_migration_checksums apply_migrations
generate_admin_password
write_config write_config
generate_jwt_keys generate_jwt_keys
generate_tls_certs
enable_and_start_services enable_and_start_services
report_service_status
install_backup_cron install_backup_cron
# Clean up temp file
rm -f /tmp/.pm-db-password-new
info "Linux Patch Manager installation complete." info "Linux Patch Manager installation complete."
;; ;;

View File

@ -8,7 +8,7 @@
services: services:
db: db:
image: postgres:16 image: postgres:16-bookworm
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: patch_manager POSTGRES_USER: patch_manager

View File

@ -1,7 +1,7 @@
{ {
"name": "patch-manager-ui", "name": "patch-manager-ui",
"private": true, "private": true,
"version": "1.1.14", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -12,43 +12,13 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search on host names
-- Enumerations -- Enumerations
-- ============================================================ -- ============================================================
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
CREATE TYPE user_role AS ENUM ('admin', 'operator'); CREATE TYPE user_role AS ENUM ('admin', 'operator');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'auth_provider') THEN
CREATE TYPE auth_provider AS ENUM ('local', 'azure_sso'); CREATE TYPE auth_provider AS ENUM ('local', 'azure_sso');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'host_health_status') THEN
CREATE TYPE host_health_status AS ENUM ('pending', 'healthy', 'degraded', 'unreachable'); CREATE TYPE host_health_status AS ENUM ('pending', 'healthy', 'degraded', 'unreachable');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_status') THEN
CREATE TYPE job_status AS ENUM ('queued', 'pending', 'running', 'succeeded', 'failed', 'cancelled'); CREATE TYPE job_status AS ENUM ('queued', 'pending', 'running', 'succeeded', 'failed', 'cancelled');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_kind') THEN
CREATE TYPE job_kind AS ENUM ('patch_apply', 'patch_remove', 'reboot', 'rollback'); CREATE TYPE job_kind AS ENUM ('patch_apply', 'patch_remove', 'reboot', 'rollback');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'window_recurrence') THEN
CREATE TYPE window_recurrence AS ENUM ('once', 'daily', 'weekly', 'monthly'); CREATE TYPE window_recurrence AS ENUM ('once', 'daily', 'weekly', 'monthly');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cert_status') THEN
CREATE TYPE cert_status AS ENUM ('active', 'revoked', 'expired'); CREATE TYPE cert_status AS ENUM ('active', 'revoked', 'expired');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM ( CREATE TYPE audit_action AS ENUM (
'user_login', 'user_logout', 'user_login_failed', 'user_login', 'user_logout', 'user_login_failed',
'user_created', 'user_deleted', 'user_updated', 'user_created', 'user_deleted', 'user_updated',
@ -61,14 +31,12 @@ DO $$ BEGIN
'config_changed', 'config_changed',
'discovery_scan_started' 'discovery_scan_started'
); );
END IF;
END $$;
-- ============================================================ -- ============================================================
-- Groups (defined before users/hosts for FK ordering) -- Groups (defined before users/hosts for FK ordering)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS groups ( CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
@ -76,13 +44,13 @@ CREATE TABLE IF NOT EXISTS groups (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_groups_name ON groups (name); CREATE INDEX idx_groups_name ON groups (name);
-- ============================================================ -- ============================================================
-- Users -- Users
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
@ -105,28 +73,28 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); CREATE INDEX idx_users_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_users_azure_oid ON users (azure_oid) WHERE azure_oid IS NOT NULL; CREATE INDEX idx_users_azure_oid ON users (azure_oid) WHERE azure_oid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role); CREATE INDEX idx_users_role ON users (role);
-- ============================================================ -- ============================================================
-- User <-> Group membership -- User <-> Group membership
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS user_groups ( CREATE TABLE user_groups (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, group_id) PRIMARY KEY (user_id, group_id)
); );
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups (group_id); CREATE INDEX idx_user_groups_group ON user_groups (group_id);
-- ============================================================ -- ============================================================
-- Refresh Tokens -- Refresh Tokens
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Stored as Argon2id hash of the opaque token bytes -- Stored as Argon2id hash of the opaque token bytes
@ -141,14 +109,14 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
ip_address INET ip_address INET
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens (user_id); CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at) WHERE revoked = FALSE; CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at) WHERE revoked = FALSE;
-- ============================================================ -- ============================================================
-- Hosts -- Hosts
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS hosts ( CREATE TABLE hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fqdn TEXT NOT NULL, fqdn TEXT NOT NULL,
ip_address INET NOT NULL, ip_address INET NOT NULL,
@ -168,28 +136,28 @@ CREATE TABLE IF NOT EXISTS hosts (
CONSTRAINT hosts_fqdn_ip_unique UNIQUE (fqdn, ip_address) CONSTRAINT hosts_fqdn_ip_unique UNIQUE (fqdn, ip_address)
); );
CREATE INDEX IF NOT EXISTS idx_hosts_health_status ON hosts (health_status); CREATE INDEX idx_hosts_health_status ON hosts (health_status);
CREATE INDEX IF NOT EXISTS idx_hosts_fqdn ON hosts USING gin (fqdn gin_trgm_ops); CREATE INDEX idx_hosts_fqdn ON hosts USING gin (fqdn gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_hosts_ip ON hosts (ip_address); CREATE INDEX idx_hosts_ip ON hosts (ip_address);
-- ============================================================ -- ============================================================
-- Host <-> Group membership -- Host <-> Group membership
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS host_groups ( CREATE TABLE host_groups (
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (host_id, group_id) PRIMARY KEY (host_id, group_id)
); );
CREATE INDEX IF NOT EXISTS idx_host_groups_group ON host_groups (group_id); CREATE INDEX idx_host_groups_group ON host_groups (group_id);
-- ============================================================ -- ============================================================
-- Host Health Data (cached results from 5-min polls) -- Host Health Data (cached results from 5-min polls)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS host_health_data ( CREATE TABLE host_health_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -198,14 +166,14 @@ CREATE TABLE IF NOT EXISTS host_health_data (
payload JSONB NOT NULL DEFAULT '{}' payload JSONB NOT NULL DEFAULT '{}'
); );
CREATE INDEX IF NOT EXISTS idx_host_health_host ON host_health_data (host_id, polled_at DESC); CREATE INDEX idx_host_health_host ON host_health_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker) -- Retained for 30 days (pruned by worker)
-- ============================================================ -- ============================================================
-- Host Patch Data (cached results from 30-min polls) -- Host Patch Data (cached results from 30-min polls)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS host_patch_data ( CREATE TABLE host_patch_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -216,14 +184,14 @@ CREATE TABLE IF NOT EXISTS host_patch_data (
cve_count INTEGER NOT NULL DEFAULT 0 cve_count INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_host_patch_host ON host_patch_data (host_id, polled_at DESC); CREATE INDEX idx_host_patch_host ON host_patch_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker) -- Retained for 30 days (pruned by worker)
-- ============================================================ -- ============================================================
-- Maintenance Windows -- Maintenance Windows
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS maintenance_windows ( CREATE TABLE maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
@ -239,14 +207,14 @@ CREATE TABLE IF NOT EXISTS maintenance_windows (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_mw_host ON maintenance_windows (host_id); CREATE INDEX idx_mw_host ON maintenance_windows (host_id);
CREATE INDEX IF NOT EXISTS idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE; CREATE INDEX idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE;
-- ============================================================ -- ============================================================
-- Patch Jobs -- Patch Jobs
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS patch_jobs ( CREATE TABLE patch_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind job_kind NOT NULL DEFAULT 'patch_apply', kind job_kind NOT NULL DEFAULT 'patch_apply',
status job_status NOT NULL DEFAULT 'queued', status job_status NOT NULL DEFAULT 'queued',
@ -265,15 +233,15 @@ CREATE TABLE IF NOT EXISTS patch_jobs (
completed_at TIMESTAMPTZ completed_at TIMESTAMPTZ
); );
CREATE INDEX IF NOT EXISTS idx_patch_jobs_status ON patch_jobs (status); CREATE INDEX idx_patch_jobs_status ON patch_jobs (status);
CREATE INDEX IF NOT EXISTS idx_patch_jobs_created ON patch_jobs (created_at DESC); CREATE INDEX idx_patch_jobs_created ON patch_jobs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_patch_jobs_user ON patch_jobs (created_by_user_id); CREATE INDEX idx_patch_jobs_user ON patch_jobs (created_by_user_id);
-- ============================================================ -- ============================================================
-- Patch Job Hosts (per-host status within a batch job) -- Patch Job Hosts (per-host status within a batch job)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS patch_job_hosts ( CREATE TABLE patch_job_hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES patch_jobs(id) ON DELETE CASCADE, job_id UUID NOT NULL REFERENCES patch_jobs(id) ON DELETE CASCADE,
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
@ -289,15 +257,15 @@ CREATE TABLE IF NOT EXISTS patch_job_hosts (
UNIQUE (job_id, host_id) UNIQUE (job_id, host_id)
); );
CREATE INDEX IF NOT EXISTS idx_pjh_job ON patch_job_hosts (job_id); CREATE INDEX idx_pjh_job ON patch_job_hosts (job_id);
CREATE INDEX IF NOT EXISTS idx_pjh_host ON patch_job_hosts (host_id); CREATE INDEX idx_pjh_host ON patch_job_hosts (host_id);
CREATE INDEX IF NOT EXISTS idx_pjh_status ON patch_job_hosts (status); CREATE INDEX idx_pjh_status ON patch_job_hosts (status);
-- ============================================================ -- ============================================================
-- Certificates -- Certificates
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS certificates ( CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- NULL = root CA cert -- NULL = root CA cert
host_id UUID REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID REFERENCES hosts(id) ON DELETE CASCADE,
@ -311,15 +279,15 @@ CREATE TABLE IF NOT EXISTS certificates (
cert_pem TEXT NOT NULL cert_pem TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_certs_host ON certificates (host_id); CREATE INDEX idx_certs_host ON certificates (host_id);
CREATE INDEX IF NOT EXISTS idx_certs_status ON certificates (status); CREATE INDEX idx_certs_status ON certificates (status);
CREATE INDEX IF NOT EXISTS idx_certs_expires ON certificates (expires_at); CREATE INDEX idx_certs_expires ON certificates (expires_at);
-- ============================================================ -- ============================================================
-- Audit Log (tamper-evident, hash-chained) -- Audit Log (tamper-evident, hash-chained)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
action audit_action NOT NULL, action audit_action NOT NULL,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
@ -334,17 +302,17 @@ CREATE TABLE IF NOT EXISTS audit_log (
row_hash TEXT NOT NULL DEFAULT '' row_hash TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log (created_at DESC); CREATE INDEX idx_audit_created ON audit_log (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_user_id); CREATE INDEX idx_audit_actor ON audit_log (actor_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); CREATE INDEX idx_audit_action ON audit_log (action);
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log (target_type, target_id); CREATE INDEX idx_audit_target ON audit_log (target_type, target_id);
-- Retained for 6 months (pruned by worker) -- Retained for 6 months (pruned by worker)
-- ============================================================ -- ============================================================
-- Azure SSO Configuration -- Azure SSO Configuration
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS azure_sso_config ( CREATE TABLE azure_sso_config (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
enabled BOOLEAN NOT NULL DEFAULT FALSE, enabled BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
@ -361,7 +329,7 @@ CREATE TABLE IF NOT EXISTS azure_sso_config (
-- System Configuration (key/value runtime settings) -- System Configuration (key/value runtime settings)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS system_config ( CREATE TABLE system_config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL, value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
@ -383,14 +351,13 @@ INSERT INTO system_config (key, value, description) VALUES
('smtp_from', '', 'From address for notifications'), ('smtp_from', '', 'From address for notifications'),
('smtp_tls_mode', 'starttls', 'SMTP TLS mode: none, starttls, tls'), ('smtp_tls_mode', 'starttls', 'SMTP TLS mode: none, starttls, tls'),
('web_tls_strategy', 'internal_ca', 'Web UI TLS cert strategy: internal_ca or operator_supplied'), ('web_tls_strategy', 'internal_ca', 'Web UI TLS cert strategy: internal_ca or operator_supplied'),
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all') ('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all');
ON CONFLICT (key) DO NOTHING;
-- ============================================================ -- ============================================================
-- Worker Heartbeat -- Worker Heartbeat
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS worker_heartbeat ( CREATE TABLE worker_heartbeat (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
worker_version TEXT NOT NULL DEFAULT '', worker_version TEXT NOT NULL DEFAULT '',
@ -401,7 +368,7 @@ CREATE TABLE IF NOT EXISTS worker_heartbeat (
-- Discovery Results (transient; cleared before each scan) -- Discovery Results (transient; cleared before each scan)
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS discovery_results ( CREATE TABLE discovery_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL, scan_id UUID NOT NULL,
ip_address INET NOT NULL, ip_address INET NOT NULL,
@ -414,5 +381,5 @@ CREATE TABLE IF NOT EXISTS discovery_results (
registered BOOLEAN NOT NULL DEFAULT FALSE registered BOOLEAN NOT NULL DEFAULT FALSE
); );
CREATE INDEX IF NOT EXISTS idx_discovery_scan ON discovery_results (scan_id); CREATE INDEX idx_discovery_scan ON discovery_results (scan_id);
CREATE INDEX IF NOT EXISTS idx_discovery_ip ON discovery_results (ip_address); CREATE INDEX idx_discovery_ip ON discovery_results (ip_address);

View File

@ -8,11 +8,11 @@
-- When the retry engine should next attempt this host; NULL = not scheduled -- When the retry engine should next attempt this host; NULL = not scheduled
ALTER TABLE patch_job_hosts ALTER TABLE patch_job_hosts
ADD COLUMN IF NOT EXISTS retry_next_at TIMESTAMPTZ; ADD COLUMN retry_next_at TIMESTAMPTZ;
-- Last failure reason captured by the worker for display in the UI -- Last failure reason captured by the worker for display in the UI
ALTER TABLE patch_job_hosts ALTER TABLE patch_job_hosts
ADD COLUMN IF NOT EXISTS last_error TEXT; ADD COLUMN last_error TEXT;
-- ============================================================ -- ============================================================
-- pg_notify trigger: fires when an immediate job is inserted -- pg_notify trigger: fires when an immediate job is inserted
@ -30,21 +30,15 @@ BEGIN
END; END;
$$; $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_job_enqueued'
) THEN
CREATE TRIGGER trg_job_enqueued CREATE TRIGGER trg_job_enqueued
AFTER INSERT ON patch_jobs AFTER INSERT ON patch_jobs
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION notify_job_enqueued(); EXECUTE FUNCTION notify_job_enqueued();
END IF;
END $$;
-- ============================================================ -- ============================================================
-- Index: efficiently find hosts due for retry -- Index: efficiently find hosts due for retry
-- ============================================================ -- ============================================================
CREATE INDEX IF NOT EXISTS idx_pjh_retry CREATE INDEX idx_pjh_retry
ON patch_job_hosts (retry_next_at) ON patch_job_hosts (retry_next_at)
WHERE retry_next_at IS NOT NULL; WHERE retry_next_at IS NOT NULL;

View File

@ -1,7 +1,7 @@
-- Migration 007: Health check configuration and results -- Migration 007: Health check configuration and results
-- Health checks configured per host (1-5 per host) -- Health checks configured per host (1-5 per host)
CREATE TABLE IF NOT EXISTS host_health_checks ( CREATE TABLE host_health_checks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
@ -27,10 +27,10 @@ CREATE TABLE IF NOT EXISTS host_health_checks (
) )
); );
CREATE INDEX IF NOT EXISTS idx_health_checks_host ON host_health_checks (host_id); CREATE INDEX idx_health_checks_host ON host_health_checks (host_id);
-- Health check poll results (4-day retention, pruned by worker) -- Health check poll results (4-day retention, pruned by worker)
CREATE TABLE IF NOT EXISTS host_health_check_results ( CREATE TABLE host_health_check_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_id UUID NOT NULL REFERENCES host_health_checks(id) ON DELETE CASCADE, check_id UUID NOT NULL REFERENCES host_health_checks(id) ON DELETE CASCADE,
healthy BOOLEAN NOT NULL, healthy BOOLEAN NOT NULL,
@ -39,4 +39,4 @@ CREATE TABLE IF NOT EXISTS host_health_check_results (
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_health_results_check ON host_health_check_results (check_id, checked_at DESC); CREATE INDEX idx_health_results_check ON host_health_check_results (check_id, checked_at DESC);

View File

@ -4,7 +4,7 @@
-- FK with ON DELETE SET NULL: if target host deleted, revert to default. -- FK with ON DELETE SET NULL: if target host deleted, revert to default.
ALTER TABLE host_health_checks ALTER TABLE host_health_checks
ADD COLUMN IF NOT EXISTS target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL; ADD COLUMN target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_health_checks_target_host ON host_health_checks (target_host_id) CREATE INDEX idx_health_checks_target_host ON host_health_checks (target_host_id)
WHERE target_host_id IS NOT NULL; WHERE target_host_id IS NOT NULL;

View File

@ -1,7 +1,7 @@
-- Migration: 016_enrollment_requests -- Migration: 016_enrollment_requests
-- Description: Create enrollment_requests table for host self-enrollment -- Description: Create enrollment_requests table for host self-enrollment
CREATE TABLE IF NOT EXISTS enrollment_requests ( CREATE TABLE enrollment_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
machine_id TEXT NOT NULL UNIQUE, machine_id TEXT NOT NULL UNIQUE,
fqdn TEXT NOT NULL, fqdn TEXT NOT NULL,
@ -12,5 +12,5 @@ CREATE TABLE IF NOT EXISTS enrollment_requests (
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours' expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
); );
CREATE INDEX IF NOT EXISTS idx_enrollment_requests_token ON enrollment_requests (polling_token); CREATE INDEX idx_enrollment_requests_token ON enrollment_requests (polling_token);
CREATE INDEX IF NOT EXISTS idx_enrollment_requests_expires ON enrollment_requests (expires_at); CREATE INDEX idx_enrollment_requests_expires ON enrollment_requests (expires_at);

View File

@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="1.1.14" VERSION="1.1.0"
RELEASE="1" RELEASE="1"
PKG_NAME="linux-patch-manager" PKG_NAME="linux-patch-manager"
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb" DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"

View File

@ -1,44 +0,0 @@
# Linux Patch Manager — Roadmap
Future work items for the Linux Patch Manager project. Items here are candidates
for future PRs — promote them to GitHub/Gitea issues when ready to work them.
---
## How to use this file
- **Add items** under the appropriate section (or create a new section).
- **Reference issues/PRs** when known: `(#NN)` at the end of the line.
- **Promote to issues** when ready to start work — link the issue back to this file.
- **Mark completed items** with `~~strikethrough~~` and the PR/issue that closed them.
- **Don't delete completed items** — keep the audit trail.
---
## Packaging & Install
_(empty — add items here)_
## CI/CD
_(empty — add items here)_
## Security
_(empty — add items here)_
## Infrastructure
_(empty — add items here)_
## Documentation
_(empty — add items here)_
## Refactoring
_(empty — add items here)_
---
_Last updated: 2026-06-10 — created `tasks/roadmap.md`_