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

View File

@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "1.1.14"
version = "1.1.0"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
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
ENV DEBIAN_FRONTEND=noninteractive
FROM rust:1.82-bookworm AS rust-builder
RUN apt-get update && apt-get install -y \
build-essential \
curl \
pkg-config \
libssl-dev \
libfontconfig1-dev \
&& 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
# Cache dependencies by building a dummy project first
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 \
crates/pm-agent-client/src crates/pm-auth/src crates/pm-ca/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
COPY crates/ crates/
COPY migrations/ migrations/
RUN cargo build --release
# 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
# ---------------------------------------------------------------------------
# Stage 2: Frontend build (Ubuntu 24.04 + Node.js 20)
# Stage 2: Frontend build
# ---------------------------------------------------------------------------
FROM ubuntu:24.04 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/*
FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /usr/src/app/frontend
COPY frontend/package.json frontend/package-lock.json ./
@ -93,7 +64,6 @@ RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3t64 \
libfontconfig1 \
openssl \
postgresql-client-16 \
argon2 \
curl \

View File

@ -49,8 +49,7 @@ health_check_poll_interval_secs = 300
# Maximum concurrent mTLS agent calls (Tokio Semaphore)
max_concurrent_agent_calls = 64
# Worker heartbeat write interval (seconds). Default: 300 = 5 minutes
heartbeat_interval_secs = 300
# Worker heartbeat write interval (seconds)
# 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

View File

@ -101,8 +101,7 @@ pub struct WorkerConfig {
pub health_check_poll_interval_secs: u64,
/// Maximum concurrent agent calls
pub max_concurrent_agent_calls: usize,
/// Worker heartbeat interval in seconds (default: 300 = 5 min)
#[serde(default = "default_heartbeat_interval")]
/// Worker heartbeat interval in seconds
pub heartbeat_interval_secs: u64,
/// WS relay HTTP polling fallback interval in seconds (default: 10)
pub ws_relay_poll_interval_secs: u64,
@ -256,10 +255,6 @@ fn default_health_check_poll_interval() -> u64 {
300
}
fn default_heartbeat_interval() -> u64 {
300
}
fn default_sso_callback_url() -> 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
* Release v1.1.0

5
debian/control vendored
View File

@ -1,9 +1,10 @@
Package: linux-patch-manager
Version: 1.1.14-1
Version: 1.1.0-1
Architecture: amd64
Maintainer: Moon Dragon <echo@moon-dragon.us>
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
Suggests: gpg
Section: admin

414
debian/postinst vendored
View File

@ -5,13 +5,8 @@ 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 HTTPS service. The service generates and prints the
# initial admin password to its journal on first startup.
#
# results in a running service with a printed admin password.
# All steps are idempotent (safe to re-run on upgrade).
# Migrations are handled by the application via sqlx on startup.
# This script: 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'
@ -27,36 +22,32 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
DB_NAME="patch_manager"
DB_USER="patch_manager"
CONFIG_DIR="/etc/patch-manager"
SERVICE_USER="patch-manager"
# 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
MIGRATION_DIR="/usr/share/patch-manager/migrations"
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
# ---------------------------------------------------------------------------
# PostgreSQL helpers — runuser instead of sudo (sudo is not guaranteed
# to exist and is discouraged in maintainer scripts).
# PostgreSQL helpers
# ---------------------------------------------------------------------------
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() {
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)
# ---------------------------------------------------------------------------
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 \
--comment "Linux Patch Manager service account" "${SERVICE_USER}"
info "Service user '${SERVICE_USER}' created."
--comment "Linux Patch Manager service account" patch-manager
info "Service user 'patch-manager' created."
else
info "Service user '${SERVICE_USER}' already exists."
info "Service user 'patch-manager' already exists."
fi
}
@ -69,72 +60,57 @@ create_directories() {
/var/log/patch-manager /opt/patch-manager \
/var/backups/patch-manager
chown -R "${SERVICE_USER}:${SERVICE_USER}" \
"${CONFIG_DIR}" /var/log/patch-manager /opt/patch-manager
# 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
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 (non-fatal failure is impossible here:
# postgresql-16 is a hard dependency and its packaging starts the cluster,
# but give it time on slow first boots).
# 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 <= 30; i++)); do
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}/30), waiting 2s..."
sleep 2
warn "PostgreSQL not ready yet (attempt ${i}/${retries}), waiting ${delay}s..."
sleep "${delay}"
done
error "PostgreSQL did not become ready after 60 seconds."
error "PostgreSQL did not become ready after $((retries * delay)) seconds."
return 1
}
# ---------------------------------------------------------------------------
# 4. Create PostgreSQL user, database, and grants (idempotent)
# 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 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
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."
printf '%s' "${db_password}" > "${PM_TMP}"
# Store password for config generation
echo "${db_password}" > /tmp/.pm-db-password-new
else
info "PostgreSQL user '${DB_USER}' already exists."
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
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
@ -144,178 +120,180 @@ setup_database() {
info "Database '${DB_NAME}' already exists, skipping creation."
fi
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
psql_run_db -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
# 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
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).
#
# 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.
# 5. Apply database migrations (idempotent)
# ---------------------------------------------------------------------------
repair_migration_checksums() {
# Skip if the migrations table doesn't exist yet (fresh install).
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; }
apply_migrations() {
info "Applying database migrations..."
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
local repairs=(
"1|3e7492a3fdedf177d0467b495717b1b3f75894cabbd57a8aa0a0fa2189bacb8832ce69f400356c5f717cc3cd87c47685|2828d99226ba5017e754a8a72bce4f7f6d5535172817c2ba90d8ea9b93ff488787ec840cf43cc0eca7e1f526c8a1c0ef"
"3|097ed9107821f2de4dd3f18b1c1b25aa3110ca4134fe8c37b81049d064357266a8bae05e84edf163bb77a88335943640|e7b6d63f244764184f127234e5b7735c9ea9ee810bd465b06bc9c74370358b56061cec137b7afee91c5417211561164c"
"7|f3218a5b94ae9e009655e66cd4808a7abebdfae81675bccfbb079567df192f95cd62eab3b6c4d60b06e8a7dcbb1d9297|95c087779e952333094e6d9e058aaa18dc8a898b480152ba7a6d780ccfd3c562525834ea49f19999b1266b0acb9b68e9"
"11|5bb3dabf7caf0a7c31998e74b03b312d1867a948643084b9253acabe0388bc00831d2a157379eacae971298fa9bc481e|f07ac98ae905b9a9730ec302aafbcce3d56eacf80a8fb0904a9cfd0e7c36fdc38bb21129054d6f0765131ce6fa1cdc2e"
"16|e5450925e570cbd522cf5edd6990f5e8e010466c494bc39484fcf72651037440083a104eea0d217d65c5625b91bfb514|11d77a7097fe3a212786c55cf5c0b64c44a792ce18ace665aec4336efee1544f41568c0ba4605b9daedbf4ac12e91e6f"
)
# 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
local entry version old new updated
for entry in "${repairs[@]}"; do
IFS='|' read -r version old new <<< "${entry}"
updated=$(psql_run_db -q -t -A -c \
"UPDATE _sqlx_migrations SET checksum = '\\x${new}'::bytea \
WHERE version = ${version} AND checksum = '\\x${old}'::bytea \
RETURNING version;" 2>/dev/null || echo "")
if [[ -n "${updated}" ]]; then
info "Repaired checksum for migration ${version}."
# 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. 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() {
local config_file="${CONFIG_DIR}/config.toml"
local db_password=""
[[ -s "${PM_TMP}" ]] && db_password=$(cat "${PM_TMP}")
if [[ -f "${config_file}" ]]; then
if grep -q 'CHANGEME' "${config_file}"; then
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
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..."
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}"
info "Config file ${config_file} already exists, not overwriting."
return 0
fi
chown "${SERVICE_USER}:${SERVICE_USER}" "${config_file}"
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}"
}
# ---------------------------------------------------------------------------
# 7. Generate JWT keys (idempotent)
# 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 "${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 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT keys generated."
elif [[ ! -f "${CONFIG_DIR}/jwt/verify.pem" ]]; then
info "Regenerating missing JWT verification key from existing signing key..."
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}/jwt/verify.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT verification key regenerated."
else
info "JWT keys already exist, skipping."
fi
}
# ---------------------------------------------------------------------------
# 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."
info "JWT signing key already exists, skipping."
fi
}
@ -324,9 +302,11 @@ generate_tls_certs() {
# ---------------------------------------------------------------------------
enable_and_start_services() {
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
info "Restarting patch-manager services (upgrade)..."
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.
# 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)
# 10. Install backup cron (idempotent)
# ---------------------------------------------------------------------------
install_backup_cron() {
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
@ -393,14 +335,16 @@ case "$1" in
create_directories
wait_for_postgresql
setup_database
repair_migration_checksums
apply_migrations
generate_admin_password
write_config
generate_jwt_keys
generate_tls_certs
enable_and_start_services
report_service_status
install_backup_cron
# Clean up temp file
rm -f /tmp/.pm-db-password-new
info "Linux Patch Manager installation complete."
;;

View File

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

View File

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

View File

@ -12,63 +12,31 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search on host names
-- Enumerations
-- ============================================================
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
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');
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');
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');
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');
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');
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');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'user_login', 'user_logout', 'user_login_failed',
'user_created', 'user_deleted', 'user_updated',
'host_registered', 'host_removed',
'group_created', 'group_deleted',
'group_membership_changed',
'patch_job_created', 'patch_job_cancelled', 'patch_job_rollback',
'maintenance_window_created', 'maintenance_window_updated', 'maintenance_window_deleted',
'certificate_issued', 'certificate_renewed', 'certificate_revoked', 'certificate_downloaded',
'config_changed',
'discovery_scan_started'
);
END IF;
END $$;
CREATE TYPE user_role AS ENUM ('admin', 'operator');
CREATE TYPE auth_provider AS ENUM ('local', 'azure_sso');
CREATE TYPE host_health_status AS ENUM ('pending', 'healthy', 'degraded', 'unreachable');
CREATE TYPE job_status AS ENUM ('queued', 'pending', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TYPE job_kind AS ENUM ('patch_apply', 'patch_remove', 'reboot', 'rollback');
CREATE TYPE window_recurrence AS ENUM ('once', 'daily', 'weekly', 'monthly');
CREATE TYPE cert_status AS ENUM ('active', 'revoked', 'expired');
CREATE TYPE audit_action AS ENUM (
'user_login', 'user_logout', 'user_login_failed',
'user_created', 'user_deleted', 'user_updated',
'host_registered', 'host_removed',
'group_created', 'group_deleted',
'group_membership_changed',
'patch_job_created', 'patch_job_cancelled', 'patch_job_rollback',
'maintenance_window_created', 'maintenance_window_updated', 'maintenance_window_deleted',
'certificate_issued', 'certificate_renewed', 'certificate_revoked', 'certificate_downloaded',
'config_changed',
'discovery_scan_started'
);
-- ============================================================
-- 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(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
@ -76,13 +44,13 @@ CREATE TABLE IF NOT EXISTS groups (
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
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
@ -105,28 +73,28 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_users_role ON users (role);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_azure_oid ON users (azure_oid) WHERE azure_oid IS NOT NULL;
CREATE INDEX idx_users_role ON users (role);
-- ============================================================
-- 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,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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
-- ============================================================
CREATE TABLE IF NOT EXISTS refresh_tokens (
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Stored as Argon2id hash of the opaque token bytes
@ -141,14 +109,14 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
ip_address INET
);
CREATE INDEX IF NOT EXISTS 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_user ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at) WHERE revoked = FALSE;
-- ============================================================
-- Hosts
-- ============================================================
CREATE TABLE IF NOT EXISTS hosts (
CREATE TABLE hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fqdn TEXT 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)
);
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_hosts_ip ON hosts (ip_address);
CREATE INDEX idx_hosts_health_status ON hosts (health_status);
CREATE INDEX idx_hosts_fqdn ON hosts USING gin (fqdn gin_trgm_ops);
CREATE INDEX idx_hosts_ip ON hosts (ip_address);
-- ============================================================
-- 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,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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)
-- ============================================================
CREATE TABLE IF NOT EXISTS host_health_data (
CREATE TABLE host_health_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -198,14 +166,14 @@ CREATE TABLE IF NOT EXISTS host_health_data (
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)
-- ============================================================
-- 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(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
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
);
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)
-- ============================================================
-- Maintenance Windows
-- ============================================================
CREATE TABLE IF NOT EXISTS maintenance_windows (
CREATE TABLE maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
@ -239,14 +207,14 @@ CREATE TABLE IF NOT EXISTS maintenance_windows (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS 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_host ON maintenance_windows (host_id);
CREATE INDEX idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE;
-- ============================================================
-- Patch Jobs
-- ============================================================
CREATE TABLE IF NOT EXISTS patch_jobs (
CREATE TABLE patch_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind job_kind NOT NULL DEFAULT 'patch_apply',
status job_status NOT NULL DEFAULT 'queued',
@ -265,15 +233,15 @@ CREATE TABLE IF NOT EXISTS patch_jobs (
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_patch_jobs_user ON patch_jobs (created_by_user_id);
CREATE INDEX idx_patch_jobs_status ON patch_jobs (status);
CREATE INDEX idx_patch_jobs_created ON patch_jobs (created_at DESC);
CREATE INDEX idx_patch_jobs_user ON patch_jobs (created_by_user_id);
-- ============================================================
-- 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(),
job_id UUID NOT NULL REFERENCES patch_jobs(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)
);
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_pjh_status ON patch_job_hosts (status);
CREATE INDEX idx_pjh_job ON patch_job_hosts (job_id);
CREATE INDEX idx_pjh_host ON patch_job_hosts (host_id);
CREATE INDEX idx_pjh_status ON patch_job_hosts (status);
-- ============================================================
-- Certificates
-- ============================================================
CREATE TABLE IF NOT EXISTS certificates (
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- NULL = root CA cert
host_id UUID REFERENCES hosts(id) ON DELETE CASCADE,
@ -311,15 +279,15 @@ CREATE TABLE IF NOT EXISTS certificates (
cert_pem TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_certs_host ON certificates (host_id);
CREATE INDEX IF NOT EXISTS idx_certs_status ON certificates (status);
CREATE INDEX IF NOT EXISTS idx_certs_expires ON certificates (expires_at);
CREATE INDEX idx_certs_host ON certificates (host_id);
CREATE INDEX idx_certs_status ON certificates (status);
CREATE INDEX idx_certs_expires ON certificates (expires_at);
-- ============================================================
-- Audit Log (tamper-evident, hash-chained)
-- ============================================================
CREATE TABLE IF NOT EXISTS audit_log (
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
action audit_action NOT 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 ''
);
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS 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_created ON audit_log (created_at DESC);
CREATE INDEX idx_audit_actor ON audit_log (actor_user_id);
CREATE INDEX idx_audit_action ON audit_log (action);
CREATE INDEX idx_audit_target ON audit_log (target_type, target_id);
-- Retained for 6 months (pruned by worker)
-- ============================================================
-- Azure SSO Configuration
-- ============================================================
CREATE TABLE IF NOT EXISTS azure_sso_config (
CREATE TABLE azure_sso_config (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
enabled BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id TEXT NOT NULL DEFAULT '',
@ -361,7 +329,7 @@ CREATE TABLE IF NOT EXISTS azure_sso_config (
-- System Configuration (key/value runtime settings)
-- ============================================================
CREATE TABLE IF NOT EXISTS system_config (
CREATE TABLE system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@ -383,14 +351,13 @@ INSERT INTO system_config (key, value, description) VALUES
('smtp_from', '', 'From address for notifications'),
('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'),
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all')
ON CONFLICT (key) DO NOTHING;
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all');
-- ============================================================
-- Worker Heartbeat
-- ============================================================
CREATE TABLE IF NOT EXISTS worker_heartbeat (
CREATE TABLE worker_heartbeat (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
worker_version TEXT NOT NULL DEFAULT '',
@ -401,7 +368,7 @@ CREATE TABLE IF NOT EXISTS worker_heartbeat (
-- 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(),
scan_id UUID NOT NULL,
ip_address INET NOT NULL,
@ -414,5 +381,5 @@ CREATE TABLE IF NOT EXISTS discovery_results (
registered BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS 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_scan ON discovery_results (scan_id);
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
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
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
@ -30,21 +30,15 @@ BEGIN
END;
$$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_job_enqueued'
) THEN
CREATE TRIGGER trg_job_enqueued
AFTER INSERT ON patch_jobs
FOR EACH ROW
EXECUTE FUNCTION notify_job_enqueued();
END IF;
END $$;
CREATE TRIGGER trg_job_enqueued
AFTER INSERT ON patch_jobs
FOR EACH ROW
EXECUTE FUNCTION notify_job_enqueued();
-- ============================================================
-- 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)
WHERE retry_next_at IS NOT NULL;

View File

@ -1,7 +1,7 @@
-- Migration 007: Health check configuration and results
-- 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(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
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)
CREATE TABLE IF NOT EXISTS host_health_check_results (
CREATE TABLE host_health_check_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_id UUID NOT NULL REFERENCES host_health_checks(id) ON DELETE CASCADE,
healthy BOOLEAN NOT NULL,
@ -39,4 +39,4 @@ CREATE TABLE IF NOT EXISTS host_health_check_results (
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.
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;

View File

@ -1,7 +1,7 @@
-- Migration: 016_enrollment_requests
-- 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(),
machine_id TEXT NOT NULL UNIQUE,
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'
);
CREATE INDEX IF NOT EXISTS 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_token ON enrollment_requests (polling_token);
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; }
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="1.1.14"
VERSION="1.1.0"
RELEASE="1"
PKG_NAME="linux-patch-manager"
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`_